分布式事务的演进与实战:从 2PC 到 Saga 的十年踩坑路
引子:一次"成功"的失败
2023 年双十一,某电商平台发生了一起诡异的事件:
用户下单支付成功,订单状态显示"已完成",但仓库系统没有收到发货指令。客服接到投诉后手动补发,结果财务对账时发现同一笔订单收了两次钱。
问题出在哪里?
用户服务 → 扣款成功
订单服务 → 状态更新成功
仓库服务 → 网络超时,发货失败三个服务,两个成功,一个失败。数据不一致了。
这就是分布式事务问题:在多个独立的服务之间,如何保证数据操作的原子性。
单库事务有 ACID 兜底,但跨服务呢?没有银弹,只有取舍。
第一阶段:两阶段提交(2PC)—— 理想很丰满
核心思想
2PC 的思想极其简单:找一个协调者,问所有人"能提交吗",都同意才提交。
┌─────────────┐
│ 协调者 │
└──────┬──────┘
│
┌───┴───┐
▼ ▼
┌─────┐ ┌─────┐
│ A │ │ B │
│参与者│ │参与者│
└─────┘ └─────┘阶段一(Prepare):
- 协调者问所有参与者:"能提交吗?"
- 参与者执行本地事务,但不提交
- 参与者回复:"能"或"不能"
阶段二(Commit/Rollback):
- 如果所有人都说"能",协调者发"提交"指令
- 如果有人说"不能",协调者发"回滚"指令
- 参与者执行对应操作
为什么 2PC 在生产环境几乎不用
问题一:阻塞协议
阶段一完成后,参与者持有锁,等待协调者指令。如果协调者挂了,参与者一直阻塞。
时间线:
T1: 协调者发 Prepare
T2: 参与者 A 执行本地事务,持锁等待
T3: 参与者 B 执行本地事务,持锁等待
T4: 协调者宕机
T5: 参与者 A 继续等待...
T6: 参与者 B 继续等待...
T∞: 锁永不释放,资源耗尽生产数据:某金融系统使用 2PC,协调者单点故障导致:
- 平均阻塞时间:47 分钟
- 高峰期锁等待:2000+ 事务
- 恢复时间:人工介入 + 重启 = 2 小时
问题二:性能瓶颈
2PC 需要两轮网络往返,每次事务至少 4 次网络通信:
协调者 → 参与者 A: Prepare
协调者 → 参与者 B: Prepare
参与者 A → 协调者:Yes
参与者 B → 协调者:Yes
协调者 → 参与者 A: Commit
协调者 → 参与者 B: Commit
参与者 A → 协调者:Ack
参与者 B → 协调者:Ack性能测试数据(1000 并发):
| 方案 | TPS | 平均延迟 |
|---|---|---|
| 本地事务 | 5000 | 5ms |
| 2PC | 800 | 45ms |
| 下降幅度 | 84% | 800% |
问题三:数据不一致窗口
阶段二如果部分参与者收到 Commit,部分没收到,就会出现部分提交:
协调者发 Commit:
- 参与者 A 收到 → 提交
- 参与者 B 网络超时 → 未收到
- 协调者认为完成 → 退出
结果:A 提交,B 未提交,数据不一致2PC 的遗产
虽然 2PC 在生产环境很少直接使用,但它的思想影响了后续所有方案:
- XA 协议:2PC 的标准化版本,数据库广泛支持
- TCC:2PC 的改进版,解决阻塞问题
- 共识算法:Paxos、Raft 都借鉴了 2PC 的协调思想
核心教训:分布式系统中,协调者单点是原罪。
第二阶段:TCC —— 把阻塞变成补偿
核心思想
TCC(Try-Confirm-Cancel)的核心突破:不持锁等待,用补偿代替回滚。
每个事务拆成三个操作:
Try: 预留资源,检查可行性
Confirm: 确认提交,使用预留资源
Cancel: 取消预留,释放资源以转账为例:
// 账户 A 扣款
@TCC
public void deduct(AccountA, amount) {
// Try: 冻结资金
freeze(AccountA, amount);
// Confirm: 实际扣款
// (Try 成功后自动执行)
doDeduct(AccountA, amount);
// Cancel: 解冻资金
// (Try 失败时执行)
unfreeze(AccountA, amount);
}
// 账户 B 入账
@TCC
public void credit(AccountB, amount) {
// Try: 预占额度
reserve(AccountB, amount);
// Confirm: 实际入账
doCredit(AccountB, amount);
// Cancel: 释放预占
release(AccountB, amount);
}TCC 的优势
1. 不持锁等待
Try 阶段只预留资源,不阻塞。Confirm/Cancel 异步执行。
2PC 时间线:
T1: Prepare → 持锁
T2: 等待协调者... (阻塞)
T3: Commit → 释放
TCC 时间线:
T1: Try → 预留
T2: 立即返回 (不阻塞)
T3: Confirm 异步执行 → 提交性能对比(相同场景):
| 指标 | 2PC | TCC |
|---|---|---|
| TPS | 800 | 3500 |
| P99 延迟 | 200ms | 25ms |
| 资源占用 | 高 | 低 |
2. 业务可控
TCC 的补偿逻辑是业务代码,可以精细控制:
// 补偿时可以:
// - 记录补偿日志
// - 发送告警通知
// - 降级处理
// - 人工介入3. 最终一致性
TCC 不要求强一致,允许短暂不一致,但最终会一致。
TCC 的坑
坑一:业务侵入性极强
每个服务都要实现 Try/Confirm/Cancel 三个方法。代码量翻倍,逻辑复杂度翻倍。
原逻辑:1 个方法
TCC 后:3 个方法 + 状态管理 + 补偿逻辑生产案例:某支付系统改造 TCC:
- 改造前:50 个服务,平均每个服务 2000 行代码
- 改造后:平均每个服务 5000 行代码
- 开发周期:3 个月 → 8 个月
- Bug 数量:增加 3 倍
坑二:悬挂问题
Try 和 Confirm/Cancel 可能乱序执行:
时间线:
T1: Try 请求发出
T2: Try 网络超时,协调者认为失败
T3: 协调者发 Cancel
T4: Cancel 执行完成
T5: Try 请求才到达参与者(网络延迟)
T6: Try 执行,资源预留
T7: 没有对应的 Confirm 来提交
结果:资源永久预留,悬挂解决方案:记录事务状态表,Check 悬挂。
坑三:空补偿问题
Cancel 可能在 Try 之前执行:
时间线:
T1: Try 请求丢失
T2: 协调者超时,发 Cancel
T3: Cancel 到达,但 Try 没执行过
结果:补偿了没做过的事解决方案:Cancel 时检查 Try 是否执行过。
坑四:幂等性要求
网络重试会导致重复调用,所有操作必须幂等:
// 必须处理:
// - 重复的 Try
// - 重复的 Confirm
// - 重复的 CancelTCC 适用场景
适合:
- 业务逻辑复杂,需要精细控制
- 对性能要求高
- 团队有能力处理复杂逻辑
不适合:
- 简单业务(杀鸡用牛刀)
- 快速迭代的业务(维护成本高)
- 小团队(人力不足)
第三阶段:Saga —— 长事务的优雅解法
核心思想
Saga 的思想:把长事务拆成一系列本地短事务,每个事务有对应的补偿操作。
事务流程:
T1 → T2 → T3 → T4
补偿流程:
C4 → C3 → C2 → C1(反向执行)以电商下单为例:
正向流程:
1. 创建订单
2. 扣减库存
3. 扣款
4. 发送通知
补偿流程(如果第 3 步失败):
1. 取消通知(跳过,未执行)
2. 退款
3. 恢复库存
4. 取消订单Saga 的两种模式
1. 编排式(Orchestration)
有一个编排者(Orchestrator)负责调度:
┌───────────────┐
│ Orchestrator │
└───────┬───────┘
│ 调用
┌────┴────┐
▼ ▼
┌─────┐ ┌─────┐
│ S1 │ │ S2 │
│服务 │ │服务 │
└─────┘ └─────┘优点:
- 流程清晰,集中控制
- 易于监控和调试
- 补偿逻辑集中管理
缺点:
- 编排者单点
- 编排者成为瓶颈
2. 协同式(Choreography)
服务之间通过事件触发:
S1 完成 → 发布事件 E1 → S2 监听 E1 执行
S2 完成 → 发布事件 E2 → S3 监听 E2 执行
S3 失败 → 发布补偿事件 → 前面服务补偿优点:
- 去中心化
- 服务解耦
缺点:
- 流程难以追踪
- 循环依赖风险
- 调试困难
生产建议:优先选择编排式,除非服务数量极大(50+)。
Saga 的实战经验
经验一:补偿操作必须幂等
网络重试是常态,补偿操作可能被多次调用:
// 错误的补偿
public void refund(String orderId) {
// 直接退款 → 可能重复退款
account.addMoney(amount);
}
// 正确的补偿
public void refund(String orderId) {
// 检查是否已补偿
if (compensationRecord.exists(orderId)) {
return; // 幂等返回
}
account.addMoney(amount);
compensationRecord.save(orderId);
}经验二:设计补偿超时要谨慎
补偿操作也可能失败,需要重试机制:
补偿策略:
- 立即重试:3 次,间隔 1 秒
- 指数退避:3 次,间隔 1s, 2s, 4s
- 降级处理:转人工
- 告警通知:通知运维生产数据:某系统 Saga 补偿成功率:
| 重试次数 | 成功率 |
|---|---|
| 0 次 | 78% |
| 1 次 | 92% |
| 2 次 | 97% |
| 3 次 | 99% |
| 3 次 + 退避 | 99.5% |
经验三:记录完整的事务日志
Saga 执行过程中,每一步都要记录:
{
"sagaId": "saga-123",
"status": "COMPENSATING",
"steps": [
{"step": 1, "action": "CREATE_ORDER", "status": "SUCCESS", "timestamp": "..."},
{"step": 2, "action": "DEDUCT_STOCK", "status": "SUCCESS", "timestamp": "..."},
{"step": 3, "action": "DEDUCT_MONEY", "status": "FAILED", "timestamp": "...", "error": "..."},
{"step": 2, "action": "RESTORE_STOCK", "status": "PENDING", "timestamp": "..."}
]
}价值:
- 问题排查:知道哪一步失败
- 人工介入:知道补偿到哪一步
- 数据分析:统计失败率
经验四:隔离级别要清晰
Saga 允许中间状态可见,要明确业务是否能接受:
场景:用户下单后库存立即扣减
问题:用户看到"下单成功",但后续扣款失败,订单取消
影响:其他用户看到库存减少,但实际会恢复
解决方案:
- 方案 A:库存扣减用"预占",对外显示可用库存不减
- 方案 B:接受短暂不一致,最终一致即可
- 方案 C:扣款成功后再扣库存(改变顺序)Saga 的局限性
1. 不保证隔离性
Saga 的中间状态对外可见,可能出现脏读:
T1: Saga A 扣减库存(未提交)
T2: Saga B 查询库存(看到已扣减)
T3: Saga A 失败,补偿恢复库存
T4: Saga B 基于错误库存做决策解决方案:业务层面规避,或引入版本控制。
2. 补偿可能失败
补偿操作本身也可能失败:
T1: 扣款成功
T2: 需要补偿退款
T3: 退款接口超时
T4: 重试退款
T5: 退款接口返回"账户不存在"解决方案:
- 补偿操作设计为"最终能成功"
- 无法自动补偿的,转人工
- 建立对账机制,定期修复
第四阶段:本地消息表 —— 最简单实用的方案
核心思想
本地消息表的思想极其简单:把分布式事务转化成本地事务 + 异步消息。
步骤:
1. 业务操作 + 消息记录,放在一个本地事务
2. 异步发送消息
3. 消费者处理消息
4. 失败重试,保证最终一致代码示例:
@Transactional // 本地事务
public void createOrder(Order order) {
// 1. 创建订单
orderMapper.insert(order);
// 2. 记录消息(同一事务)
messageMapper.insert({
"topic": "order_created",
"payload": order.toJson(),
"status": "PENDING"
});
}
// 异步任务
@Scheduled(fixedDelay = 1000)
public void sendPendingMessages() {
List<Message> pending = messageMapper.findPending();
for (Message msg : pending) {
try {
mq.send(msg.topic, msg.payload);
msg.status = "SENT";
messageMapper.update(msg);
} catch (Exception e) {
// 失败不更新,下次重试
}
}
}为什么本地消息表最实用
1. 实现简单
- 不需要复杂的框架
- 不需要额外的服务
- 代码量少,易于理解
2. 性能优秀
- 本地事务,无网络开销
- 异步发送,不阻塞主流程
- 批量发送,提高吞吐
性能对比(1000 并发):
| 方案 | TPS | P99 延迟 |
|---|---|---|
| 2PC | 800 | 200ms |
| TCC | 3500 | 25ms |
| Saga | 4000 | 20ms |
| 本地消息表 | 4500 | 15ms |
3. 最终一致性保证
通过重试机制,保证消息最终送达:
重试策略:
- 第 1-3 次:立即重试
- 第 4-10 次:指数退避(1s, 2s, 4s...)
- 第 11-20 次:每分钟重试
- 20 次以上:转人工,告警生产数据:某系统消息投递成功率:
- 1 分钟内:95%
- 5 分钟内:99%
- 30 分钟内:99.9%
- 最终:100%(人工兜底)
本地消息表的坑
坑一:消息表膨胀
消息记录只增不减,表会越来越大:
数据量增长:
- 日订单量:100 万
- 消息记录:100 万/天
- 一个月:3000 万条
- 一年:3.6 亿条解决方案:
- 定期归档(发送成功的消息移到历史表)
- 分库分表
- 设置 TTL,自动清理
坑二:消息重复消费
网络重试会导致消息重复:
T1: 发送消息
T2: 消费者处理
T3: 确认 ACK 丢失
T4: 消息重发
T5: 消费者再次处理解决方案:消费者幂等处理(见下文)。
坑三:消息顺序问题
同一订单的多个消息可能乱序:
发送顺序:创建 → 支付 → 发货
接收顺序:创建 → 发货 → 支付(乱序)解决方案:
- 同一订单的消息发往同一队列(Kafka Partition)
- 消费者单线程处理同一订单
- 业务层面处理乱序(版本号、时间戳)
幂等性设计 —— 分布式事务的基石
为什么幂等性如此重要
分布式系统的铁律:任何网络调用都可能重复。
可能重复的场景:
- 请求超时,客户端重试
- 服务端处理完成,ACK 丢失
- 网络抖动,请求重发
- 负载均衡,请求路由到不同实例没有幂等性,分布式事务就是空中楼阁。
幂等性实现方案
方案一:唯一键约束
利用数据库唯一索引:
CREATE TABLE order_payment (
order_id VARCHAR(32),
payment_id VARCHAR(32), -- 唯一键
amount DECIMAL(10,2),
UNIQUE KEY uk_payment (payment_id)
);
-- 插入时:
INSERT INTO order_payment VALUES (...)
ON DUPLICATE KEY UPDATE amount = VALUES(amount);优点:简单可靠,数据库保证 缺点:只能防重复插入,不能防重复更新
方案二:状态机
用状态控制操作合法性:
public void pay(String orderId) {
Order order = orderMapper.select(orderId);
// 检查状态
if (order.status != "UNPAID") {
return; // 已支付,幂等返回
}
// 更新状态(带条件)
int rows = orderMapper.updateStatus(
orderId,
"UNPAID", // 期望状态
"PAID" // 目标状态
);
if (rows == 0) {
// 并发情况下可能被其他请求先更新
return;
}
// 继续处理...
}优点:业务语义清晰 缺点:需要设计状态机
方案三:去重表
单独维护去重记录:
CREATE TABLE idempotent_record (
biz_type VARCHAR(32), -- 业务类型
biz_id VARCHAR(64), -- 业务 ID
request_id VARCHAR(64), -- 请求 ID(幂等键)
result TEXT, -- 处理结果
created_at TIMESTAMP,
UNIQUE KEY uk_request (biz_type, biz_id, request_id)
);public Response process(Request req) {
// 1. 查去重表
Record record = recordMapper.select(req.bizType, req.bizId, req.requestId);
if (record != null) {
return record.result; // 返回之前结果
}
// 2. 处理业务
Response resp = doProcess(req);
// 3. 记录结果
recordMapper.insert(req.bizType, req.bizId, req.requestId, resp);
return resp;
}优点:通用,可复用 缺点:多一次数据库查询
幂等性设计原则
原则一:前端生成幂等键
错误做法:服务端生成幂等键
问题:重试时生成不同的键,无法去重
正确做法:前端生成 UUID 作为幂等键
优势:重试时携带相同键,服务端可识别原则二:业务语义幂等
技术幂等:同一请求返回相同结果
业务幂等:同一操作产生相同业务效果
案例:
- 转账 100 元,执行两次 → 技术幂等(返回成功),业务不幂等(转了 200)
- 解决方案:用订单号作为幂等键,同一订单只处理一次原则三:查询接口也要幂等
误区:只有写操作需要幂等
真相:查询也可能有副作用
案例:
- 查询并扣减次数
- 查询并标记已读
- 查询并领取奖励
解决方案:查询接口设计为纯函数,无副作用方案选型指南
决策树
需要强一致吗?
├── 是 → 能用本地事务吗?
│ ├── 是 → 本地事务(最优)
│ └── 否 → 2PC/XA(接受性能损失)
│
└── 否(接受最终一致)→ 业务复杂度?
├── 低 → 本地消息表(最简单)
├── 中 → Saga(编排式)
└── 高 → TCC(精细控制)各方案对比
| 维度 | 2PC | TCC | Saga | 本地消息表 |
|---|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | 最终一致 | 最终一致 |
| 性能 | 低 | 高 | 高 | 最高 |
| 实现复杂度 | 低 | 高 | 中 | 低 |
| 业务侵入 | 低 | 高 | 中 | 低 |
| 适用场景 | 金融核心 | 复杂业务 | 长流程 | 大多数场景 |
生产环境推荐
推荐方案组合:
80% 场景:本地消息表
- 简单,性能好,维护成本低
15% 场景:Saga
- 长流程,多服务协作
4% 场景:TCC
- 业务复杂,需要精细控制
1% 场景:2PC/XA
- 金融核心,监管要求强一致实战案例:电商订单系统
业务场景
下单流程:
1. 创建订单(订单服务)
2. 扣减库存(库存服务)
3. 扣款(支付服务)
4. 发送通知(通知服务)
5. 记录积分(积分服务)方案设计
核心原则:下单体验优先,后台最终一致。
用户侧:
- 创建订单 + 预占库存 → 同步返回"下单成功"
- 用户看到订单,可以支付
后台侧:
- 支付结果 → 异步消息 → 扣款、积分、通知
- 失败 → 重试 → 人工兜底技术选型:
| 环节 | 方案 | 理由 |
|---|---|---|
| 订单 + 库存 | 本地事务 | 同一数据库,强一致 |
| 库存 + 支付 | 本地消息表 | 最终一致,性能好 |
| 支付 + 积分 | 本地消息表 | 同上 |
| 通知 | 异步消息 | 非核心,可延迟 |
关键代码
下单接口:
@Transactional
public OrderResult createOrder(CreateOrderRequest req) {
// 1. 创建订单
Order order = new Order();
order.setId(IdGenerator.generate());
order.setStatus("CREATED");
orderMapper.insert(order);
// 2. 预占库存(同一事务)
for (OrderItem item : req.getItems()) {
int rows = stockMapper.deduct(item.skuId, item.quantity, "PRE_OCCUPIED");
if (rows == 0) {
throw new StockNotEnoughException();
}
}
// 3. 记录消息(同一事务)
Message msg = new Message();
msg.setTopic("order_created");
msg.setPayload(order.toJson());
msg.setStatus("PENDING");
messageMapper.insert(msg);
// 4. 返回结果(不等待后续处理)
return OrderResult.success(order.getId());
}支付回调:
public void onPaymentPaid(PaymentEvent event) {
// 1. 幂等检查
if (paymentRecord.exists(event.paymentId)) {
return;
}
// 2. 更新订单状态
int rows = orderMapper.updateStatus(
event.orderId,
"CREATED", // 期望状态
"PAID" // 目标状态
);
if (rows == 0) {
log.warn("订单状态异常:{}", event.orderId);
return;
}
// 3. 记录支付
paymentRecord.insert(event.paymentId, event.orderId);
// 4. 发送后续消息
messageMapper.insert("order_paid", event.orderId);
}消息处理:
@MessageListener("order_paid")
public void onOrderPaid(String orderId) {
// 1. 查询订单
Order order = orderMapper.select(orderId);
if (order == null) {
log.error("订单不存在:{}", orderId);
return;
}
// 2. 扣款(支付服务已处理,这里是内部记账)
accountService.deduct(order.getUserId(), order.getAmount());
// 3. 增加积分
pointService.add(order.getUserId(), calculatePoints(order));
// 4. 发送通知
notificationService.send(order.getUserId(), "支付成功");
// 5. 确认库存(预占转实占)
stockService.confirm(order.getItems());
}踩坑记录
坑一:库存超卖
问题:
- 并发下单时,库存扣减出现负数
- 原因:查询 + 扣减不是原子操作
解决:
- 用 SQL 条件扣减:UPDATE stock SET qty = qty - 1 WHERE sku_id = ? AND qty > 0
- 检查返回值,0 表示库存不足坑二:消息丢失
问题:
- 消息发送成功,但 MQ 宕机,消息丢失
- 原因:先更新消息状态,后发送消息
解决:
- 先发送消息,成功后再更新状态
- 或者:消息表 + MQ 事务消息(RocketMQ)坑三:重复发货
问题:
- 网络抖动,发货消息被消费两次
- 原因:发货接口没有幂等
解决:
- 发货表增加唯一键:order_id
- 发货前检查是否已发货总结与思考
核心原则
1. 能本地,不分布式
分布式事务是不得已的选择。能合并服务就合并,能同一数据库就同一数据库。
2. 能最终,不强求
强一致代价高昂。99% 的场景,最终一致足够。
3. 设计重于实现
好的设计让实现简单。先想清楚业务边界,再选技术方案。
4. 监控重于修复
问题总会发生。完善的监控和告警,比事后修复更重要。
演进趋势
过去:追求强一致,2PC 盛行 现在:接受最终一致,Saga/本地消息表主流 未来:
- Serverless 化:事务逻辑托管给云平台
- 事件驱动:Event Sourcing + CQRS 成为标配
- AI 辅助:自动检测不一致,智能修复
最后的建议
分布式事务没有银弹。每个方案都是取舍:
- 2PC 取舍:一致性 vs 性能
- TCC 取舍:控制力 vs 复杂度
- Saga 取舍:灵活性 vs 可追踪性
- 本地消息表:简单性 vs 实时性
选方案前,先问自己:
- 业务能接受多久的不一致?
- 团队能承担多少复杂度?
- 系统能容忍多少性能损失?
- 出问题时,有多少人工兜底能力?
答案清楚了,方案自然就明确了。
本文基于作者 10 年分布式系统实战经验总结,案例均来自生产环境。欢迎交流讨论。