分布式事务
本文是分布式算法科普系列第四篇。前三篇讲了服务发现、共识算法、流控——都是"怎么把活分下去"和"怎么保护自己不被冲垮"。这一篇回到一个老问题:一笔业务操作跨越了多个服务,怎么保证数据要么全成功、要么全回滚?
一、故事:数据库拆了,事务怎么办
1970 年代,随着数据库从单机走向网络化,一个此前不存在的问题浮现出来——一笔业务需要同时修改两台机器上的数据,怎么保证原子性?
在单机数据库上,事务是再自然不过的事情——BEGIN → 改 A 表 → 改 B 表 → COMMIT。数据库内部用 undo log 和 redo log 保证崩溃恢复后数据的一致性。但如果 A 表在机器 1 上,B 表在机器 2 上——COMMIT 只对机器 1 生效,机器 2 没收到,或者收到了但执行到一半宕机了——怎么办?
Jim Gray 在 1978 年的《Notes on Data Base Operating Systems》中首次系统描述了两阶段提交(2PC,Two-Phase Commit)——用一个"协调者"站在所有参与者中间,分两步确认:第一步问所有人"准备好了没",第二步根据所有人的答复决定"一起提交"还是"一起回滚"。
这个设计的影响延续至今。XA 规范(1991 年由 X/Open 组织发布)将 2PC 标准化为分布式事务处理的工业协议。几乎所有关系型数据库(MySQL、Oracle、PostgreSQL)都支持 XA 事务。
但 2PC 有一个众所周知的痛点——同步阻塞。协调者挂了,参与者只能干等。于是在微服务时代,一种更灵活的方案出现了——TCC(Try-Confirm-Cancel),把二阶段的"锁资源"升级为"预留资源 + 确认或释放"。
二、前置:单机事务不够用了
先从业务场景开始。一个典型的电商下单流程:
下单(Order 服务)→ 扣库存(Inventory 服务)→ 扣余额(Account 服务)
三个操作跨了三个服务、三套数据库。如果在"扣库存"成功后、“扣余额"之前——Account 服务宕机了——库存扣了,但余额没扣,钱没收,货没了。
这就是分布式事务要解决的问题——跨多个服务(多个数据库)的一组操作,要么全部成功,要么全部回滚。
单机事务靠 ACID 保证——原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。分布式事务的目标也是 ACID,但实现手段完全不同——它不靠数据库内部的 undo/redo log,而是靠多个参与者之间的协调协议。
📌 前置知识:ACID 中的原子性(Atomicity)指的是"一个事务中的所有操作要么全做、要么全不做”——不是物理上的"不可分割",而是逻辑上"失败时自动回滚到事务开始前的状态"。分布式事务的"原子性"也是这个意思——跨服务的操作失败时,每个服务各自回滚。
三、2PC——两阶段提交
3.1 角色与两个阶段
2PC 引入一个协调者(Coordinator)——它不执行业务逻辑,只管"问"和"拍板"。真正执行操作的是参与者(Participant)——各个微服务。
2PC 把一次分布式事务分成两个阶段:
阶段一(Prepare / 表决阶段)
协调者 → 问所有参与者:"这个操作你能做吗?"
参与者 → 各自执行操作——但不提交——把结果锁住——回复"可以"或"不行"
阶段二(Commit / 执行阶段)
协调者 → 所有人的回复都是"可以" → 通知所有人:"提交!"
协调者 → 有任何人回复"不行" → 通知所有人:"回滚!"
参与者 → 执行协调者的指令——提交或回滚——释放锁
sequenceDiagram
participant C as 协调者 (Coordinator)
participant P1 as 参与者A (Order服务)
participant P2 as 参与者B (Inventory服务)
Note over C,P2: 阶段一——Prepare 表决
C->>P1: Prepare——准备扣库存
P1->>P1: 执行SQL——锁定数据行\n但不提交事务
P1-->>C: YES——准备好了
C->>P2: Prepare——准备扣余额
P2->>P2: 执行SQL——锁定数据行\n但不提交事务
P2-->>C: YES——准备好了
Note over C,P2: 阶段二——Commit 执行
C->>C: 所有人都回复YES——决定提交
C->>P1: Commit——提交事务
P1->>P1: COMMIT——释放锁
P1-->>C: ACK——已提交
C->>P2: Commit——提交事务
P2->>P2: COMMIT——释放锁
P2-->>C: ACK——已提交
Note over C: 事务完成 ✅
3.2 如果有人在 Prepare 阶段说了 NO
如果参与者 B 回复"不行"——协调者不会进入 Commit,而是向所有人发送 Rollback:
sequenceDiagram
participant C as 协调者
participant P1 as 参与者A——订单
participant P2 as 参与者B——库存
C->>P1: Prepare——创建订单
P1-->>C: YES
C->>P2: Prepare——扣库存
P2-->>C: NO——库存不足!
C->>C: 有人回复NO——决定回滚
C->>P1: Rollback——回滚
P1->>P1: ROLLBACK——撤销订单
P1-->>C: ACK
Note over C: 事务回滚——数据回到初始状态 ❌
3.3 2PC 的核心问题——协调者宕机
2PC 最大的软肋是协调者自己有单点故障。如果协调者在发出 Commit 指令之前宕机了——参与者已经在 Prepare 阶段锁住了数据,不知道接下来该提交还是回滚——只能干等协调者恢复。
这被称为阻塞问题(Blocking Problem)。参与者手里的锁在协调者恢复之前无法释放,可能导致大量其他事务被阻塞。
flowchart LR
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
COORD["协调者——已收集所有\n参与者的 Prepare 回复\n准备发送 Commit"]:::highlight
COORD --> CRASH["⚡ 协调者宕机\nCommit 指令未发出"]:::reject
CRASH --> P1["参与者A——数据已锁定\n不知道下一步——干等"]:::condition
CRASH --> P2["参与者B——数据已锁定\n不知道下一步——干等"]:::condition
P1 --> LOCK["🔒 锁一直不释放\n其他事务被阻塞\n整个系统卡住"]:::reject
P2 --> LOCK
⚠️ 新手提示:有些资料说"3PC(三阶段提交)解决了 2PC 的阻塞问题"——这个说法不完全对。3PC 通过引入超时机制减少了阻塞的概率,但如果发生网络分区,3PC 同样可能脑裂。生产环境中用 3PC 的系统很少,反而是 TCC 更实用。
四、TCC——Try、Confirm、Cancel
4.1 核心思路——从"锁资源"到"预留资源"
2PC 在 Prepare 阶段依赖数据库的行锁来保证数据一致性——锁住被修改的数据行,直到第二阶段决定提交或回滚。锁是强力的,但也是笨重的——锁着的时候其他事务完全无法操作这些数据。
TCC 换了一个思路:不靠数据库锁,而是让业务代码自己提供三个阶段的操作:
| 阶段 | 干了什么 | 类比 |
|---|---|---|
| Try | 预留资源——检查是否可以执行——但不真正执行 | 订机票时"锁定座位"——还没出票——别人不能抢 |
| Confirm | 确认执行——真正使用预留的资源 | 付款成功后"出票"——座位真正归你 |
| Cancel | 释放预留资源——恢复到 Try 之前 | 付款失败——“释放座位”——别人可以订 |
可以把 TCC 理解为会议室的预约系统。Try:在前台预约某个时段的会议室——这时会议室还不能用(别人不能同时预约),但也没真正占用。Confirm:到时间了,确认使用,会议室正式被占用。Cancel:取消预约,释放时段,别人可以重新预约。
4.2 TCC 的完整流程
sequenceDiagram
participant TM as 事务管理器 (TM)
participant S1 as 订单服务 (Try/Confirm/Cancel)
participant S2 as 库存服务 (Try/Confirm/Cancel)
Note over TM,S2: Try 阶段——预留资源
TM->>S1: Try——创建订单(状态=PENDING)
S1->>S1: INSERT订单——状态=PENDING\n不是最终状态
S1-->>TM: OK——订单已预留
TM->>S2: Try——预扣库存(冻结库存数)
S2->>S2: UPDATE库存——冻结数+1\n可用库存不变——冻结数增加
S2-->>TM: OK——库存已预留
Note over TM,S2: Confirm 阶段——确认执行
TM->>TM: 所有Try成功——决定Confirm
TM->>S1: Confirm——确认订单
S1->>S1: UPDATE订单——状态=CONFIRMED
S1-->>TM: OK
TM->>S2: Confirm——确认扣库存
S2->>S2: UPDATE库存——可用-1——冻结-1
S2-->>TM: OK
Note over TM: 事务完成 ✅
如果 Try 阶段有任何参与者返回失败——TM 向所有人发 Cancel:
Cancel 阶段——释放预留
TM → 订单服务: Cancel——取消订单
订单服务 → UPDATE 订单——状态=CANCELLED
TM → 库存服务: Cancel——释放冻结库存
库存服务 → UPDATE 库存——冻结数-1(可用库存不变——没真正扣过)
4.3 TCC 的 Confirm 和 Cancel 必须是幂等的
这是 TCC 最容易踩的坑。网络超时可能导致 TM 重试 Confirm 或 Cancel——如果 Confirm 被调了两次,扣库存不能扣两次。Cancel 同理——释放冻结库存不能释放两次变成负数。
TCC 的每个参与者都要自己保证幂等性(通常通过唯一事务 ID + 状态机判断来防止重复执行)。
⚠️ 新手提示:幂等性(Idempotency)——同一个操作执行一次和执行多次,结果相同。比如"设置 x=5"是幂等的——执行 100 次,x 还是 5。“x+1"不是幂等的——每次执行结果都不同。TCC 的 Confirm 和 Cancel 必须是"设置为某个状态"而不是"加减某个值”——这样才能扛住网络重试。
五、2PC vs TCC 对比
| 维度 | 2PC | TCC |
|---|---|---|
| 资源隔离方式 | 数据库行锁——锁住数据直到第二阶段 | 业务预留——通过状态字段(PENDING/FROZEN)隔离 |
| 对业务的侵入性 | 低——数据库层面——业务代码几乎无感知 | 高——每个参与者必须实现 Try/Confirm/Cancel 三个接口 |
| 阻塞风险 | 高——协调者宕机导致参与者锁等待 | 低——不依赖数据库锁——超时后自动 Cancel |
| 性能 | 较差——第一阶段就锁表——并发度低 | 较好——Try 阶段只改状态字段——不锁核心数据 |
| 回滚复杂度 | 低——数据库自动 ROLLBACK | 高——Cancel 逻辑要自己写——涉及各种补偿 |
| 适用场景 | 短事务、对一致性要求极高 | 长事务、对并发和可用性要求高 |
| 典型实现 | Seata AT 模式、XA 事务 | Seata TCC 模式 |
没有谁更好,只有谁更合适。如果业务简单、事务执行快(几十毫秒)、并发量低——2PC 够用。如果业务复杂、事务可能跨几分钟(涉及人工审批)、并发量高——TCC 更合适。
六、Seata 如何实现这两种模式
Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务中间件,它的 AT 模式和 TCC 模式分别对应 2PC 和 TCC 两种算法。
6.1 AT 模式——自动挡的 2PC
AT 模式的思路是对业务代码零侵入——业务开发者只管写自己的 SQL,Seata 自动做 2PC:
flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
BIZ["业务代码——执行业务SQL\nUPDATE inventory SET stock=stock-1\n(开发者只写这一行)"]:::startEnd
BIZ --> BEFORE["Seata——执行SQL之前\n自动生成前置镜像\nSELECT stock FROM inventory → before_image\n记录修改前的值"]:::process
BEFORE --> AFTER["业务SQL执行后\n自动生成后置镜像\nSELECT stock FROM inventory → after_image\n记录修改后的值"]:::process
AFTER --> UNDO["把 undo_log 写入数据库\n(和业务数据在同一个事务里)\nundo_log={before_image, after_image, table, pk}"]:::data
UNDO --> PHASE1["阶段一完成——本地事务提交
✅ 成功了 → 通知 TC——一阶段提交成功
❌ 失败了 → 通知 TC——一阶段失败——TC 发回滚"]:::highlight
PHASE1 --> PHASE2["阶段二——TC 根据全局\n所有分支的结果决定:
✅ 全部成功 → 异步删除 undo_log
❌ 有失败 → 用 undo_log 生成反向SQL——回滚数据"]:::data
Seata AT 模式的核心是undo_log 表——Seata 自动在业务数据库里建一张 undo_log 表,拦截所有 SQL,自动记录修改前和修改后的快照。回滚时,用 before_image 生成反向 UPDATE 语句把数据改回去。
⚠️ 新手提示:AT 模式的回滚不是数据库的 ROLLBACK——一阶段的本地事务已经 COMMIT 了。AT 的回滚是补偿——用 undo_log 生成反向 SQL 把数据恢复成修改前的样子。这是 AT 模式和 XA 2PC 的关键区别——XA 在一阶段不提交事务、锁一直不释放;AT 在一阶段就提交了、只靠 undo_log 来补救。
6.2 TCC 模式——手动挡的补偿型事务
TCC 模式需要业务开发者自己实现 Try / Confirm / Cancel 三个方法:
Try: 冻结库存(stock_frozen + 1)
Confirm: 确认扣库存(stock - 1, stock_frozen - 1)
Cancel: 释放冻结(stock_frozen - 1)
每个方法都必须幂等——Seata 的 TCC 框架会通过事务 ID 做幂等控制,但业务开发者需要在数据库层面配合(比如用唯一索引防止重复插入、用状态机防止重复扣减)。
七、总结
flowchart TD
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
Q["分布式事务解决什么"]:::process --> Q1["一笔业务跨多个服务\n要么全成功\n要么全回滚"]:::process
Q --> Q2["单机事务的 ACID\n在分布式环境下\n需要协调协议来保证"]:::process
Q1 --> A1["2PC——两阶段提交\nPrepare——锁资源——表决\nCommit/Rollback——执行"]:::data
Q2 --> A2["TCC——Try/Confirm/Cancel\nTry——预留资源\nConfirm——确认\nCancel——释放"]:::data
A1 --> TRADE1["优势——对业务无侵入\n劣势——同步阻塞——性能差"]:::highlight
A2 --> TRADE2["优势——高性能——无锁\n劣势——侵入业务——幂等难"]:::highlight
TRADE1 --> USE["Seata AT 模式 = 自动挡 2PC\n——通过undo_log补偿回滚\nSeata TCC 模式 = 手动挡 TCC\n——需要自己实现三阶段"]:::data
TRADE2 --> USE
USE --> NEXT["分布式事务保证了数据一致\n但异步消息怎么保证\n一定能投递成功?\n→ 下一篇:事务消息与回查"]:::process
一句话记住 —— 2PC 靠锁保证一致性,笨重但简单;TCC 靠业务补偿保证一致性,灵活但对开发者要求高。实际选型时,大部分场景用 Seata AT(自动挡)就够了。当遇到高并发扣库存、长事务跨多系统这类 AT 扛不住的场景时,再考虑 TCC。
下一篇讲 RocketMQ 的事务消息——另一种处理分布式事务的思路——不靠锁也不靠补偿接口,靠"半消息 + 回查"来保证异步场景下的事务一致性。
📖 系列导航:本文是分布式算法科普系列第 4 篇。上一篇:流控算法三件套:滑动窗口、漏桶与令牌桶,讲 Sentinel 如何限流。下一篇:事务消息:半消息与回查,讲 RocketMQ 怎么用半消息保证分布式事务的最终一致性。