Naive Introduction To Distributed Transaction

分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。由于本人才疏学浅,本文仅介绍一些浅显的做法,在效率上可能不佳。

Review ACID

  • Atomicity 原子性。原子性是指事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败。
  • Consistency 一致性。它通常是指数据库会强制某些应用程序定义的数据不变。举例说明:张三向李四转100元,转账前和转账后的数据是正确的状态,这就叫一致性,如果出现张三转出100元,李四账号没有增加100元这就出现了数据错误,就没有达到一致性。
  • Isolation 隔离性。事务的隔离性是多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离。
  • Durability 持久性。持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。

Concurrency Control

因为单机也可能会有并发的事务,所以在并发控制这里其实分布式事务和单机事务是比较相似的。通常而言,ACID 特性中的 Isolation 就会意味着 Serializable 可串行化的,这个词的意思是,多个并发的事务产生的结果,一定和他们以某个序列串行执行的结果一样,而不会出现所有串行序列的效果以外的结果。

举个栗子,事务 T1 把账户 X 的钱转 1 给 账户 Y。事务 T2 把账户 X 和账户 Y 的钱都打印出来。设 X 和 Y 初始都是 10。我们要求,T2 要么打印出 X 10 Y 10,要么打印出 X 9 Y 11,绝不允许其他情况如 X 9 Y 10 这种中间态。

Two-Phase Locking (2PL) 二阶段锁是可串行化的充分条件。他把一个事务分成两个阶段:

  • Growing。在这个阶段事务只能不断地获得锁,不能释放锁。
  • Shrinking。在这个阶段只能释放释放锁,不能再获取新的锁。

img

这玩意儿保证了一个锁被释放后,一定意味着相关的项目已经使用完毕,不再被这个事务操控,也就是不会重复拿回锁。

然而,2PL 有一个问题:级联回滚(Cascading Aborts)

如下所示,T1 释放锁之后,T2 事务开始被执行,T2 对 A 的操作是基于 T1 对 A 进行临时修改后的版本进行的,如果 T1 事务没有提交而是被 abort 了,那么 T2 必须跟着 T1 一起回滚(如果 T2 进行的是读操作,那么这也被称为脏读,”dirty reads”)。

img

这件事发生的本质原因在于,T1 还没结束就允许别的事务获取它用过的锁,进而读到它还没提交的事务结果。如果 T1 提交成功了那当然很好,如果没成功,那别的读到了没提交的结果的事务就也得跟着回滚。

解决方法也很简单,严格二阶段锁(Strong Strict 2PL,简称SS2PL)规定,在事务提交后再释放所有锁。期间别的事务就不能操作相关的数据了。缺点就是并发程度进一步下降。

img

至此,我们完成了最傻瓜的并发控制,至于实际上还存在的死锁等问题,就需要死锁常见的那些策略来解决,有些受害者事务可能需要回滚。再另外,为了尽量让并发程度高一点,锁的粒度也应该被控制,参考数据库课学的多粒度锁。

Atomic Commit

这是在分布式事务里才常见的一个好问题。在分布式系统中,事务可能是跨节点的,如数据分片等情况。为了让每个节点都能够感知到其他节点的事务执行状况,需要引入一个中心节点来统一处理所有节点的执行逻辑,这个中心节点叫做协调者(coordinator),被中心节点调度的其他业务节点叫做参与者(participant)。

我们要保证,一个事务要么在所有相关 server 上都成功了,要么都失败,不能个别 server 成功,破坏了一致性。这就是 coordinator 存在的原因。

最老式的做法是 Two-Phase Commit (2PC) 二阶段提交。顾名思义,2PC将分布式事务分成了两个阶段,两个阶段分别为提交请求(投票)和提交(执行)。协调者根据参与者的响应来决定是否需要真正地执行事务,具体流程如下:

提交请求(投票)阶段

  • 协调者向所有参与者发送 prepare 请求与事务内容,询问是否可以准备事务提交,并等待参与者的响应。
  • 参与者执行事务中包含的操作,并记录 undo 日志(用于回滚)和 redo 日志(用于重放),但不真正提交。
  • 参与者向协调者返回事务操作的执行结果,执行成功返回 yes,否则返回 no。

提交(执行)阶段

分为成功与失败两种情况。

若所有参与者都返回 yes,说明事务可以提交:

  • 协调者向所有参与者发送 commit 请求。
  • 参与者收到 commit 请求后,将事务真正地提交上去,并释放占用的事务资源,并向协调者返回 ack。
  • 协调者收到所有参与者的 ack 消息,事务成功完成。

若有参与者返回 no 或者超时未返回,说明事务中断,需要回滚:

  • 协调者向所有参与者发送 rollback 请求。
  • 参与者收到 rollback 请求后,根据 undo 日志回滚到事务执行前的状态,释放占用的事务资源,并向协调者返回 ack。
  • 协调者收到所有参与者的 ack 消息,事务回滚完成。

然而,两阶段提交有着极差的名声。其中一个原因是,因为有多轮消息的存在,它非常的慢。各个组成部分之间着大量的交互。

另外,如果任何地方出错了,消息丢了,某台机器崩溃了,参与者需要在持有锁的状态下等待一段长时间。

两阶段提交的架构中,本质上是有一个 Leader(事务协调者),将消息发送给 Follower(事务参与者),Leader 只能在收到了足够多 Follower 的回复之后才能继续执行。这与 Raft 非常像,然鹅,它需要等所有参与者都相应,这本身就很可能缓慢。另外这个 Leader 挂了也没人顶替,作为单点非常脆弱。

为了让这个系统更加稳定一些,减少超时和失败的情况,我们可以在协调者和参与者每个节点都采用一个 Raft 集群,结合两种思想来同时获得高可用和原子提交。

image-20230118192658510

References

Lecture 12 - Distributed Transaction - MIT6.824 (gitbook.io)

15-445-Lec16-两阶段锁 - 知乎 (zhihu.com)

如何理解两阶段提交? - 知乎 (zhihu.com)