当前位置: 首页 > news >正文

分布式事务指南:从二阶段锁到两阶段提交,了解核心设计

分布式事务:核心设计、并发控制与原子提交协议

构建跨多台服务器的数据库系统时,一个核心难题摆在面前:如何在保证数据一致性的前提下,让多个事务并发执行,同时还能在节点故障、网络分区时做到“全有或全无”?分布式事务正是解决这一问题的经典方案。它从单机事务的 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)。
  • 事务 ID(TID):每个事务消息都携带唯一的 TID,用于各节点跟踪状态和去重。
2. 第一阶段:准备阶段(Prepare Phase)

协调者向所有参与者发送Prepare消息,询问:“你们准备好提交这个事务了吗?”

每个参与者收到后:

  1. 检查自身状态(是否有死锁、约束是否满足、资源是否充足)。
  2. 将事务的所有修改写入预写日志(Write-Ahead Log)并强制刷盘。这是最关键的一步——参与者一旦回复“同意”,就必须承诺能够提交,即使之后自己崩溃重启,也能从日志中恢复并完成提交。
  3. 根据检查结果回复Vote-YesVote-No

协调者参与者 A参与者 BPrepare (TID)Prepare (TID)持久化日志,加锁持久化日志,加锁Vote-YesVote-Yes协调者参与者 A参与者 B

3. 第二阶段:提交/中止阶段
情况 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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

http://www.cnnetsun.cn/news/2466592.html

相关文章:

  • test aimate
  • 从零想法到可部署 MVP:v0 + Cursor + Vibe Coding 三步工作流实战
  • 树莓派部署CuraEngine:打造低功耗3D打印切片服务器
  • 5 分钟原型验证实战:Trae 在极速开发工作流中的 4 种快速试错策略
  • 日志分析效率提升3倍:Trae 轻量化自动化任务的 4 种正则提取模式
  • 《龙虾OpenClaw系列:从嵌入式裸机到芯片级系统深度实战60课》058、机器学习部署:TinyML在OpenClaw上的推理优化
  • 别光会调API!用RT-Thread Studio调试信号量死锁的实战记录(附排查思路)
  • Vue项目里如何优雅地导入和展示本地的.bpmn文件?一份包含raw-loader配置和样式定制的避坑指南
  • 嵌入式系统入门指南:从零基础到实践应用
  • 安信可VC离线语音模组进阶玩法:如何自定义唤醒词和命令词,打造你的智能语音灯
  • 从两张照片到全场位移:手把手教你用DIC技术分析桥梁裂缝扩展
  • ARM内存标记扩展(MTE)技术解析与应用实践
  • 告别PyInstaller!用Nuitka 1.9.5 + MinGW64打包Python程序,速度更快还防反编译
  • IoT设备数据存储新思路:FlashDB时序数据库模式,轻松搞定传感器数据记录与查询
  • 技术从业者职场沟通技巧:与产品经理、设计师和领导的高效沟通之道
  • 车间管理越管越乱?找准根源+避坑,跳出管理内耗
  • 当台风来袭时,电网如何“未雨绸缪”?聊聊应急移动电源(MPS)的预配置策略与实战价值
  • 别再被供电坑了!STM32F103C8T6驱动AS608指纹模块,实测3.3V引脚电压不足的解决方案
  • 从PN结到FinFET:CMOS工艺演进中的光刻与结构创新
  • MaskClip压电传感技术:医疗语音交互的硬件降噪方案
  • 从原理到实现:深入解析G.711语音压缩标准
  • Windows 11/10 下用 Python 和 Bleak 库玩转 BLE 设备:从扫描到收发数据的保姆级教程
  • MobaXterm自定义语法高亮进阶:修复绿色失效与打造个性化终端
  • MobileVIT架构解析与移动端部署实战
  • 把5G模组变成软路由:用RG200U-CN的PCIE接口玩转千兆交换与多网口扩展
  • 打造开放共赢生态,携手共育创新人才,AMD AI开发者大会首次在中国举行
  • 电机学笔记:从磁极对数到气隙磁密,掌握直流电机核心参数
  • DASP软件PREPARE模块:H掺杂Ga2O3缺陷计算前的超胞构建与参数校准
  • 别再手动刷固件了!用STM32CubeIDE搞定IAP升级,附F1/F4/H7多型号Bootloader源码
  • 告别理论!在CST中对比虚拟阵列与真实物理阵列的仿真结果差异(附工程文件)