从事务ACID到分布式事务(二)

本篇接上一篇事务文章,上篇介绍了事务的ACID特性,介绍了隔离级别以及简单讨论了MVVC,接下来就是分布式事务的讨论。

先回顾一下之前的知识点:

事务的一个关键特性就是保证一致性,因此在单个数据源或者说在单个节点上时,你可以利用数据库系统已经封装好的特性来完成想要事务。比如数据库本地可以利用Redo Log来保证原子性和持久性,利用本地加锁实现隔离级别,当然目前常见的是MVVC方式。

但是,当一个事务包含多个服务或者多个数据源时,很难确保同时都成功,或是同时都失败的语义。那为什么在分布式环境下保证事务这么难呢?因为在分布式系统中,故障和失效是不可避免的。但也请不要忘了,构建一个容错的系统,也是分布式的目标之一。

CAP理论

提到这,不免要提一下大家常说的CAP理论,三个字母分别代表一致性(Consistency),可用性(Availability),分区容忍性(Partition Tolerance)。

  • 一致性代表着一个系统看起来好像只有一个数据副本,且所有操作都是原子的。这里的一致性和事务隔离级别有些相似性,但也有显著区别,事务隔离主要是为了处理并发执行事务时的各种临界条件,而分布式一致性则是针对延迟和故障等问题来协调副本之间的状态。
  • 可用性代表着有着不中断提供服务的能力,虽然可能访问到的数据不是最新的。
  • 对于分区容忍性,我们首先要明白一点,网络分区并不是一个特性,它是一个故障,导致网络分区的可能很多,譬如网络延迟,节点崩溃等等。虽然你可以构建高可靠的网络,但并不能避免这个问题。因此对于分布式系统,出现了故障,我们就要容忍它,其它部分组成的系统仍能正确地提供服务。

正如前面所说,网络是不可靠的,甚至硬件环境也充满了各种意外,机架意外断电,内存条松动,电缆被意外挖断……这些都将导致节点失效。当这些问题无法避免的会发生地时候,各个节点或者数据分区之间的数据同步就可能出现问题,副本和副本之间就可能复制滞后,那么这对一致性提出了挑战。故障发生后,我们要自动剔除失效节点,做节点切换,必要情况下还需要选举新的主节点(共识问题),有些时候我们还有可能会加入新的节点,因此我们的服务才具备高可用性。网络故障导致某个分区或节点的失效,那么存储在这个分区或者节点的数据必然是不可用的,因此数据上我们在多个节点上有备份,依赖副本和副本之间的复制来保证数据的同步和一致性,这样才能容忍分区问题。

上面的一段话,说了一大堆,好像是一个”怪圈”,绕了一圈又回来了。

再总结下,我们需要在节点失效时,整个系统还需要对外完整提供服务,那各个节点之间要做数据同步和备份,此时给一致性带了无法避免的挑战,因为不管是主主复制,还是主从复制都没法避免网络中断带来的破坏一致性。失效节点切换时,或者说在做主节点选举时,候选节点必须要和原主节点数据差异最小,这样才能避免数据丢失或者不一致的问题,自动切换可能还会带来”脑裂“问题,同样会数据不一致。

所以最后CAP理论就告诉我们说,上述的三个特性最多只能同时满足其中两个。其实我们还可以这样理解:在分布式系统中,既然网络分区(故障)无法避免且必须满足,那你选择一致性还是可用性???

强一致性

原子性保证(强一致性),就是要满足要么成功,要么失败。如果单节点的事务,则只需要先将事务记录写入日志,就算节点中途崩溃,也可以从日志记录中恢复事务。如果涉及多个节点的事务,就没有那么简单。两阶段提交(2PC,Two-Phase Commit),就是一种实现多节点之间事务原子提交的算法。

两阶段提交

2PC引入了一个协调者概念,或者叫做事务管理器,这个角色一般由提出事务请求的服务来担当,当然也可以是一个独立进程或服务来担当这个角色。还有一种角色就是分布式事务的参与者,这里我们指执行事务的数据库节点。2PC一般分为以下两个阶段:

  1. 协调者向所有参与者发起询问,即准备请求,询问它们是否可以提交事务。并且协调者会跟踪每个节点的回复。如果有某一个节点回答“否”,那协调者向所有的节点发送放弃事务的请求。
  2. 如果所有节点在准备阶段回复了“是”,那接下来协调者向所有节点发出提交请求,开始实际提交事务。

相对于直接单一提交请求来说,我们有一个准备阶段,当所有参与者都回答“是”,此时这就是一个承诺,后续的事务提交,该节点就必须保证成功,而单一提交请求就没有这种保证。当第二阶段开始执行时,即使某个参与者失败,协调者也要多次重试直到成功,因为它不能违背之前的承诺,这就像我们生活中泼出去的水,不可能再有回旋的余地。因此对于所有参与者来说,当它在准备阶段投出了宝贵的一票,那就没有反悔的机会,即使失败不断重试也要贯彻执行。

从上面我们看到了协调者在这里发挥的重要作用,但是如果协调者发生失败或者故障呢?阶段2时某一个参与者收到了提交请求,但是此时协调者故障了,那对于其它参与者来说,是放弃事务还是提交事务呢?无论参与者选择什么,都可能出现数据的不一致。因此对于参与者来说,它是不能单方面做决定的,因此必须要等到协调者恢复过来,从日志中继续恢复未完成的事务。

从上面的介绍,我相信你看到2PC的缺点了。它是一个阻塞式的原子提交协议,所有参与者必须按部就班行事,一旦协调者出现问题,可能导致参与者一直处于阻塞状态,并且“茫然无措”。

此时,我们就引入一个改进版本:三阶段提交(3PC,Three-Phase Commit)。

三阶段提交

顾名思义,就是从两个阶段变成了三个阶段:

  1. Can Commit。向所有的参与者询问下是否能够正常执行事务,参与者会预先检查下资源情况,来判断是否可以执行事务,这个过程是一个轻量级操作。
  2. PreCommit。此过程就是类似2PC的第一个阶段。如果上一个阶段所有参与者回答“是”,则向所有的节点发送执行事务(但是不提交)请求。如果有一个或多个参与者执行事务失败,或者存在参与者超时没有回复,则协调者发送放弃事务请求。
  3. Do Commit。这个阶段就是真正提交事务阶段,前提肯定是第二个阶段的所有参与者都正常执行完了事务,此阶段协调者向所有参与者发送提交请求,参与者向协调者报告提交结果。如果此阶段,协调者因为网络原因或者自身故障,没有发出提交或者回滚的请求,参与者都会在等待超时之后,继续执行事务提交。

我们看到,虽然3PC加入了第一个阶段来减少事务执行失败的可能性,并且引入超时机制来避免整个系统阻塞。但是网络中仅仅依靠超时,它并不是一个可靠的故障检测机制。可能此时刚好你的协调者进程正在FULL GC,但因为最终参与者超时之后都会提交事务,此时依然有数据不一致的问题。正是因为我们无法创造出一个能在规定时间内响应的网络,目前大多数系统依旧采取2PC。

最终一致性

正是因为要实现一个完全一致性的分布式系统,是异常复杂的。我们看到,就连现代的处理器都不满足这个特性,一个CPU修改了某个内存地址的值,紧接着另外一个CPU核读取时,并不保证最新值,除非使用内存屏障。满足一致性也就必然会导致性能急剧下降。因此这也是目前为什么大多数系统选择放弃一致性,来满足对性能追求。

放弃一致性并不是意味着一致性的完全破坏,而是提供最终一致性。如果停止更新数据库,则一段时间后(未知),最终所有的读请求都会返回相同的内容,而不会出现不同结果情况;也就是说最终结果都会收敛。我们也把最终一致性的事务模型称为“柔性事务”,与强一致性的“刚性事务”对应。下面就是介绍常见的柔性事务模式。

TCC事务模式

TCC分布式事务模式,它是“Try-Confirm-Cancel”三个单词的缩写。它分为以下三个阶段。

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

TCC 有点类似 2PC 的准备阶段和提交阶段。该事务模式是不依赖于数据库层面对事务的支持,它是应用层的层面上的,因此对业务代码的侵入性比较高。

SAGA 事务模式

SAGA 由两部分操作组成,分别是子事务和补偿动作。

  • 大事务拆分若干个小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为。
  • 为每一个子事务设计对应的补偿动作,命名为 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。

SAGA为我们提供了一种思路,事务并不是一定要回滚,而是可以进行补偿。

总结

上述的分布式事务模式大可不必自己手动编写,可以借助框架来处理,比如阿里的SEATA。事务我们分了两篇来梳理,特别是在分布式场景下,事务变的异常复杂, 不仅涉及到数据复制,还有共识算法(比如Paxos,Raft,Zab),其中又涉及到全序关系广播算法。总之,事务所做的一切都是在为一致性服务,我们要根据实际的业务场景,来选择合适的模式,到底是刚性事务还是柔性事务。