事务的两阶段提交

事务

一般当我们的功能函数中有批量的增删改时,我们会添加一个事务包裹这一系列的操作,要么这一组操作全部执行成功,只要有一条 SQL 执行失败了我们就全部回滚。

对 MySQL 来说你可以通过下面的命令显示的开启、提交、回滚事务

Copy# 开启事务
begin;

# 或者下面这条命令
start transaction;

# 提交
commit;

# 回滚
rollback;

2PC

二阶段提交(Two Phase Commit,2PC) 是指在 计算机网络 以及 数据库 领域内,为了使基于 分布式系统 架构下所有节点在进行事务提交时保持一致性而设计的一种算法。通常,二阶段提交也被称为一种协议。

在我们的分布式系统中,每个节点虽然可以知道自己的操作成功或者失败,但是不知道其他的节点的成功或者失败,当一个事务跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终决定这些节点是否要进行真正的提交。这里可以回忆下 redo log 和 binlog 的两阶段提交。

因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈结果决定各参与者是否要提交操作还是回滚操作

第一阶段(提交请求阶段)

  1. 协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与节点的响应
  2. 参与者节点执行协调者节点询问发起的所有事务操作;
  3. 各参与节点响应协调者节点发起的询问。如果参与者节点的事务执行成功,则它返回一个“YES”消息;如果参与者节点事务执行失败,则返回一个“NO”消息。

第一阶段,也被称为投票阶段,即各个参与者投票是否要继续接下来的提交操作。

第二阶段(提交执行阶段)

成功则提交

当协调者节点从所有参与者节点获得的响应消息都为“YES”时:

  1. 协调者节点向所有参与者节点发出“正式提交”的请求;
  2. 参与者节点正式完成操作,并释放在整个事务期间内占用的资源;
  3. 参与者节点向协调者节点发送“完成”消息;
  4. 协调者节点收到所有参与者节点的“完成”消息后,完成事务。

失败则回滚

如果任一参与者节点在第一阶段返回的消息为“NO”,或者协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

  1. 协调者节点向所有参与者节点发出“回滚操作”的请求;
  2. 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源;
  3. 参与者节点向协调者节点发送“回滚完成”消息;
  4. 协调者节点收到所有参与者节点的“完成”消息,取消事务。

第二阶段,也被称为完成阶段,因为无论结果怎样,协调者都必须在此阶段结束当前事务。

具体实例

引言

MySQL 单机事务 解决了绝大部分互联网业务场景下的数据一致性问题。它确保在一个数据库实例内,多个 SQL 操作要么全部成功,要么全部失败,从而维护数据的完整性。这依赖于数据库本身的 ACID 特性。

当业务需求涉及跨越多个独立的数据库实例(甚至异构系统)时,单机事务就无法满足需求了。这时,我们需要 分布式事务 来保证这些独立操作的原子性、一致性、隔离性和持久性(ACID)。

使用 分布式锁 来实现分布式事务的思路是一种常见的尝试。它的基本原理是:

  1. 获取分布式锁:在执行跨库操作之前,先获取一个分布式锁(例如在 Redis 中设置一个带有过期时间的 key)。
  2. 执行本地事务:对每个数据库实例执行各自的本地事务。
  3. 判断结果与回滚:
    • 如果所有本地事务都成功提交,则整个分布式事务成功。
    • 如果有任何一个本地事务失败,则回滚所有已经提交的本地事务,并标记整个分布式事务失败。
  4. 释放分布式锁:无论成功或失败,在操作结束后释放分布式锁。

但是,分布式锁的方案会带来一个问题就是:并发度过低。

  • 串行化:分布式锁的本质是为了保证在特定时间内,只有一个“分布式事务”能够获得锁并执行,这导致了操作的串行化。
  • 资源争抢:当并发量高时,大量的请求会阻塞在获取锁这一步,严重降低了系统的吞吐量(QPS)。
  • 锁粒度:如果锁的粒度过大(例如锁住整个业务流程),那么并发度下降会更明显。
  • 性能瓶颈:Redis 作为分布式锁的实现载体,其自身的性能也会成为瓶颈,尤其是在高并发写操作时。

例如一个横跨两个 DB 实例的分布式事务,使用分布式锁后,QPS 从 N 下降到 N/2,还不包括对 redis 的操作,横跨的 instance 越多,并发度就成倍降低。这在生产环境中是难以接受的,尤其是在高并发的互联网业务中。

因此,虽然分布式锁提供了一种实现分布式事务的思路,但它在并发度上的牺牲使得它不适用于高并发场景。为了解决这一问题,业界发展出了多种更为复杂的分布式事务解决方案,例如:

  • 两阶段提交 (2PC)
  • 三阶段提交 (3PC)
  • TCC (Try-Confirm-Cancel)
  • Saga 模式
  • 消息最终一致性

方案

两阶段提交实现 Mysql 的分布式事务,可以保证多个 DB 操作的强一致性,同时保证并发度跟单机事务一致。这里的两阶段提交跟大家理解的两阶段提交在字面含义上是一致的:

  • 一阶段 prepare
  • 二阶段 commit 或者 rollback

不同的是:无论是 prepare,还是 commit 和 rollback,都是在 application 层面实现的。具体到每个阶段的操作,都是 application 对 Mysql 操作的单机事务。接下来要做的就是通过设计数据表,来记录 Mysql 对每个阶段的正确反馈,以及Application 层面针对单机事务的反馈作出的反应。

首先说明下业务场景:

  1. 多个账户之间的进行资金操作;
  2. 账户数很多,单个 DB instance 无法承载,所以根据 AccountId 的进行分库分表;
  3. 分库分表后,两个需要转移资金的账户可能不在一个 instance,需要保证资金转移操作的强一致性;

然后设计表结构

明确资金操作包括两个: 借D(余额减少)、贷C(余额增加)。主要有两个数据表:

1、Balance Table (账户余额表):

Attribute Comment
accountId 账户 ID
balance 余额
preparedBalance 预留余额

2、BalanceTransaction Table (账户余额操作命令表)

Attribute Comment
commandId 操作命令 ID
transactionId 单机事务 ID
accountId 账户 ID
transType 操作类型: 借D、贷C
transAmount 操作金额
reservedAmount 前置命令预留金额

这几个表中明确几点内容:

  1. 一个单机事务可能包含多个操作命令,所以 BalanceTransaction 表的 unique key 是 commandId,transactionId 和 accountId 是 key。
  2. preparedBalance、reservedAmount 两个字段需要着重理解,它们是这个分布式事务表设计的重点。
  3. 贷C 命令在并发状态是肯定能够成功的,因为是加余额,一阶段prepare 只需要记录下 C 命令,二阶段 commit 的时候,将transAmount 加到 balance 即可。
  4. 借D 命令在并发状态下如果不做处理,则不一定能成功,因为是减余额,一阶段 prepare 即使看到余额充足,二阶段 commit 也可能会出现余额不足的情况。所以 D 命令需要借助 preparedBalance 字段。

preparedBalance 字段的作用

主要用于处理 借D 命令的并发扣减问题。

一阶段(Prepare):当有 D 命令时,不是直接扣减 balance,而是先检查 balance - preparedBalance(即可用余额)是否足够。如果足够,则将 D 命令的金额加到 preparedBalance 中,表示这部分钱已经被“预留”了,但 balance 本身不变。这确保了在并发环境下,D 命令能够正确地进行“预检查”和“预占”。

二阶段(Commit):真正扣减 balance,并从 preparedBalance 中减去相应的预留金额。

二阶段(Rollback):只需要从 preparedBalance 中减去预留金额即可。


账户 A 的 balance=100,preparedBalance=0,有 3 个属于不同分布式事务的单机事务并发操作账户 A,执行顺序如下:

一阶段 prepare:

  1. 事务 1 执行 D 命令,扣减账户 50 元。一阶段看到的可用余额是: balance - preparedBalance = 100,余额足够,预留本次扣减的钱: preparedBalance = preparedBalance + 50 = 50,balance还是 100 没有变化。
  2. 事务 2 执行 D 命令,扣减账户 70 元。一阶段看到的可用余额是: balance - preparedBalance = 50,余额不够,事务失败返回。
  3. 事务 3 执行D命令,扣减账户 20 元。一阶段看到的可用余额是: balance - preparedBalance = 50,余额足够,预留本次扣减的钱: preparedBalance = preparedBalance + 20 = 70,balance 还是 100 没有变化。

二阶段commit:

  1. 事务 1 执行 D 命令,扣减账户 50 元。二阶段真正扣减余额: balance = balance - 50 = 50,清空一阶段预留的金额: preparedBalance = preparedBalance - 50 = 20
  2. 事务 3 执行 D 命令,扣减账户 20 元。二阶段真正扣减余额: balance = balance - 20 = 30,清空一阶段预留的金额: preparedBalance = preparedBalance - 20 = 0

总结下计算公式:

  • preparedBalance = preparedBalance + D命令的金额
  • 可用余额 = balance - preparedBalance

reservedAmount 字段的作用

主要用于优化单机事务内混合操作(C 和 D)的 D 命令处理。

当一个单机事务中既有加钱(C)又有减钱(D)命令时,系统会优先使用 C 命令增加的金额来抵扣 D 命令的减少金额,减少对实际 balance 的依赖。

reservedAmount 记录了当前 D 命令从前置 C 命令中“借用”了多少金额。这意味着这部分 D 命令的金额无需真正占用 balancepreparedBalance


单机事务可由多个操作命令组成,C 命令或者 D 命令。为了尽可能保证所有的 D 命令都能成功,在执行单机事务前会对所有的命令进行排序。C 命令会在 D 命令之前执行,然后执行 D 命令时,会优先使用前置 C 命令的金额。

在处理过程中,应用程序会维护一个变量,我们可以称之为 current_preceding_reserve (当前前置可用金额/前置命令预留金额)。这个变量不需要记录在数据库表中。

举个例子: 账户 A 的 balance为 100 元,事务 1 请求操作账户 A 的单机事务有四条命令:命令 C1 加 30,命令 D1 减 20,命令 D2 减 25,命令 D3 减 35。执行顺序如下:

一阶段 prepare:

  1. 加钱命令 C1.transAmount = 30
    (1) 在 BalanceTransaction 记录一条加钱命令,transType=贷(C),transAmount=30。
  2. 减钱命令 D1.transAmount = 20
    (1) 可用余额 = balance - preparedBalance = 100
    (2) 前置命令预留金额 = C1.transAmount = 30
    (3) 总可用余额 = 可用余额 + 前置命令预留金额 = 130 > D1.transAmount,余额足够。
    (4) 前置命令预留金额 = 30 > D1.transAmount,所以命令D1没必要扣减余额,直接扣减前置命令预留金额即可,得出: D1.reservedAmount = 20
    (5) 因为没有扣减余额,所以没必要使用preparedBalance,preparedBalance = D1.transAmount - D1.reservedAmount = 0
  3. 减钱命令 D2.transAmount = 25
    (1) 可用余额 = balance - preparedBalance = 100
    (2) 前置命令预留金额 = C1.transAmount - D1.reservedAmount = 10,所以 D2 命令可以使用的前置命令预留金额D2.reservedAmount = 10
    (3) 总可用余额 = 可用余额 + 前置命令可使用的金额 = 110 > D2.transAmount,余额足够。
    (4) D2命令需要的预留余额 = D2.transAmount - D2.reservedAmount = 15,所以 Account 的 preparedBalance = preparedBalance + D2 命令需要的预留余额 = 15
  4. 减钱命令 D3.transAmount = 35
    (1) 可用余额 = balance - preparedBalance = 85
    (2) 前置命令预留金额 = C1.transAmount - D1.reservedAmount - D2.reservedAmount = 0,所以D3命令没有可以使用的前置命令预留金额:D3.reservedAmount = 0
    (3) 总可用余额 = 可用余额 + 前置命令可使用的金额 = 85 > D3.transAmount,余额足够。
    (4) D3 命令需要的预留余额 = D3.transAmount - D3.reservedAmount = 35,所以Account的preparedBalance = preparedBalance + D3命令需要的预留余额 = 50

二阶段commit:

  1. balance = balance + C1.transAmount = 130
  2. balance = balance - D1.transAmount = 110,清空一阶段 D1 命令预留的金额: preparedBalance = preparedBalance - (D1.transAmount - D1.reservedAmount) = 50
  3. balance = balance - D2.transAmount = 85,清空一阶段 D2 命令预留的金额: preparedBalance = preparedBalance - (D2.transAmount - D2.reservedAmount) = 35
  4. balance = balance - D3.transAmount = 50,清空一阶段 D3 命令预留的金额: preparedBalance = preparedBalance - (D3.transAmount - D3.reservedAmount) = 0

总结一下:

一阶段 C 命令的执行步骤:

  1. 记录 C 命令即可

一阶段 D 命令的执行步骤:

  1. 可用余额 = balance - preparedBalance
  2. 前置命令预留金额= sum(前置C命令的transAmount) - sum(前置D命令的reservedAmount)
  3. 判断余额是否足够: 可用余额 + 前置命令预留金额 >= D命令的transAmount。余额充足,则继续,不足,则rollback。
  4. D命令的reservedAmount = 前置命令预留金额 >= D命令的transAmount ? D命令的transAmount : 前置命令预留金额
  5. system_balance = system_balance + (D命令的transAmount - D命令的reservedAmount)

二阶段提交:

  1. C命令,执行: balance + C命令的transAmount
  2. D命令,执行: balance - D命令的transAmount,并清空一阶段D命令的预留金额: preparedBalance = preparedBalance - (D命令的transAmount - D命令的reservedAmount)

二阶段回滚:

  1. 清空一阶段D命令的预留金额: preparedBalance = preparedBalance - (D命令的transAmount - D命令的reservedAmount)
  2. 删除C命令和D命令。

总结

好了,目前为止,整个两阶段提交实现分布式事务的方案已经讲解完了。如果大家能够完全理解,会发现这个方案不仅能实现两个账户之间的资金转移,还可以实现很复杂的多账户资金转移,如下图所示。同时并发度跟账户数并无关系,单机事务能承受多少 QPS,该分布式事务模型就能承受多少 QPS。

img

我的看法

作者提出的这种二阶段提交方案,通过引入 preparedBalance 字段,在并发控制上取得了显著的进步。它将传统的悲观锁(直接锁定余额)转化为了一种乐观预占(乐观锁)的机制,通过 preparedBalance 的累加和扣减,巧妙地在应用层实现了对资金的预留和并发检查。

  • 保证并发正确性在于:
    1. preparedBalance 字段的原子性更新(由数据库行锁保障)。
    2. 一阶段 balance - preparedBalance 的检查,确保资金不会被超支预留。
    3. 二阶段的统一提交/回滚机制,保障原子性。
  • 提升并发度在于:
    1. D 命令在一阶段只预留金额(增加 preparedBalance),而不立即扣减 balance,降低了对 balance 字段的竞争(不需要访问 balance 字段)。
    2. reservedAmount 进一步减少了对 preparedBalance 的竞争(访问应用程序维护的 current_preceding_reserve 和 事务表维护的 reservedAmount 而不是数据库表中的 preparedBalance 字段)。
    3. 多个并发事务可以同时尝试预留,只有最终预留成功且满足余额的事务才能继续。

虽然这种方案在 preparedBalance 字段上依然存在更新的串行化,但其粒度远小于直接锁定 balance,且允许多个事务同时进行预检查,确实有效地提升了系统的并发处理能力,使其能够适应高并发的场景。同时,它保留了 2PC 强一致性的核心保证。

  1. 降低了锁的竞争粒度,提高了并发事务的通过率

    • 直接锁定 balance 如果每个事务在操作余额前都要直接锁定 balance 字段(例如,传统的悲观锁,或者在借款时直接扣减 balance),那么所有涉及到该账户的并发事务都会直接在这一步被阻塞,形成一个长长的等待队列。这意味着任何涉及该账户的资金操作都必须严格串行,效率极低。
    • 锁定 preparedBalance 引入 preparedBalance 后,只有 D 命令(借款)才会在一阶段更新它。C 命令(存款)甚至不需要动它,它们可以直接记录并完成一阶段。 更重要的是,即使是多个 D 命令并发操作同一个账户,它们锁定的也只是 preparedBalance 这个“预留”字段,而不是核心的 balance 字段。 这意味着:
      • 读操作的并发度提高: 其他事务在读取 balance 时,通常不会被 preparedBalance 的锁阻塞(除非它们也要更新 preparedBalance)。
      • 写操作的并发度提高(相对而言): 事务不再需要等到真正扣减 balance 的那一刻才加锁。它们可以在一阶段先争抢 preparedBalance 的更新锁,然后立即释放,使得其他事务也能很快地进行它们的 preparedBalance 更新。
      • 更短的锁持有时间: 更新 preparedBalance 通常是一个非常快的操作(preparedBalance = preparedBalance + X)。事务持有锁的时间更短,减少了其他事务的等待时间。
  2. 将复杂业务逻辑的阻塞点前移且细化

    • 提前失败,减少资源浪费: 在一阶段 prepare 阶段,通过 balance - preparedBalance 的检查,如果发现可用余额不足,事务会立即失败并回滚。这避免了事务进入到二阶段才发现余额不足,从而减少了不必要的资源占用和计算。
    • 将大锁拆分成小锁: 传统的锁定 balance 意味着整个资金操作的生命周期(从发起请求到最终提交)都需要长时间持有锁。而通过 preparedBalance,我们将并发控制的“压力点”转移到并分散到了两个阶段:
      • 一阶段:多个事务可以并发地在 preparedBalance 上进行轻量级的原子操作和预留。
      • 二阶段:最终对 balance 的扣减/增加发生在事务的末尾,且是基于一阶段成功预留的前提。

总而言之,将 preparedBalance 的更新串行化,而不是直接锁定 balance,其核心优势在于:

  • 将高并发竞争的“战场”从核心的 balance 转移到了 preparedBalance
  • 缩短了关键资源的锁持有时间。
  • 使得更多的并发事务能够在早期阶段(Prepare)完成其操作,减少了不必要的阻塞和资源浪费。

这使得系统能够在保持资金强一致性的前提下,处理更高的并发请求,是该分布式事务方案设计中的一个关键亮点。

参考

两阶段写日志

MySQL 中的日志

为了保证事务的 ACID 特性,MySQL 需要记录一些日志来辅助事务的执行和恢复。MySQL 中主要有两种日志:redo log 和 binlog。

redo log 是 InnoDB 存储引擎特有的日志,用于记录数据页的物理修改,保证事务的持久性和原子性。redo log 是循环写入的,由两部分组成:一块固定大小的内存区域(redo log buffer)和一组固定大小的磁盘文件(redo log file)。当事务对数据进行修改时,会先将修改记录到 redo log buffer 中,然后在适当的时机将其刷新到 redo log file 中。这样即使数据库发生异常重启,也可以根据 redo log 恢复数据。

binlog 是 MySQL Server 层的日志,用于记录 SQL 语句的逻辑修改,保证事务的一致性。binlog 是追加写入的,由一个 binlog 文件序列和一个索引文件组成。当事务提交时,会将 SQL 语句记录到 binlog 中。binlog 主要用于数据备份、恢复和主从复制。

为什么需要两阶段提交

如果只有 redo log 或者只有 binlog,那么事务就不需要两阶段提交。但是如果同时使用了 redo log 和 binlog,那么就需要保证这两种日志之间的一致性。否则,在数据库发生异常重启或者主从切换时,可能会出现数据不一致的情况。

例如,假设我们有一个事务 T,它修改了两行数据 A 和 B,并且同时开启了 redo log 和 binlog。如果我们先写 redo log 再写 binlog,并且在写完 redo log 后数据库发生了宕机,那么在重启后,根据 redo log 我们可以恢复 A 和 B 的修改,但是 binlog 中没有记录 T 的信息,导致备份或者从库中没有 T 的修改。反之,如果我们先写 binlog 再写 redo log,并且在写完 binlog 后数据库发生了宕机,那么在重启后,根据 redo log 我们无法恢复 A 和 B 的修改,但是 binlog 中有记录 T 的信息,导致备份或者从库中有 T 的修改。

为了避免这种情况,MySQL 引入了两阶段提交的机制。两阶段提交就是将一个事务分成两个阶段来提交:prepare 阶段和 commit 阶段。在 prepare 阶段,事务会先写 redo log 并将其标记为 prepare 状态,然后写 binlog;在 commit 阶段,事务会将 redo log 标记为 commit 状态,并将 binlog 落盘。这样,无论数据库在哪个时刻发生宕机,都可以根据 redo log 和 binlog 的状态来判断事务是否提交,并保证数据的一致性。

如何判断binlog和redolog是否达成了一致

当MySQL写完redolog并将它标记为prepare状态时,并且会在redolog中记录一个XID,它全局唯一的标识着这个事务。而当你设置sync_binlog=1时,做完了上面第一阶段写redolog后,mysql就会对应binlog并且会直接将其刷新到磁盘中。

下图就是磁盘上的row格式的binlog记录。binlog结束的位置上也有一个XID。

只要这个XID和redolog中记录的XID是一致的,MySQL就会认为binlog和redolog逻辑上一致。就上面的场景来说就会commit,而如果仅仅是rodolog中记录了XID,binlog中没有,MySQL就会RollBack

img

MySQL采用了如下的二阶段提交流程:

img

  1. 在准备阶段,MySQL先将数据修改写入redo log,并将其标记为prepare状态,表示事务还未提交。然后将对应的SQL语句写入bin log。
  2. 在提交阶段,MySQL将redo log标记为commit状态,表示事务已经提交。然后根据sync_binlog参数的设置,决定是否将bin log刷入磁盘。

下面是一个简单的示例:

假设我们有一个表test_backup如下:

CREATE TABLE `test_backup` (
  `id` int (11) NOT NULL AUTO_INCREMENT,
  `name` varchar (255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

然后我们执行以下语句:

insert into test_backup values (1,'tom');
insert into test_backup values (2,'jerry');
insert into test_backup values (1,'herry');

这时候,MySQL会按照以下步骤进行二阶段提交:

  1. 将第一条插入语句写入redo log,并标记为prepare状态。(一阶段提交)
  2. 将第一条插入语句写入bin log。
  3. 将redo log标记为commit状态。(二阶段提交)
  4. 如果sync_binlog=1,则将bin log刷入磁盘。
  5. 重复以上步骤,直到所有插入语句都完成。

如果在这个过程中发生了崩溃,比如在第三步之前,那么MySQL重启后会根据redo log发现有一个prepare状态的事务,然后会去查找bin log中是否有对应的SQL语句。如果有,则说明该事务已经写入了bin log,可以提交;如果没有,则说明该事务还没有写入bin log,需要回滚。这样就可以保证数据的一致性。

总结

MySQL 的两阶段提交是为了保证同时使用 redo log 和 binlog 的情况下,数据的一致性。两阶段提交将一个事务分成 prepare 阶段和 commit 阶段,在 prepare 阶段写 redo log 和 binlog,在 commit 阶段修改 redo log 的状态并落盘 binlog。这样可以避免数据库发生异常重启或者主从切换时出现数据不一致的情况。

MySQL 的两阶段提交确保分布式的 ACID 特性和高效性与确保 redolog 和 binlog 的一致性不是冲突的,而是相联系的。因为 binlog 起作用就是作保数据库有多个 instance 的情况下,确保主从 instance 的数据一致。

参考

3PC

两阶段提交存在的问题

两阶段提交存在以下几个问题:

  1. 同步阻塞问题:执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。也就是说从投票阶段到提交阶段完成这段时间,资源是被锁住的。
  2. 单点故障:由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。
  3. 数据不一致问题:在 2PC 最后提交阶段中,当协调者向参与者发送 commit 请求之后,发生了局部网络异常或者在发送 commit 请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了 commit 请求。而在这部分参与者接到 commit 请求之后就会执行 commit 操作。但是其他部分未接到 commit 请求的机器则无法执行事务提交,于是整个分布式系统便出现了数据不一致性的现象。

三阶段提交

三阶段提交(Three-Phase Commit,简称3PC)是在 2PC 协议的基础上添加了一个额外的阶段来解决 2PC 协议可能出现的阻塞问题。

3PC 协议包含三个阶段:

  1. CanCommit 阶段(询问阶段):在这个阶段,事务协调者(Transaction Coordinator)向所有参与者(Transaction Participant)发出 CanCommit 请求,询问它们是否准备好提交事务。参与者执行所有必要的操作,并回复协调者它们是否可以提交事务。
  2. PreCommit 阶段(准备阶段):如果所有参与者都回复可以提交事务,则协调者将向所有参与者发送PreCommit 请求,通知它们准备提交事务。参与者执行所有必要的操作,并回复协调者它们是否已经准备好提交事务。
  3. DoCommit 阶段(提交阶段):如果所有参与者都已经准备好提交事务,则协调者将向所有参与者发送DoCommit 请求,通知它们提交事务。参与者执行所有必要的操作,并将其结果记录在持久性存储中。一旦所有参与者都已提交事务,协调者将向它们发送确认请求。如果任何参与者未能提交事务,则协调者将通知所有参与者回滚事务。

与 2PC 协议相比,3PC 协议将 CanCommit 阶段(询问阶段)添加到协议中,使参与者能够在 CanCommit 阶段发现并解决可能导致阻塞的问题。这样,3PC 协议能够更快地执行提交或回滚事务,并减少不必要的等待时间。需要注意的是,与 2PC 协议相比,3PC 协议仍然可能存在阻塞的问题。

两阶段提交 VS 三阶段提交

3PC 就是将 2PC 的准备阶段一分为二,在 2PC 中,准备阶段只是判断是否可以成功 Commit,而在 3PC 中,还引入了一个额外的准备阶段,那就是是否准备好 Commit,避免网络故障或者其它原因导致的事务无法提交。

同时 3PC 引入的超时机制也可以避免 2PC 中参与者“失联”的问题。

2PC 和 3PC 是分布式事务中两种常见的协议,3PC 可以看作是 2PC 协议的改进版本,相比于 2PC 它有两点改进:

  1. 引入了超时机制,同时在协调者和参与者中都引入超时机制(2PC 只有协调者有超时机制);
  2. 3PC 相比于 2PC 增加了 CanCommit 阶段,可以尽早的发现问题,从而避免了后续的阻塞和无效操作。

也就是说,3PC 相比于 2PC,因为引入了超时机制,所以发生阻塞的几率变小了;同时 3PC 把之前 2PC 的准备阶段一分为二,变成了两步,这样就多了一个缓冲阶段,保证了在最后提交阶段之前各参与节点的状态是一致的。

数据一致性问题和解决方案

3PC 虽然可以减少同步阻塞问题和单点故障问题,但依然存在数据一致性问题(概率很小),而解决数据一致性问题的方案有很多,比如 Paxos 算法或柔性事物机制等。

Paxos 算法

Paxos 算法是一种基于消息传递的分布式一致性算法,并在 2013 年获得了图灵奖。

简单来说,Paxos 算法是一种分布式共识算法,用于在分布式系统中实现数据的一致性和共识,保证分布式系统中不同节点之间的数据同步和一致性。

Paxos 算法由三个角色组成:提议者、接受者和学习者。当一个节点需要发起一个提议时,它会向其他节点发送一个提议,接受者会接收到这个提议,并对其进行处理,可能会拒绝提议,也可能会接受提议。如果有足够多的节点接受了该提议,那么提议就会被确定下来,并且通知给所有学习者,最终所有节点都会达成共识。

Paxos 算法看起来很简单,但它实际上是非常的复杂。

Paxos 算法应用的产品也很多,比如以下几个:

  • Redis:Redis 是一个内存数据库,使用 Paxos 算法实现了分布式锁服务和主从复制等功能。
  • MySQL:MySQL 5.7 推出的用来取代传统的主从复制的 MySQL Group Replication 等。
  • ZooKeeper:ZooKeeper 是一个分布式协调服务,使用 Paxos 算法实现了分布式锁服务和数据一致性等功能。
  • Apache Cassandra:Cassandra 是一个分布式数据库系统,使用 Paxos 算法实现了数据的一致性和复制等功能。
  • Google Chubby:Chubby 是 Google 内部使用的分布式锁服务,使用 Paxos 算法实现了分布式锁服务和命名服务等功能。

4.2 柔性事务

柔性事务机制:允许一定时间内不同节点的数据不一致,但要求最终一致的机制。

柔性事物有 TCC 补偿事物、可靠消息事物(MQ 事物)等。

小结

在分布式事务中,通常使用两阶段或三阶段提交协议来保障分布式事务的正常执行。两阶段协议包含准备阶段和提交阶段,然而它存在同步阻塞问题、单点故障和数据一致性问题。而三阶段协议可以看作是两阶段协议的改进版,它将两阶段的准备阶段一分为二,多了一个询问阶段,保证了提交阶段之前各参与节点的状态是一致的,同时引入了超时机制,减少了同步阻塞问题发生的几率。但 2PC 和 3PC 都存在数据一致性问题,此时可以采用 Paxos 算法或柔性事务机制等方案来解决事务一致性问题。

参考

posted @ 2025-06-06 18:58  光風霽月  阅读(39)  评论(0)    收藏  举报