"用户下单要扣库存、扣余额、加积分,这三件事必须同时成功或同时失败。在单库里 BEGIN-COMMIT 就行;但库存在 A 服务,余额在 B 服务,积分在 C 服务 —— 怎么办?" 这就是分布式事务问题。它没有单库 ACID 那么干净的答案 —— 所有方案都是在一致性、可用性、性能之间的精心折衷。这篇文章把 2PC、3PC、TCC、Saga、消息事务讲透,讲清楚每种方案的适用边界和坑。
问题:单库事务在分布式失效
单库事务依赖数据库的两个能力:
- WAL(预写日志)+ 锁:保证原子性。
- 持久化 + 崩溃恢复:保证持久性。
跨多个数据库 / 服务后,这两个能力都失效:
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'X'; -- 库存库
UPDATE accounts SET balance = balance - 100 WHERE id = 'u1'; -- 账户库
INSERT INTO points (user_id, amount) VALUES ('u1', 10); -- 积分库
COMMIT;
-- 多库 COMMIT 不是原子的:中间网络断了怎么办?
这就需要"跨多个独立资源管理器(RM)的事务协调"机制。
2PC:两阶段提交
最经典的分布式事务协议,1970 年代提出。引入一个 事务协调者(TC),事务参与者称为 RM。流程:
阶段 1:Prepare(准备)
TC -> 所有 RM: "准备好提交了吗?"
RM 各自执行本地操作但不真正提交,记录日志,锁住资源
RM -> TC: "Yes / No"
阶段 2:Commit / Rollback
如果所有 RM 都说 Yes:
TC -> 所有 RM: "提交"
RM 真正提交,释放锁
否则:
TC -> 所有 RM: "回滚"
XA 协议就是 2PC 的标准实现。MySQL XA、Oracle DTP 都支持。Spring 的 JTA + Atomikos / Bitronix 是 Java 生态的典型 2PC 框架。
2PC 的致命问题
- 同步阻塞:第一阶段后所有 RM 都锁住资源等 TC,直到第二阶段。如果 TC 慢或网络抖,资源被长时间锁住,业务停摆。
- 单点故障:TC 挂了所有 RM 都不知道该提交还是回滚,卡死。
- 脑裂:阶段 2 时 TC 发出 commit 但只有一部分 RM 收到,数据不一致。
- 性能差:每次事务 4 次网络往返(2 准备 + 2 提交),且锁占用时间长。
这些缺陷让 2PC 在互联网级系统里几乎不被采用。金融核心系统因为对一致性要求极高、且 QPS 不高,可以接受 2PC 的代价。互联网业务普遍弃用。
3PC:三阶段提交
2PC 的改进,加一个 CanCommit 阶段,引入超时机制减少阻塞:
阶段 1:CanCommit 询问能否参与(不锁资源)
阶段 2:PreCommit (类似 2PC 的 Prepare,锁资源)
阶段 3:DoCommit / Abort
3PC 在某些场景下能避免"无限阻塞",但没有解决脑裂,且复杂度更高。实际使用很少,在工业界基本被 Raft / Paxos 替代。
TCC:Try-Confirm-Cancel
2PC 锁资源等待的问题,TCC 用业务补偿解决。每个操作分三步:
Try: 检查 + 预留资源(不真正扣)
Confirm:真正扣
Cancel: 释放预留
具体例子(下单)
// Try 阶段:三个服务各自预留
inventoryService.try_deduct(orderId, sku='X', qty=1); // 库存冻结(stock-1, frozen+1)
accountService.try_deduct(orderId, userId='u1', amount=100); // 余额冻结
pointsService.try_add(orderId, userId='u1', amount=10); // 积分预记
// 全部 Try 成功 -> Confirm
inventoryService.confirm(orderId); // 冻结的真正扣掉
accountService.confirm(orderId);
pointsService.confirm(orderId);
// 任一 Try 失败 -> Cancel
inventoryService.cancel(orderId); // 解冻
accountService.cancel(orderId);
// pointsService.cancel 如果还没 Try 也要能幂等执行
关键特性:
- Try 阶段做最严格的检查 —— 后面 Confirm 必须能成功。
- Confirm 和 Cancel 必须幂等 —— 网络重试不会出问题。
- 不长时间锁资源 —— Try 预留是业务级别的"冻结状态",而非数据库锁。
TCC 的优点和代价
优点:性能远好于 2PC,没有长事务和数据库锁。
代价:业务侵入大。每个服务要专门实现 try / confirm / cancel 三套接口,数据库要加 frozen / status 字段。简单业务不划算,只在高频 + 严格事务场景上(支付、金融、跨服务库存)用。
Saga 模式:长事务的最终一致方案
Saga 把一个长事务拆成 N 个本地事务,每个都有对应的补偿事务。任一步失败,顺次执行前面已完成步骤的补偿。
步骤 1:扣库存 -> 补偿:补回库存
步骤 2:扣余额 -> 补偿:补回余额
步骤 3:加积分 -> 补偿:扣回积分
步骤 4:发短信 -> 补偿:发"订单取消"短信
两种实现风格:
编排式(Choreography)
每个服务发出"事件",其他服务订阅自己感兴趣的:
OrderService:发 OrderCreated 事件
↓
InventoryService:订阅 OrderCreated -> 扣库存 -> 发 InventoryDeducted
↓
AccountService:订阅 InventoryDeducted -> 扣余额 -> 发 PaymentDone
↓
任一失败,该服务发对应的"失败事件",前面服务订阅后执行补偿
优点:服务间解耦,事件总线异步。
缺点:流程隐式分散在各服务里,排查难。
编制式(Orchestration)
一个"Saga 协调者"显式编排所有步骤:
class OrderSaga {
void execute(Order order) {
try {
step1 = inventoryService.deduct(order);
step2 = accountService.deduct(order);
step3 = pointsService.add(order);
} catch (Exception e) {
// 反向补偿
if (step3 succeeded) pointsService.remove(order);
if (step2 succeeded) accountService.refund(order);
if (step1 succeeded) inventoryService.add(order);
throw e;
}
}
}
优点:流程清晰集中,易调试。
缺点:协调者本身复杂,且要持久化进度以防它崩了。
主流框架:Seata(阿里)、Axon、Camunda。Saga 是互联网常用的"长事务"方案。
消息事务:最终一致的工程主流
大多数互联网业务的分布式事务,最后归到一个模式:本地事务 + 消息。核心思想:
- 本地操作 + 发消息 这两件事必须原子(要么都做,要么都不做)。
- 消费消息的下游通过"消息驱动"做自己的本地操作。
- 最终一致(异步收敛)。
本地消息表
-- 本地事务里同时改业务表 + 写消息表
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = 1001;
INSERT INTO outbox (event_type, payload) VALUES ('OrderPaid', '...');
COMMIT;
-- 后台轮询 outbox,发到消息队列
SELECT * FROM outbox WHERE sent = false LIMIT 100;
for each msg:
mq.publish(msg);
UPDATE outbox SET sent = true WHERE id = msg.id;
这种"Outbox 模式"保证"业务改 + 发消息"绝不脱节。Debezium / CDC 工具能直接从 binlog 读 outbox 表,无需轮询。
RocketMQ 事务消息
RocketMQ 原生支持事务消息:
1. Producer 发 "half message"(消息暂不投递给 Consumer)
2. 执行本地事务
3. 本地事务成功 -> commit half message(变成正式消息)
本地事务失败 -> rollback half message
半小时无应答 -> MQ 回调 Producer "你的本地事务到底成没成?"
# Java
producer.sendMessageInTransaction(msg, executor);
class MyExecutor implements TransactionListener {
public LocalTransactionState executeLocalTransaction(...) {
// 执行本地事务,返回 COMMIT / ROLLBACK / UNKNOWN
}
public LocalTransactionState checkLocalTransaction(...) {
// 被 MQ 回查时调用
}
}
幂等性:分布式事务的隐形必须
所有分布式事务方案都依赖"重试 + 幂等"。TCC 的 confirm / cancel 要幂等,Saga 的补偿要幂等,消息事务的消费要幂等。实现幂等的常见手段:
-- 1. 唯一约束 + 重复忽略
INSERT INTO points (order_id, ...) VALUES (...) ON DUPLICATE KEY DO NOTHING;
-- 2. 业务状态机:只在某个状态下允许变更
UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'CREATED';
-- 若返回 0 行,说明已经被处理过
-- 3. 幂等 token
INSERT INTO idempotent_log (token) VALUES (?); -- 第一次成功
-- 第二次因主键冲突失败 -> 知道已经处理过
-- 4. 版本号(乐观锁)
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
方案对比
方案 一致性强度 性能 业务侵入 适用场景
2PC/XA 强一致 差 中 金融核心、传统企业
TCC 强一致 中 大 高频金融业务
Saga 最终一致 好 中 长流程业务、跨服务
消息事务 最终一致 好 小 互联网常规场景
选型决策树
- 能用单库事务吗? 能 → 用单库,不要分布式。
- 能接受最终一致(几秒到几分钟)吗? 能 → 消息事务 / Saga(优先消息事务,实现简单)。
- 必须强一致?是金融核心吗? 是 → TCC。
- 实在不行? 上 2PC / XA,但接受性能差。
实战中的几个坑
坑 1:补偿逻辑没人维护。 Try / Cancel 写好后从来不验证。某天分布式事务真的失败,补偿代码因为业务变化早已不能用,数据脏掉。规范:补偿路径要专门测试,定期模拟失败场景。
坑 2:消息丢失。 本地事务成功但消息没发出去(MQ 故障)。Outbox 表 + 后台轮询是基本保证。
坑 3:消息重复消费。 重试 / MQ 重发导致下游收到两次。消费方必须幂等。
坑 4:补偿失败怎么办? 主流程失败开始补偿,补偿又失败 → 数据卡在中间状态。规范:补偿失败要告警,人工兜底;或者无限重试 + 监控阻塞队列。
坑 5:超时处理。 长事务里某步 Try 之后服务挂了,Confirm/Cancel 都没发出 —— 永远卡在中间。规范:每个事务有超时,超时后扫描器自动补偿。
Seata:阿里巴巴的分布式事务框架
Seata 是 Java 生态最流行的分布式事务框架,支持 AT、TCC、Saga、XA 四种模式。其中 AT(Automatic Transaction)模式最特别 —— 它对业务几乎无侵入:
@GlobalTransactional
public void placeOrder(Order order) {
inventoryService.deduct(order); // 普通 @Service 调用
accountService.deduct(order);
pointsService.add(order);
}
Seata AT 在背后做的事:
- 每个 SQL 执行前,自动记录"更新前的数据"到 undo_log 表。
- 本地 SQL 提交后,向 Seata 服务器报告"已提交"。
- 全局事务回滚时,Seata 通知各分支用 undo_log 反向恢复。
原理是"每个分支的本地事务先提交,出错时用 undo_log 回滚",绕过了 2PC 长锁的问题。代价:需要在数据库加 undo_log 表,且不能跨数据库类型(目前主要支持 MySQL)。
本地事务表 vs 分布式事务框架:选哪个
消息事务(Outbox 模式)和 Seata / Atomikos 等框架是不同抽象层级的方案:
- 消息事务:业务感强,需要自己设计"事件 + 消费幂等",但灵活、轻量、最适合互联网业务。
- 分布式事务框架:对业务侵入小(像写本地事务),但底层复杂、依赖中央协调器、性能受协调器影响。
大多数项目从消息事务起步,只有特殊场景(老系统不能改、强一致要求)才上 Seata 这类框架。
事务消息的工程坑
坑 1:消息发出但本地事务回滚。Outbox 模式天然避免这个(本地事务 + 消息表是同一个本地事务)。RocketMQ 半消息也避免。但"先 commit 数据库再发 MQ"的朴素写法会踩。
坑 2:消息发出但消费者一直失败。需要死信队列 + 人工兜底。死信队列里的消息要监控,否则积压成灾。
坑 3:消息顺序保证。"扣库存 → 发短信"乱序后,可能"扣库存失败也发短信"。Kafka 通过 key 路由到同一 partition 保证顺序;消息总线无序时,消费方要处理乱序(根据业务状态判断当前事件是否合法)。
Outbox 模式的 CDC 加速
传统 Outbox 用后台任务轮询表,有延迟。用 Debezium 这类 CDC 工具直接监听 MySQL binlog,变化几毫秒就传到 Kafka:
# Debezium 连接器配置
{
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql.internal",
"table.include.list": "myapp.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}
这种"Database as Source of Events"是事件驱动架构的现代标配,Outbox + CDC 让分布式事务在毫秒级完成。
写在最后
分布式事务的本质是"用工程换一致性"。所有方案都不像单库事务那么干净,都是在"正确性、性能、复杂度"间的权衡。最好的分布式事务是"不做分布式事务" —— 把业务边界划好,让一个事务能在单库里完成;实在不行,再选合适的协议。
给一个工程心得:分布式事务通常不是"用什么协议"的问题,而是"业务边界划得对不对"的问题。把"用户下单一定要扣库存 + 扣账户 + 加积分"这种强耦合的需求拆解,改成"下单完成 → 异步通知库存 / 账户 / 积分各自处理",一致性要求从"同时成功"变成"最终一致",难度立刻降一个量级。架构能力 = 看清"什么时候真的需要强一致"的能力。
—— 别看了 · 2026