分布式事务指南:从二阶段锁到两阶段提交,了解核心设计 分布式事务核心设计、并发控制与原子提交协议构建跨多台服务器的数据库系统时一个核心难题摆在面前如何在保证数据一致性的前提下让多个事务并发执行同时还能在节点故障、网络分区时做到“全有或全无”分布式事务正是解决这一问题的经典方案。它从单机事务的 ACID 特性出发逐步引入并发控制、死锁处理与两阶段提交最终在分布式环境下撑起一片可靠的天空。本文将以 Raft 那篇文章的风格带你深入理解分布式事务的核心概念、并发控制策略以及最具代表性的原子提交协议——两阶段提交2PC。一、分布式事务的核心特点ACID事务的 ACID 特性是分布式环境下一切设计的基石。特性含义分布式挑战原子性Atomicity事务要么全部执行成功要么全部不执行不允许部分生效。节点可能崩溃、网络可能中断必须确保所有参与者要么一起提交要么一起回滚。一致性Consistency事务执行前后数据满足用户定义的完整性约束如账户总额不变。在分布式复制场景下还要求不同副本读取到的值相同。注ACID 中的“一致性”专指业务完整性约束分布式复制中的“副本一致”通常对应线性一致性或顺序一致性是另一个维度。写并发、副本滞后、网络分区都会破坏一致性感知。隔离性Isolation并发事务不能看到彼此的中间状态最终效果等价于某种串行执行顺序。需要跨节点的锁或乐观检查开销远大于单机。持久性Durability一旦事务提交修改永久保存在非易失性存储磁盘、SSD中即使系统崩溃也不丢失。协调者必须持久化提交决定若为提升可用性可借助 Paxos 等协议将决策日志同步到多数派节点。在分布式事务中原子性和隔离性是最难保证的两条也是本文后续章节的重点。二、可串行化隔离性的黄金标准1. 为什么需要可串行化如果允许事务看到其他事务的中间状态就会产生各种异常脏读、不可重复读、幻读……最严重的是可能导致数据永久性错误。我们来看一个具体例子。假设有两个事务转账事务 T1把 A 账户的 x 元转入 B 账户步骤①A 余额减去 x步骤②B 余额加上 x统计事务 T2计算 A、B 的总资产并将结果写入数据库操作Write( A B )如果 T2 恰好安排在 T1 的步骤①和步骤②之间执行会看到什么转账事务 T1数据库统计事务 T2求和得到 (A - x) B A B - x读取 A 余额写入 A A - x读取 A得到 A - x读取 B得到 B写入 B B x提交写入统计结果 A B - x转账事务 T1数据库统计事务 T2结果T2 最终写入的总额比实际少了x。如果这是银行系统的日终报表就会造成对账错误。可串行化Serializability要求任何并发事务的执行效果都必须等价于某种顺序的串行执行。在上面的例子中唯一合理的串行顺序要么是 T1 完全执行完再执行 T2要么是 T2 先执行再执行 T1。两种都不会产生错误的总和。2. 如何实现可串行化最直接的思路就是锁。事务在读写数据前先获取锁其他事务如果请求冲突的锁就必须等待。由此衍生出两种设计哲学悲观并发控制与乐观并发控制。三、并发控制策略1. 悲观并发控制Pessimistic Concurrency Control假设冲突会很频繁核心机制每个事务在读写数据前必须先获取该记录的锁。如果锁已被其他事务持有则等待。锁的粒度可以是一行、一个文档也可以是一张表粒度越细并发越高开销也越大。适用场景冲突率高的系统例如热门商品库存扣减。优点强隔离不会产生脏数据实现相对简单。缺点可能引发死锁锁等待会降低吞吐量。2. 乐观并发控制Optimistic Concurrency Control假设冲突是意外核心机制事务无阻塞地执行所有读写将修改暂存在本地。提交时检查是否与其他事务冲突例如是否读取了被他人修改的数据。冲突时的处理如果检测到冲突则回滚当前事务并重试。适用场景冲突率低的系统例如大部分是读操作或者不同用户操作不同数据。优点无锁等待高并发下吞吐量高。缺点冲突时重试成本高需要记录读写集检查开销也不小。两种策略没有绝对的优劣很多数据库会混合使用比如在索引上采用悲观锁在堆表数据上采用乐观检查。四、两阶段锁2PL悲观锁的标准实现在关系型数据库例如MySQL中实现可串行化最经典的方法就是两阶段锁Two-Phase Locking, 2PL。1. 两阶段锁的基本规则一个事务的生命周期被划分为两个阶段扩张阶段Growing Phase事务可以获取新的锁但不能释放任何锁。收缩阶段Shrinking Phase事务可以释放锁但不能获取任何新锁。而在分布式事务中使用严格两阶段锁Strict 2PL——实际系统中最常用的变体——规则更加严格事务在提交或中止之前必须持有所有已经获取的锁。不允许在中间释放任何锁。也就是说所有锁的释放都推迟到事务结束的那一刻。收缩阶段扩张阶段事务提交/中止释放锁释放锁获取锁获取锁获取锁2. 为什么必须等到最后才释放锁如果允许事务在使用数据后立即释放锁就会破坏可串行化甚至破坏原子性。反例提前释放锁导致的脏读T1 获取 x 的写锁将 x 修改为新值尚未提交然后立即释放写锁。T2 此时顺利获得 x 的读锁读到了 T1 未提交的新值脏读。若 T1 随后因故回滚T2 已经基于这个“幽灵值”进行了计算或写入一致性就被彻底破坏。这就是经典的**脏读Dirty Read**问题。严格两阶段锁通过“所有锁保留到事务结束”来杜绝这种情况T1 只要还没提交就会一直持有写锁T2 根本无法读到未提交的修改。五、死锁与检测锁机制虽然保证了可串行化但也引入了一个新问题死锁。1. 死锁示例两个事务互相等待对方持有的锁导致都无法推进T1 持有 A 的锁请求 B 的锁。T2 持有 B 的锁请求 A 的锁。两者永远等不到对方释放系统卡死。扩展死锁探测方法常见死锁处理方法方法原理优缺点超时机制事务等待锁超过阈值如 1 秒就主动回滚自己。实现简单但可能误杀高负载下误判。集中式死锁检测选一个节点作为全局检测器收集所有节点的等待图发现环则中止某个事务。逻辑简单但中心节点可能成为瓶颈和单点故障。分布式死锁检测Obermarck 算法每个节点维护本地等待图跨节点等待边通过“探针”消息传递。探针沿等待链传播若回到发起点则说明有环。无中心节点但实现复杂消息开销大。边追逐法Chandy-Misra-Haas每个事务发起探测消息 (detector, blocker, hop)沿等待链传递。若消息回到 detector则死锁。经典算法通过 ID 比较避免重复处理。生产系统中超时机制是最普遍的选择——它简单、可靠且避免了复杂的跨节点协调。只有当超时机制导致过多误杀时才会考虑引入主动检测。六、原子提交两阶段提交协议2PC单机事务的原子性依靠预写日志WAL就能实现崩溃后重放日志即可。但在分布式环境中数据分布在多个独立节点上要么全部节点都提交要么全部节点都中止——这就是原子提交问题。两阶段提交Two-Phase Commit, 2PC是最经典的原子提交协议。1. 角色与前提协调者Coordinator负责驱动整个事务的决策。通常选择一个可靠的节点例如 Raft 集群的领导者来承担。参与者Participants真正持有数据并执行读写操作的服务节点如 Server A, Server B。事务 IDTID每个事务消息都携带唯一的 TID用于各节点跟踪状态和去重。2. 第一阶段准备阶段Prepare Phase协调者向所有参与者发送Prepare消息询问“你们准备好提交这个事务了吗”每个参与者收到后检查自身状态是否有死锁、约束是否满足、资源是否充足。将事务的所有修改写入预写日志Write-Ahead Log并强制刷盘。这是最关键的一步——参与者一旦回复“同意”就必须承诺能够提交即使之后自己崩溃重启也能从日志中恢复并完成提交。根据检查结果回复Vote-Yes或Vote-No。协调者参与者 A参与者 BPrepare (TID)Prepare (TID)持久化日志加锁持久化日志加锁Vote-YesVote-Yes协调者参与者 A参与者 B3. 第二阶段提交/中止阶段情况 A所有参与者都回复 Vote-Yes协调者决定提交Commit向所有参与者发送Commit消息。参与者收到 Commit 后将事务真正应用到状态机或数据库释放锁然后回复Ack。协调者收到所有 Ack 后可以安全地清理该事务的日志。情况 B至少有一个参与者回复 Vote-No或者超时协调者决定中止Abort向所有参与者发送Abort消息。参与者收到 Abort 后回滚所有修改释放锁回复 Ack。协调者清理事务状态。全部 Yes任一 No 或超时协调者发送 Prepare收集投票发送 Commit发送 Abort参与者提交并释放锁参与者回滚并释放锁4. 2PC 的故障处理2PC 真正复杂的不是正常流程而是各种故障场景下的恢复。场景 1参与者在准备阶段崩溃崩溃前已持久化日志并发送了 Vote-Yes重启后参与者读取日志发现自己已经承诺提交。它会进入等待状态持续向协调者询问 Commit 或 Abort直到收到明确指令。崩溃前未发送 Vote-Yes重启后无该事务记录。若收到协调者的 Prepare直接回复 Vote-No因为无法确认之前的状态。场景 2参与者在提交阶段崩溃如果已收到 Commit 并持久化了结果重启后应继续完成应用并释放锁或重放日志。如果收到重复的 Commit 消息只需再次回复 Ack 即可。场景 3协调者崩溃这是 2PC 最棘手的故障点。在发送 Commit 前崩溃若协调者在做出提交决定例如持久化 Commit 日志后、发送 Commit 消息前崩溃所有投了 Vote-Yes 的参与者会因收不到最终决策而超时。此时参与者绝对不能单方面中止因为协调者已经决定了提交只是消息未发出。参与者只能进入阻塞状态持续等待协调者恢复并从其日志中读取提交决定重新发送 Commit。这恰恰是两阶段提交“阻塞问题”的核心。在发送 Commit 后崩溃此时可能部分参与者已经收到 Commit 并执行部分尚未收到。已经收到的参与者不能回滚因为协调者已经做出了提交决定。尚未收到的参与者会一直阻塞等待直到协调者重启并从日志中读取决策重新发送 Commit 或 Abort。这是 2PC 被诟病最多的阻塞问题如果协调者在发送 Commit 后崩溃且长时间无法恢复参与者会无限期持有锁阻塞其他事务。这也是为什么 2PC 不适合跨数据中心、长事务或高可用性要求极高的场景。场景 4网络消息丢失协调者未收到某参与者的投票超时后协调者可以单方面决定中止Abort避免无限等待。参与者已发送 Vote-Yes 但未收到 Commit/Abort绝对不能单方面中止因为协调者可能已经向其他参与者发送了 Commit。此时只能阻塞等待直到网络恢复或协调者重启。2PC 阻塞问题的本质2PC 的阻塞来源于“决策者单一且决策不可逆”一旦协调者决定了 Commit这个决定就不能撤销。如果协调者故障参与者不知道这个决定就只能等待。这种设计在跨组织、跨地域的分布式系统中尤其危险——网络分区或协调者宕机几小时整个系统就可能瘫痪。工业界后来的改进方案如Paxos Commit、三阶段提交 3PC试图缓解阻塞问题但要么增加复杂性要么在分区下仍无法完全避免阻塞。5. 2PC 的局限性总结问题描述性能慢至少两轮 RPC 多次强制刷盘fsync机械硬盘时代延迟可达 10ms 级别严重限制吞吐量。阻塞问题协调者故障时参与者可能无限期持有锁导致系统局部或整体不可用。单点故障协调者是关键节点崩溃后恢复期间事务会挂起。不适合长事务锁持有时间长冲突概率高且协调者故障风险累积。不适合跨数据中心网络延迟大且分区容忍性差网络分区时多数参与者无法投票协调者无法决策。尽管如此2PC 仍然是很多分布式数据库如 MySQL Cluster、PostgreSQL XC和分布式协调系统如 ZooKeeper 的多节点事务的底层基石。对于短事务、可靠网络、低延迟要求不极致的场景2PC 简单且正确。作者传说之后链接https://juejin.cn/post/7641149062687899711来源稀土掘金著作权归作者所有。商业转载请联系作者获得授权非商业转载请注明出处。