3.4.4 SAGA事务
TCC事务具有较强的隔离性,避免了“超售”的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。TCC的最主要限制是它的业务侵入性很强,这里并不是重复上一节提到的它需要开发编码配合所带来的工作量的限制,而是指它所要求的技术可控性上的约束。譬如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能消费,允许直接在购物时通过U盾或扫码支付,在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以TCC中的第一步Try阶段往往无法施行。我们只能考虑采用另外一种柔性事务方案:SAGA事务。SAGA在英文中是“长篇故事、长篇记叙、一长串事件”的意思。
SAGA事务模式的历史十分悠久,还早于分布式事务概念的提出。它源于1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem在ACM发表的一篇论文“SAGAS”[1]。
文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本SAGA的目的是避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。SAGA由两部分操作组成。
·将大事务拆分成若干个小事务,将整个分布式事务T分解为n个子事务,命名为T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为原子行为。如果分布式事务能够正常提交,其对数据的影响(即最终一致性)应与连续按顺序成功提交Ti等价。
·为每一个子事务设计对应的补偿动作,命名为C1,C2,…,Ci,…,Cn。Ti与Ci必须满足以下条件。
·Ti与Ci都具备幂等性。
·Ti与Ci满足交换律(Commutative),即无论先执行Ti还是先执行Ci,其效果都是一样的。
·Ci必须能成功提交,即不考虑Ci本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者被人工介入为止。
如果T1到Tn均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一。
·正向恢复(Forward Recovery):如果Ti事务提交失败,则一直对Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti,(重试)…,Ti+1,…,Tn。
·反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。
与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。譬如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到Fenix’s Bookstore系统中,这步是经由用户支付操作(扫码或U盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销之前的用户转账操作,但是由Fenix’s Bookstore系统将货款转回到用户账号上作为补偿措施却是完全可行的。
SAGA必须保证所有子事务都得以提交或者补偿,但SAGA系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程严谨地进行也需要花费不少工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA事务通常也不会直接靠裸编码来实现,一般是在事务中间件的基础上完成,前面提到的Seata就同样支持SAGA事务模式。
基于数据补偿来代替回滚的思路,还可以应用在其他事务方案上,这些方案笔者就不再单独展开,放到这里一起来解释。举个具体例子,阿里的GTS(Global Transaction Service,Seata由GTS开源而来)所提出的“AT事务模式”就是这样的应用。
从整体上看,AT事务是参照了XA两段提交协议实现的,但对于XA 2PC的缺陷,即在准备阶段必须等待所有数据源都返回成功后,协调者才能统一发出Commit命令而导致的木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放),AT事务设计了针对性的解决方案。大致的做法是在业务数据提交时自动拦截所有SQL,将SQL对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向SQL”。基于这种补偿方式,分布式事务中涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。这种异步提交的模式,相比2PC极大地提升了系统的吞吐量水平,而代价就是大幅度牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚并不是总能成功的。譬如,在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),这时候一旦分布式事务需要回滚,就不可能再通过自动的逆向SQL来实现补偿,只能由人工介入处理了。
通常来说,脏写是一定要避免的,所有传统关系数据库在最低的隔离级别上都仍然要加锁以避免脏写,因为脏写情况一旦发生,其实也很难通过人工进行有效处理。所以GTS增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待。这种设计以牺牲一定性能为代价,避免了两个分布式事务中包含的本地事务修改同一个数据的情况,从而避免脏写。在读隔离方面,AT事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能产生脏读(Dirty Read)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。
[1] 下载地址:https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf。