分布式事务与最终一致性:从 2PC 到 Saga 的取舍
分布式事务与最终一致性:从 2PC 到 Saga 的取舍
前言
单体应用里一个 @Transactional 就能解决的事,拆成微服务后必须自己实现。动手前先想清楚一句话:绝大多数业务需要的不是强一致,而是"不丢、不重、可对账"。
一、先确认:真的需要强一致吗
| 场景 | 一致性要求 | 合适方案 |
|---|---|---|
| 账户扣款与加款 | 强一致 | 单库事务 / TCC / 2PC |
| 下单扣库存 | 最终一致 | 本地消息表 + 补偿 |
| 下单发积分、发券 | 最终一致 | 事务消息 / 消息表 |
| 跨系统对账 | 最终一致 | 异步核对 + 差异修复 |
判断标准:如果中间态(钱扣了、券没发)被人看到会不会引发资损或投诉。会,才付昂贵的强一致成本。
二、2PC / XA:为什么生产上很少用
两阶段提交:协调者先让所有参与者 prepare(锁住资源、不提交),全部成功后发 commit,任一失败则 rollback。
XA START 'tx1';
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'tx1';
XA PREPARE 'tx1'; -- 此处行锁与连接一直被占住
XA COMMIT 'tx1';
问题很硬:
- 同步阻塞:prepare 到 commit 之间,资源全锁,并发度极差;
- 协调者单点:协调者挂掉,参与者只能一直等;
- 长事务:数据库连接被长期占用,连接池很快打满;
- 性能差:MySQL 的 XA 相比本地事务有数量级损耗。
结论:只在低频、低并发、必须原子的内部账务场景考虑,别拿它当通用方案。
三、TCC:把锁从数据库挪到业务
Try(预留资源)→ Confirm(确认)→ Cancel(释放)。以扣库存为例:
try {
// Try:冻结 1 件,可用库存 -1,冻结库存 +1;用唯一约束防重复
inventoryService.freeze(orderId, skuId, 1);
// Confirm:冻结 → 已扣
inventoryService.confirm(orderId, skuId, 1);
} catch (Exception e) {
// Cancel:解冻
inventoryService.unfreeze(orderId, skuId, 1);
}
三个必修的坑:
- 空回滚:Try 没执行,Cancel 却先到了。必须记录事务状态,发现"从未 Try"直接返回成功;
- 悬挂:Cancel 先于 Try 到达。Try 执行前要先查状态,已 Cancel 则拒绝执行;
- 幂等:Confirm / Cancel 都可能被重复调用,靠事务控制表 + 唯一索引兜底。
TCC 的代价是每个参与方都要改三套接口,侵入性最强,通常只在资金类核心链路使用。
四、本地消息表:性价比最高的方案
思路:业务写入和消息写入放在同一个本地事务里,之后异步投递,失败重试。
CREATE TABLE outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0 待投递 1 已投递 2 失败超限
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_id)
);
BEGIN;
INSERT INTO orders (id, user_id, amount) VALUES (1001, 7, 99);
INSERT INTO outbox (biz_id, payload, next_retry_at)
VALUES ('order-1001', '{"type":"order.created","id":1001}', NOW());
COMMIT;
后台 worker 扫 status = 0 AND next_retry_at <= NOW() 投递 MQ,成功置 1,失败按退避时间递增重试(retry_count 超限转人工)。
优点:不引入新中间件、可靠性一目了然;缺点:有秒级延迟、消息表需要定期归档。语义是"至少一次",所以消费方必须幂等。
五、事务消息:托管版本地消息表
RocketMQ 的流程:先发 half message(对消费者不可见)→ 执行本地事务 → 提交 commit/rollback → 若没回执,Broker 定期回查生产者本地事务状态。
本质与本地消息表相同,只是把"存消息 + 重试"托管给了消息队列。注意 Kafka 没有这个能力,需要自己实现 outbox 模式。
六、Saga:面向长流程
把一个长事务拆成 T1…Tn,每个 Ti 配一个补偿 Ci,失败时逆序执行补偿。
| 维度 | 编排式(Orchestration) | 协同式(Choreography) |
|---|---|---|
| 控制中心 | 有,集中调度 | 无,靠事件驱动 |
| 可观测性 | 好,流程一目了然 | 差,链路散落各处 |
| 耦合度 | 编排器知道所有步骤 | 服务之间只认事件 |
| 适用 | 步骤多、需人工介入 | 步骤少、事件流清晰 |
示例:下单 → 扣库存 → 支付 → 发券。发券失败则退支付、恢复库存、关闭订单。
Saga 没有隔离性,中间态对外可见,所以业务上要设计"未完成订单"这类状态,并保证补偿幂等、可重试、有人工兜底入口——补偿失败不能静默丢弃。
七、兜底三件套
任何最终一致性方案,缺了这三样都会出事:
- 幂等:唯一键去重表 / 状态机只允许单向流转 / 消息 ID 去重;
- 重试:指数退避 + 最大次数 + 死信队列,禁止无脑立即重试;
- 对账:T+1 定时核对两边的流水与总额,差异自动生成修复工单。
总结
选型对照:
| 方案 | 一致性 | 侵入性 | 性能 | 适用 |
|---|---|---|---|---|
| 2PC / XA | 强 | 低(数据库层) | 差 | 低频强一致内部账务 |
| TCC | 强(最终) | 高(三套接口) | 好 | 资金、库存核心链路 |
| 本地消息表 | 最终 | 中 | 好 | 跨服务事件通知,性价比最高 |
| 事务消息 | 最终 | 中 | 好 | 已用 RocketMQ 的团队 |
| Saga | 最终 | 中高 | 好 | 长流程、多步骤业务编排 |
落地顺序推荐:先上本地消息表解决 80% 场景 → 核心资金链路再考虑 TCC → 长流程用 Saga 编排。记住分布式事务的第一原则:能靠单库事务解决的,就不要跨服务。
- 点赞
- 收藏
- 关注作者
评论(0)