# 分布式事务
本文为要点摘录,具体可参考《凤凰架构:构建可靠的大型分布式系统》周志明
# CAP与ACID
CAP定理(Consistency、Availability、Partition Tolerance Theorem),也称为Brewer定理,起源于2000年7月,是加州大学伯克利分校的Eric Brewer教授于“ACM分布式计算原理研讨会(PODC)”上提出的一个猜想。
两年后,麻省理工学院的Seth Gilbert和Nancy Lynch以严谨的数学推理证明了CAP猜想。自此,CAP正式从猜想变为分布计算领域所公认的著名定理。这个定理描述了在一个分布式系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个。
- 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、有多种细分类型的概念,以后讨论分布式共识算法时,我们还会提到一致性,但那种面向副本复制的一致性与这里面向数据库状态的一致性从严格意义来说并不完全等同,具体差别我们将在第6章再作探讨。
- 可用性(Availability):代表系统不间断地提供服务的能力。理解可用性要先理解与其密切相关的两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如99.9999%可用,即代表平均年故障修复时间为32秒。
- 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。
由于CAP定理已有严格的证明,本节不去探讨为何CAP不可兼得,而是直接分析舍弃C、A、P时所带来的不同影响。
- 如果放弃分区容忍性(CA without P),意味着我们将假设节点之间的通信永远是可靠的。永远可靠的通信在分布式系统中必定是不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以Oracle的RAC集群为例,它的每一个节点均有自己独立的SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据,通过共享磁盘的方式来避免出现网络分区。因而Oracle RAC虽然也是由多个实例组成的数据库,但它并不能称作分布式数据库。
- 如果放弃可用性(CP without A),意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题相当于退化到前面3.2节讨论的一个系统使用多个数据源的场景之中,我们可以通过2PC/3PC等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的情况一般出现在对数据质量要求很高的场合中,除了DTP模型的分布式数据库事务外,著名的HBase也属于CP系统。以HBase集群为例,假如某个RegionServer宕机了,这个RegionServer持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预先估计的。
- 如果放弃一致性(AP without C),意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的AP系统是目前设计分布式系统的主流选择,因为P是分布式网络的天然属性,你再不想要也无法丢弃;而A通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数NoSQL库和支持分布式的缓存框架都是AP系统,以Redis集群为例,如果某个Redis节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回客户端的是不一致的数据的情况。
# 可靠时间队列
最终一致性的概念是由eBay的系统架构师Dan Pritchett在2008年在ACM发表的论文“Base:An Acid Alternative”[1]中提出的,该论文总结了一种独立于ACID获得的强一致性之外的、使用BASE来达成一致性目的的途径。BASE分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)的缩写。BASE这种提法简直是把数据库科学家酷爱凑缩写的恶趣味发挥到淋漓尽致,不过有ACID vs BASE(酸vs碱)这个朗朗上口的梗,该论文的影响力的确传播得足够快。在这里笔者就不多谈BASE中的概念问题了,虽然调侃它是恶趣味,但这篇论文本身作为最终一致性的概念起源,并系统性地总结了一种针对分布式事务的技术手段,是非常有价值的。
依靠持续重试来保证可靠性的解决方案谈不上是Dan Pritchett的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字——“最大努力交付”(Best-Effort Delivery),譬如TCP协议中未收到ACK应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。
# TCC事务
依靠持续重试来保证可靠性的解决方案谈不上是Dan Pritchett的首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字——“最大努力交付”(Best-Effort Delivery),譬如TCP协议中未收到ACK应答自动重新发包的可靠性保障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的是将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息系统)来促使同一个分布式事务中的其他关联业务全部完成。
前面介绍的可靠消息队列虽然能保证最终结果的相对可靠性,过程也足够简单(相对于TCC来说),但整个过程完全没有任何隔离性可言,虽然在一些业务中隔离性是无关紧要的,但在有些业务中缺乏隔离性就会带来许多麻烦。譬如在本章的场景事例中,缺乏隔离性会带来的一个明显问题便是“超售”:如两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情属于刚性事务,且隔离级别足够时是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部分属于数据库本地事务方面的知识,可以参考前面的讲解。如果业务需要隔离,那架构师通常就应该重点考虑TCC方案,该方案天生适用于需要强隔离性的分布式事务中。
在具体实现上,TCC较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同TCC的名字所示,它分为以下三个阶段。
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需要用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。Confirm阶段可能会重复执行,因此本阶段执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放Try阶段预留的业务资源。Cancel阶段可能会重复执行,因此本阶段执行的操作也需要具备幂等性。
TCC其实有点类似2PC的准备阶段和提交阶段,但TCC是在用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是TCC也带来了更高的开发成本和业务侵入性,即更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现TCC,而是基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,尽量减轻一些编码工作量。
# 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不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。
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)。也可以采用全局锁的方案解决读隔离问题,但直接阻塞读取的话,代价就非常大了,一般不会这样做。由此可见,分布式事务中没有一揽子包治百病的解决办法,因地制宜地选用合适的事务处理方案才是唯一有效的做法。