外观
分布式事务选型框架:Saga、TCC、本地消息表的适用边界一张表说清
「订单创建了,库存没扣」「账户扣了钱,权益没发货」——跨服务数据不一致是微服务化之后绕不开的问题。分布式事务的选型之所以困难,是因为每个方案都像在讲道理:强一致的 2PC 性能差,性能好的 Saga 会出中间态,无侵入的本地消息表只保证最终一致。选错方向的代价往往要到长尾故障时才显现,比如用 TCC 的团队在流量高峰被 Confirm 阶段的重复请求打爆,用纯 Saga 的团队在补偿逻辑里写出新的 bug。这篇文章不给标准答案,而是给一个可复用的选型框架:先问业务要什么一致性,再看团队能付出多少改造与运维成本,最后在方案矩阵里对号入座。
五类方案的横向对比
先看全景矩阵,再逐个展开:
| 方案 | 一致性 | 侵入性 | 性能 | 典型场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低(依赖DB) | 差 | 传统数据库间短事务 |
| TCC | 准强一致 | 高(三接口) | 中 | 资金扣减等敏感操作 |
| Saga | 最终一致 | 中(补偿) | 好 | 长流程跨服务编排 |
| 本地消息表 | 最终一致 | 低 | 好 | 异步解耦的主流选择 |
| 事务消息 | 最终一致 | 低 | 好 | 与 RocketMQ 深度绑定 |
一个常被忽略的前提:分布式事务的第一选项永远是「不要分布式事务」。能通过服务边界重划让事务回到单库,就不要引入任何框架——拆服务前先想清楚事务边界,比选型任何方案都省事。以下讨论默认跨库跨服务已不可避免。
TCC:资金场景的准强一致,三件套必须配齐
TCC 把一个事务拆成 Try(资源预留)、Confirm(确认提交)、Cancel(取消释放)三个阶段。它最强的能力是「预留」:Try 阶段冻结金额而不是直接扣减,使得中间态对业务可见且可审计。但工程上必须处理三个经典异常,缺一个就是资损事故:
java
// TCC 接口的三个经典异常防护:空回滚、悬挂、幂等
@TwoPhaseBusinessAction(name = "deductBalance", commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryDeduct(BusinessActionContext ctx, String accountId, BigDecimal amount);
boolean confirm(BusinessActionContext ctx); // 必须幂等:可能被重复调用
boolean cancel(BusinessActionContext ctx) {
// 防空回滚:Try 未执行(如超时未到达)时,Cancel 不能释放未冻结的资源
if (!frozenRecordExists(ctx)) {
insertEmptyRollbackRecord(ctx); // 记录空回滚,防止迟到的 Try 再执行(防悬挂)
return true;
}
// 防 Cancel 重复执行:按事务 ID 条件更新,重复调用幂等返回
return unfreezeByTxId(ctx.getXid()) >= 0;
}空回滚、悬挂、幂等这三件套是 TCC 的及格线:空回滚是 Cancel 先于 Try 到达(Try 所在节点超时),悬挂是空回滚记录后迟到的 Try 再执行、资源被永久冻结,幂等是 Confirm/Cancel 都可能被框架重复调用。三者都用「事务日志表 + 条件更新」解决。TCC 的代价也在这里:每个参与方要写三个接口和一张日志表,改造成本与参与服务数成正比,只值得用在资金敏感、并发可控的核心操作上。
Saga:长流程的最终一致,补偿设计是灵魂
Saga 把长事务拆成一串本地事务,每步失败则反向执行已完成步骤的补偿。它天然适合流程长、参与方多的场景(下单 → 扣库存 → 优惠核销 → 通知履约)。编排方式分协同式(服务间通过事件驱动)与编排式(中央协调器驱动,如 Seata Saga 模式、Temporal),生产上推荐编排式——流程可视、可控、可运维。
Saga 的设计重心全在补偿逻辑上:
java
// 补偿的三个设计约束
// 1. 补偿必须幂等:协调器重试会导致补偿多次执行
// 2. 补偿必须可空跑:对应 TCC 空回滚,前置步骤未生效时补偿不报错
// 3. 不保证隔离性:中间态对外可见,业务要接受「已扣库存但订单失败后回补」的窗口第三点是 Saga 与 TCC 的本质差异:Saga 没有预留阶段,库存是「先扣后退」,中间态可能被其他事务读到。如果业务不能接受超卖窗口(比如秒杀库存),Saga 不合适;如果是履约流程这类「先走通、错了再撤」的场景,Saga 的低侵入和高吞吐优势明显。
本地消息表:大多数系统的最优解
对于「本服务改库 + 通知下游」的异步一致性场景,本地消息表是工程性价比之王——不依赖特定中间件,只依赖本地事务的原子性:
sql
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) NOT NULL COMMENT '业务键,用于下游幂等',
topic VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待投递 1已投递 2死信',
retry_count INT NOT NULL DEFAULT 0,
next_retry_time DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_id, topic)
) COMMENT '本地消息表:与业务同库,同事务写入';java
@Transactional // 业务写入与消息登记在同一本地事务,要么都成功要么都失败
public void createOrder(Order order) {
orderMapper.insert(order);
messageMapper.insert(LocalMessage.of(order.getId(), "order.created", toJson(order)));
}
// 独立投递任务:扫描待投递消息,发送成功才标记,失败按退避策略重试
@Scheduled(fixedDelay = 500)
public void deliverPendingMessages() {
List<LocalMessage> pending = messageMapper.scanPending(100);
for (LocalMessage msg : pending) {
boolean sent = mqProducer.send(msg.getTopic(), msg.getPayload());
messageMapper.markDeliveredOrRetry(msg.getId(), sent); // 重试超限转死信人工介入
}
}下游消费者必须按 biz_id 幂等(见本板块幂等设计篇),这套「本地消息表 + 消费端幂等」组合覆盖了 80% 的跨服务一致性需求。RocketMQ 的事务消息把「消息表 + 投递任务」下沉到中间件(半消息 + 回查机制),省掉建表运维,代价是与 MQ 强绑定且回查逻辑仍需自己实现,两者选其一即可。
选型推演与避坑清单
以「下单扣库存」推演一遍框架:资金敏感吗?订单金额结算敏感,但库存扣减本身可用「扣减 + 回补」表达;并发量高吗?秒杀场景高并发。结论:热点扣库存用原子减(UPDATE stock SET num = num - 1 WHERE num > 0)替代事务保护,订单创建走本地消息表通知下游,只有账户冻结类操作才上 TCC。大多数团队的根本问题是把 TCC 当默认选项,用高成本方案解决低复杂度问题。
还有一类场景值得单独划出:跨系统的最大努力通知(如支付结果通知商户)。它的可靠性要求最低——通知方按退避间隔重试若干次(如 15 秒、30 秒、1 分钟、5 分钟、30 分钟),超时后放弃投递并暴露查询接口让对端主动核实。把「通知」与「一致性」区分开能省掉大量过度设计:需要结果可信的走本地消息表,只需尽力而为的用退避通知即可。两者混为一谈的常见后果,是给通知场景错误套上了事务消息的复杂度,而对账场景却漏掉了消费端幂等。
最后把避坑清单收拢如下:
- 先看能否不引入分布式事务:重新划分服务边界,让事务回到单库是最高优先级选项。
- 资金预留类操作选 TCC,且空回滚/悬挂/幂等三件套作为代码评审必查项。
- 长流程多参与方选 Saga(编排式),补偿的三条约束写进设计文档再动手。
- 异步解耦类需求默认本地消息表或事务消息,消费端幂等是这套方案的前置条件。
- 任何方案都要有「最终不一致」的兜底:对账任务每日跑平,差异进人工处理队列——对账不是可选项,是分布式事务的最后防线。
分布式事务选型的本质是「用多少工程成本买多强的一致性」。把业务需求的真实强度问清楚,把每个方案的实施成本摆到桌面上,答案通常并不难找。记住:架构师的水平不在于会用 TCC,而在于知道什么时候不用它。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。