learn-tech/专栏/周志明的架构课/15_分布式事务之TCC与SAGA.md
2024-10-16 06:37:41 +08:00

15 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        15 _ 分布式事务之TCC与SAGA
                        你好,我是周志明。

今天我们接着上一节课的话题继续讨论另外两种主流的分布式事务实现方式TCC和SAGA。

TCC事务的实现过程

TCCTry-Confirm-Cancel是除可靠消息队列以外的另一种常见的分布式事务机制它是由数据库专家帕特 · 赫兰德Pat Helland在2007年撰写的论文《Life beyond Distributed Transactions: An Apostates Opinion》中提出的。

在上一讲我给你介绍了可靠消息队列的实现原理虽然它也能保证最终的结果是相对可靠的过程也足够简单相对于TCC来说但现在你已经知道可靠消息队列的整个实现过程完全没有任何隔离性可言。

虽然在有些业务中有没有隔离性不是很重要比如说搜索系统。但在有些业务中一旦缺乏了隔离性就会带来许多麻烦。比如说前几讲我一直引用的Fenixs Bookstore在线书店的场景事例中如果缺乏了隔离性就会带来一个显而易见的问题超售。

事例场景Fenixs Bookstore是一个在线书店。一份商品成功售出需要确保以下三件事情被正确地处理

用户的账号扣减相应的商品款项; 商品仓库中扣减库存,将商品标识为待配送状态; 商家的账号增加相应的商品款项。

也就是说,在书店的业务场景下,很有可能会出现这样的情况:两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和,却超过了库存。

如果这件事情是发生在刚性事务且隔离级别足够的情况下其实是可以完全避免的。比如我前面提到的“超售”场景就需要“可重复读”Repeatable Read的隔离级别以保证后面提交的事务会因为无法获得锁而导致失败。但用可靠消息队列就无法保证这一点了。我在第12讲中已经给你介绍过数据库本地事务的相关知识你可以再去回顾复习下。

所以如果业务需要隔离我们通常就应该重点考虑TCC方案它天生适合用于需要强隔离性的分布式事务中。

在具体实现上TCC的操作其实有点儿麻烦和复杂它是一种业务侵入性较强的事务方案要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。另外你看名字也能看出来TCC的实现过程分为了三个阶段

Try尝试执行阶段完成所有业务可执行性的检查保障一致性并且预留好事务需要用到的所有业务资源保障隔离性。 Confirm确认执行阶段不进行任何业务检查直接使用Try阶段准备的资源来完成业务处理。注意Confirm阶段可能会重复执行因此需要满足幂等性。 Cancel取消执行阶段释放Try阶段预留的业务资源。注意Cancel阶段也可能会重复执行因此也需要满足幂等性。

那么根据Fenixs Bookstore在线书店的场景事例TCC的执行过程应该是这样的

第一步最终用户向Fenixs Bookstore发送交易请求购买一本价值100元的《深入理解Java虚拟机》。

第二步创建事务生成事务ID记录在活动日志中进入Try阶段

用户服务检查业务可行性可行的话把该用户的100元设置为“冻结”状态通知下一步进入Confirm阶段不可行的话通知下一步进入Cancel阶段。 仓库服务检查业务可行性可行的话将该仓库的1本《深入理解Java虚拟机》设置为“冻结”状态通知下一步进入Confirm阶段不可行的话通知下一步进入Cancel阶段。 商家服务:检查业务可行性,不需要冻结资源。

第三步如果第二步中所有业务都反馈业务可行就将活动日志中的状态记录为Confirm进入Confirm阶段

用户服务完成业务操作扣减被冻结的100元。 仓库服务完成业务操作标记那1本冻结的书为出库状态扣减相应库存。 商家服务完成业务操作收款100元

第四步如果第三步的操作全部完成了事务就会宣告正常结束。而如果第三步中的任何一方出现了异常不论是业务异常还是网络异常都将会根据活动日志中的记录来重复执行该服务的Confirm操作即进行“最大努力交付”。

第五步如果是在第二步有任意一方反馈业务不可行或是任意一方出现了超时就将活动日志的状态记录为Cancel进入Cancel阶段

用户服务取消业务操作释放被冻结的100元。 仓库服务取消业务操作释放被冻结的1本书。 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。

第六步如果第五步全部完成了事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常不论是业务异常还是网络异常也都将会根据活动日志中的记录来重复执行该服务的Cancel操作即进行“最大努力交付”。

那么你从上述的操作执行过程中可以发现TCC其实有点类似于2PC的准备阶段和提交阶段但TCC是位于用户代码层面而不是在基础设施层面这就为它的实现带来了较高的灵活性我们可以根据需要设计资源锁定的粒度。

另外TCC在业务执行的时候只操作预留资源几乎不会涉及到锁和资源的争用所以它具有很高的性能潜力。

但是由于TCC的业务侵入性比较高需要开发编码配合在一定程度上增加了不少工作量也就给我们带来了一些使用上的弊端那就是我们需要投入更高的开发成本和更换事务实现方案的替换成本。

所以通常我们并不会完全靠裸编码来实现TCC而是会基于某些分布式事务中间件如阿里开源的Seata来完成以尽量减轻一些编码工作量。

现在你就已经知道了TCC事务具有较强的隔离性能够有效避免“超售”的问题而且它的性能可以说是包括可靠消息队列在内的几种柔性事务模式中最高的。但是TCC仍然不能满足所有的业务场景。

我在前面也提到了TCC最主要的限制是它的业务侵入性很强但并不是指由此给开发编码带来的工作量而是指它所要求的技术可控性上的约束。

比如说我们把这个书店的场景事例修改一下由于中国网络支付日益盛行在书店系统中现在用户和商家可以选择不再开设充值账号至少不会强求一定要先从银行充值到系统中才能进行消费而是允许在购物时直接通过U盾或扫码支付在银行账户中划转货款。

这个需求完全符合我们现在支付的习惯但这也给系统的事务设计增加了额外的限制如果用户、商家的账户余额由银行管理的话其操作权限和数据结构就不可能再随心所欲地自行定义了通常也就无法完成冻结款项、解冻、扣减这样的操作因为银行一般不会配合你的操作。所以在TCC的执行过程中第一步Try阶段往往就已经无法施行了。

那么我们就只能考虑采用另外一种柔性事务方案SAGA事务。

SAGA事务基于数据补偿代替回滚的解决思路

SAGA事务模式的历史十分悠久比分布式事务的概念提出还要更早。SAGA的意思是“长篇故事、长篇记叙、一长串事件”它起源于1987年普林斯顿大学的赫克托 · 加西亚 · 莫利纳Hector Garcia Molina和肯尼斯 · 麦克米伦Kenneth Salem在ACM发表的一篇论文《SAGAS》这就是论文的全名

文中提出了一种如何提升“长时间事务”Long Lived Transaction运作效率的方法大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出SAGA的目的是为了避免大事务长时间锁定数据库的资源后来才逐渐发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。

SAGA由两部分操作组成。

一部分是把大事务拆分成若干个小事务将整个分布式事务T分解为n个子事务我们命名为T1T2TiTn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务T能够正常提交那么它对数据的影响最终一致性就应该与连续按顺序成功提交子事务Ti等价。

另一部分是为每一个子事务设计对应的补偿动作我们命名为C1C2CiCn。Ti与Ci必须满足以下条件

Ti与Ci都具备幂等性 Ti与Ci满足交换律Commutative即不管是先执行Ti还是先执行Ci效果都是一样的 Ci必须能成功提交即不考虑Ci本身提交失败被回滚的情况如果出现就必须持续重试直至成功或者要人工介入。

如果T1到Tn均成功提交那么事务就可以顺利完成。否则我们就要采取以下两种恢复策略之一

正向恢复Forward Recovery如果Ti事务提交失败则一直对Ti进行重试直至成功为止最大努力交付。这种恢复方式不需要补偿适用于事务最终都要成功的场景比如在别人的银行账号中扣了款就一定要给别人发货。正向恢复的执行模式为T1T2Ti失败Ti重试Ti+1Tn。 反向恢复Backward Recovery如果Ti事务提交失败则一直执行Ci对Ti进行补偿直至成功为止最大努力交付。这里要求Ci必须在持续重试后执行成功。反向恢复的执行模式为T1T2Ti失败Ci补偿C2C1。

所以你能发现与TCC相比SAGA不需要为资源设计冻结状态和撤销冻结的操作补偿操作往往要比冻结操作容易实现得多。

我给你举个例子。我在前面提到的账户余额直接在银行维护的场景从银行划转货款到Fenixs Bookstore系统中这步是经由用户支付操作扫码或U盾来促使银行提供服务如果后续业务操作失败尽管我们无法要求银行撤销掉之前的用户转账操作但是作为补偿措施我们让Fenixs Bookstore系统将货款转回到用户账上却是完全可行的。

SAGA必须保证所有子事务都能够提交或者补偿但SAGA系统本身也有可能会崩溃所以它必须设计成与数据库类似的日志机制被称为SAGA Log以保证系统恢复后可以追踪到子事务的执行情况比如执行都到哪一步或者补偿到哪一步了。

另外你还要注意,尽管补偿操作通常比冻结/撤销更容易实现但要保证正向、反向恢复过程能严谨地进行也需要你花费不少的工夫。比如你可能需要通过服务编排、可靠事件队列等方式来完成。所以SAGA事务通常也不会直接靠裸编码来实现一般也是在事务中间件的基础上完成。我前面提到的Seata就同样支持SAGA事务模式。

还有SAGA基于数据补偿来代替回滚的思路也可以应用在其他事务方案上。举个例子阿里的GTSGlobal Transaction ServiceSeata由GTS开源而来所提出的“AT事务模式”就是这样的一种应用。

另一种应用模式AT事务

从整体上看AT事务是参照了XA两段提交协议来实现的但针对XA 2PC的缺陷即在准备阶段必须等待所有数据源都返回成功后协调者才能统一发出Commit命令而导致的木桶效应所有涉及到的锁和资源都需要等到最慢的事务完成后才能统一释放AT事务也设计了针对性的解决方案。

它大致的做法是在业务数据提交时自动拦截所有SQL分别保存SQL对数据修改前后结果的快照生成行锁通过本地事务一起提交到操作的数据源中这就相当于自动记录了重做和回滚日志。

如果分布式事务成功提交了那么我们后续只需清理每个数据源中对应的日志数据即可而如果分布式事务需要回滚就要根据日志数据自动产生用于补偿的“逆向SQL”。

所以基于这种补偿方式分布式事务中所涉及的每一个数据源都可以单独提交然后立刻释放锁和资源。AT事务这种异步提交的模式相比2PC极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性甚至直接影响到了原子性。因为在缺乏隔离性的前提下以补偿代替回滚不一定总能成功。

比如当在本地事务提交之后、分布式事务完成之前该数据被补偿之前又被其他操作修改过即出现了脏写Dirty Wirte而这个时候一旦出现分布式事务需要回滚就不可能再通过自动的逆向SQL来实现补偿只能由人工介入处理了。

一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。

所以GTS增加了一个“全局锁”Global Lock的机制来实现写隔离要求本地事务提交之前一定要先拿到针对修改记录的全局锁后才允许提交而在没有获得全局锁之前就必须一直等待。

这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。

另外在读隔离方面AT事务默认的隔离级别是读未提交Read Uncommitted这意味着可能会产生脏读Dirty Read。读隔离也可以采用全局锁的方案来解决但直接阻塞读取的话我们要付出的代价就非常大了一般并不会这样做。

所以到这里,你其实能发现,分布式事务中并没有能一揽子包治百病的解决办法,你只有因地制宜地选用合适的事务处理方案,才是唯一有效的做法。

小结

通过上一讲和今天这节课的学习我们已经知道CAP定理决定了C与A不可兼得传统的ACID强一致性在分布式环境中要想能保证一致性C就不得不牺牲可用性A。那么这个时候随着分布式系统中节点数量的增加整个系统发生服务中断的概率和时间都会随之增长。

所以我们只能退而求其次把“最终一致性”作为分布式架构下事务处理的目标。在这两节课中我给你介绍的可靠事件队列、TCC和SAGA都是实现最终一致性的三种主流模式。

一课一思

请你思考并对比可靠事件队列、TCC和SAGA三种事务实现的优缺点然后来总结一下它们各自适用的场景。

欢迎在留言区分享你的思考和见解。 如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。