两阶段提交(Two-phase Commit, 2PC),是一种为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的算法。本文介绍了两阶阶段提交协议的实现原理。
协议整体流程
上图Database A, Database C是2个参与者(worker/participant/cohort),一般是关系数据库。协调者(coordinator/transaction manager)一般位于应用服务器(比如Java EE容器)中,当然也可以单独部署。2PC的执行过程实际可以理解成3个阶段:
- 发送SQL语句阶段。在客户端开始分布式事务的时候coordinator先生成一个事务ID,然后在每个worker上开启事务,之后客户端通过coordinator往每个worker分别发送多个多个读写SQL。在悲观串行化隔离级别下,worker需要用读锁和写锁分别锁定所有被读取和写入的数据。在乐观策略下,则需要在prepare阶段检查数据版本。本文以悲观策略为例来简化对问题的理解。
- 准备(Prepare)阶段。coordinator分别给各个worker发送询问确认单节点内的事务是否可以保证被提交。如有任何一个节点返回no,则回滚(abort)所有节点的事务。
- 提交(Commit)阶段。coordinator给每个worker发送提交事务的指令,最终提交本地的事务并释放资源相关的锁。
在客户端发起commit之后coordinator负责完成准备(Prepare)阶段和提交(Commit)阶段,也就对应2PC。
事务日志处理
上图有3个worker。coordinator 和 worker 都需要在一些进度点记录日志(图里面只拿了一个worker举例)。有了日志记录,任何时间点任何节点挂掉都可以通过日志来恢复事务进度。
- coordinator需要在prepare阶段开始时记录 BEGIN 日志,内部包含事务ID和涉及的worker
- prepare全部确认后coordinator记录COMMITTED日志。在coordinator记录完COMMITTED日志后,意味着这个事务一定会被提交。
- worker确认事务可以提交后记录 BEGIN 和 PREPARED 日志。日志里包括写入的数据信息,比如mysql 用 redo log来记录prepare。这个日志保证了即使worker宕机,其恢复后依然可以确保事务能被提交。
- worker在自己提交后记录 COMMITED 日志。比如,mysql 用 binlog 来记录 COMMITED 日志。
- coordinator在所有worker返回commit成功的响应后,就可以清理掉该事务在coordinator上的日志并给客户端返回事务已完成。
故障点及处理
以下为可能的处理策略,并不对应任何软件的实现。
- 故障发生在发送SQL语句阶段。如果任何节点发生故障,都可以直接给客户端返回错误。有些开始了事务的worker,coordinator可以将其回滚。如果coordinator挂了,则worker需要自己等待事务超时后回滚。
- 故障发生在prepare阶段
- 情况1:worker发生了故障。这种情况下coordinator需要不停的重试,直到worker恢复后给到响应为止。为什么coordinator不能直接回滚整体事务呢,因为coordinator无法确认故障worker是否已经进入了prepared阶段,如果忽略该worker的进度,可能导致worker上的临界资源被永久锁定。coordinator如果选择回滚整体事务也必须后续确认该故障worker也回滚了事务。
- 情况2:coordinator发生了故障。coordinator在恢复后会从日志中读取到该已经 BIGIN 的但未 COMMITTED 的事务,coordinator会重新对每个worker发起prepare操作。worker需要保证prepare操作是幂等的(事务ID可以做为幂等Key)。
- 故障发生在commit阶段
- 情况1:worker发生了故障。coordinator需要重试,直到worker恢复后给回响应。因worker在prepare阶段记录了事务细节并锁定了临界资源,所以其能保证宕机恢复后事务依然一定可以提交成功。
- 情况2:coordinator发生了故障。coordinator在恢复后会从日志中读取到该已经 COMMITTED 的但未被删除的事务记录。coordinator会重新对每个worker发起commit操作。worker需要保证commit操作是幂等的。
这里的2个核心逻辑是
- 通过日志可以保证事务的执行进度被记录下来,而不会因宕机而导致事务被丢失,进而不会导致一些worker一直处于一个异常的中间状态。
- 通过保证操作的幂等性,能保证在不确定操作是否已经执行成功的情况下,可以进行重试。
2PC的问题
长久锁定的问题(blocking problem)
- 在prepare之后每个worker需要继续锁定相关资源,一直到收到coordinator的commit指令。如果coordinator在prepare之后commit之前挂了,会导致临界资源在coordinator恢复前会一直处于锁定状态。(这里会阻塞掉有竞争的写入,但并不影响只读事务。)
- 解决办法:
- 让coordinator实现容错,让其做自动的故障恢复。 实践中coordinator可以用Raft,Paxos等协议来实现容错。Google's Spanner 使用Paxos协议来复制coordinators。但实现这些协议应该会导致coordinator的吞吐下降。
- 通过别的worker确认整体事务是否commit。比如,worker在等待超时之后给其他worker发消息确认提交决定。 这个容错方法并不包含在2PC协议里。
性能问题
- 多次远程通讯,多次日志写入,锁定数据等均会降低性能。据测试,MySQL 中的分布式事务比单节点事务慢 10 倍以上。TPS参考值为几千。
部署问题
- 如果将coordinator与应用服务器部署到一起,因为coordinator需要存储日志,会将应用服务器变为有状态的服务,会减低其运维的灵活性。如果独立部署coordinator又会增加一层远程调用。
死锁问题
- coordinator无法检测跨多个worker的死锁。比如k1, k2分别位于worker1, worker2; 事务T1依次修改k1, k2; 事务T2依次修改k2, k1, 那么可能出现
- T1: w_lock(k1), wait w_lock(k2) forever
- T2: w_lock(k2), wait w_lock(k1) forever
- 这种情况worker1, worker2都无法独立的检测到死锁。coordinator通常也并不会从worker同步锁信息。
脏读的问题
- 2PC因提交阶段是无法保证所有worker都在同一个时间点commit的,所以先被commit的资源会先被读取到。
- 2PC是否能解决写偏差(write skew)的问题呢?理论上是可以的,最好对具体实现做测试。
思考
为什么要prepare阶段?
- prepare 阶段 worker 会将事务进度持久化,从而保证 worker 宕机恢复后可以继续提交事务。所以 prepare 返回 true 代表 worker 承诺事务一定能提交。
- 为什么不自动 prepare ?prepare之前可能有多个 SQL语句,自动 prepare 会导致一些不必要的持久化操作,进而影响性能。
有没有可能去掉prepare阶段呢?
- 是可能的。但需要支持commit之后的逆向操作(undone, compensating transaction),且在整体事务完成前要保持对资源的锁定,这导致最终还是需要一次确认操作来释放锁。这样跟加入prepare阶段比并不能带来优势。
资料
- http://dbmsmusings.blogspot.com/2019/01/its-time-to-move-on-from-two-phase.html
- https://www.youtube.com/watch?v=B6btpukqHpM
- Principles of Computer System Design An Introduction_Chapter 9_atomicity.pdf