分布式事务选型与排查
分布式事务最重要的不是记住很多方案,而是能根据业务场景选对方案,并知道线上出问题时怎么查。
选型第一问:能不能避免分布式事务
很多分布式事务问题,其实可以通过建模避免。
| 做法 | 说明 |
|---|---|
| 合并服务边界 | 强一致的数据不要过早拆到不同服务 |
| 同库事务 | 能放一个数据库事务就不要跨库 |
| 异步化非核心动作 | 积分、通知、ES 同步不要放主链路 |
| 状态机 | 用状态流转减少“回滚”需求 |
| 预生成流水 | 用唯一业务流水保证幂等 |
如果订单和订单明细强绑定,拆成两个服务反而会制造不必要的一致性问题。
选型流程
flowchart TD
A["跨服务一致性问题"] --> B{"是否必须立即强一致?"}
B -- "否" --> C["可靠消息 / 本地消息表 / 事务消息"]
B -- "是" --> D{"流程是否很短,资源是否支持 XA?"}
D -- "是" --> E["XA / JTA / Seata XA"]
D -- "否" --> F{"业务资源是否可冻结?"}
F -- "是" --> G["TCC / Seata TCC"]
F -- "否" --> H{"是否长流程且可补偿?"}
H -- "是" --> I["Saga / Seata Saga"]
H -- "否" --> J["重新设计业务边界或人工审核"]典型场景选择
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 支付记账跨库 | XA/JTA 或严格本地化账务边界 | 强一致要求高 |
| 下单锁库存 | TCC 或本地消息表 | 库存可冻结,也可最终一致 |
| 支付成功改订单 | MQ 事务消息、本地消息表、回调重试 | 最终一致可接受,但要对账 |
| 订单完成加积分 | 本地消息表 | 可延迟,消费幂等即可 |
| 商品同步 ES | MQ + 定时补偿 | ES 是搜索视图,可重建 |
| 订单履约流程 | Saga | 长流程,可补偿 |
| 发短信通知 | 最大努力通知 | 失败可重试,不需要强事务 |
| 秒杀扣库存 | 数据库条件更新、Redis 预扣、TCC 谨慎 | 高并发热点,不适合重型全局事务 |
选型背后的原理:先看事实源和可逆性
选分布式事务方案不要先问“用不用 Seata”,而要先问四个问题:
| 问题 | 为什么重要 | 例子 |
|---|---|---|
| 谁是事实源 | 出现不一致时必须知道以谁为准 | ES 以 MySQL 为准,缓存以 DB 为准 |
| 操作能不能撤销 | 不能撤销就不能随便 Saga 补偿 | 已经给用户打款就不能简单反向删除记录 |
| 资源能不能预留 | 能预留才适合 TCC | 库存冻结、余额冻结、优惠券锁定 |
| 用户能否接受延迟 | 能接受延迟才适合最终一致 | 积分、通知、搜索索引 |
为什么强一致贵
强一致方案要让多个参与方在同一时刻对“提交还是回滚”达成共识。只要有一个参与方慢、挂、网络抖动,其他参与方就可能等待。
flowchart TD
A["全局事务开始"] --> B["参与方 A 锁资源"]
A --> C["参与方 B 锁资源"]
B --> D["等待统一决议"]
C --> D
D --> E{"协调者是否正常"}
E -- "正常" --> F["统一提交或回滚"]
E -- "异常" --> G["资源可能长时间占用"]这就是为什么 XA/2PC 不适合把订单、库存、支付、物流、通知、搜索同步全部包成一个巨大事务。事务越长,锁持有越久,失败面越大,可用性越差。
为什么最终一致常用
最终一致方案把“主链路成功”和“后置动作成功”拆开。主链路先把事实源写正确,后置动作通过消息、重试、补偿慢慢追平。
flowchart TD
A["订单本地事务"] --> B["订单状态写入 MySQL"]
B --> C["写本地消息表"]
C --> D["事务提交"]
D --> E["投递订单事件"]
E --> F["库存、积分、通知、ES 各自消费"]
F --> G["失败重试与补偿"]它牺牲的是实时强一致,换来的是更短的主事务、更高吞吐、更容易隔离故障。适合业务上允许短暂延迟的场景。
方案成本排序
从简单到复杂大致是:
- 本地事务。
- 本地事务 + MQ 最终一致。
- 本地消息表 / Outbox。
- MQ 事务消息。
- Saga。
- TCC。
- XA/JTA/2PC。
这不是绝对性能排序,而是工程复杂度和治理成本的粗略排序。越靠后越要谨慎使用。
各方案失败点对比
| 方案 | 最怕的问题 | 为什么会发生 | 必须配套 |
|---|---|---|---|
| XA/JTA/2PC | prepare 后协调者故障、资源锁长时间占用 | 参与方已经准备好但不知道最终提交还是回滚 | 事务恢复日志、短事务、锁监控 |
| TCC | 空回滚、悬挂、重复 Confirm/Cancel | Try 超时但 Cancel 先到,或网络重试导致重复调用 | 分支事务状态表、幂等、防悬挂 |
| Saga | 补偿失败或补偿不可逆 | 每一步已经本地提交,只能执行反向业务动作 | Saga 实例表、步骤状态、人工兜底 |
| 本地消息表 | 消息长期投递失败 | 业务已提交,消息还没送到 MQ 或下游 | 扫描任务、重试次数、异常表、告警 |
| MQ 事务消息 | 回查逻辑错误、消费者失败 | MQ 只保证本地事务和消息发送关系,不保证消费成功 | 本地事务状态查询、消费幂等、死信 |
| Seata AT | 全局锁冲突、undo_log 膨胀、SQL 不兼容 | 框架要记录前后镜像并控制全局写冲突 | 短事务、SQL 规范、undo_log 清理 |
| 最大努力通知 | 对方长时间不可达 | 通知类天然无法强制对方成功 | 重试、主动查询、对账、人工处理 |
商业场景拆解:支付成功改订单并同步 ES
这个场景很常见:第三方支付回调成功后,本地订单要变成已支付,还要发通知、加积分、同步 ES。
不推荐做法
@Transactional
public void payCallback(PayCallback callback) {
orderRepository.markPaid(callback.orderNo());
pointClient.addPoint(callback.userId(), callback.amount());
esClient.updateOrder(callback.orderNo());
smsClient.sendPaidMessage(callback.phone());
}问题:
@Transactional只能管理本地数据库,管不了远程积分、ES、短信。- ES 或短信慢会拖住支付回调主链路。
- 远程调用成功后本地事务回滚,会产生更复杂的不一致。
- 支付渠道可能重复回调,没有幂等会重复处理。
推荐做法
flowchart TD
A["支付回调"] --> B["校验签名和金额"]
B --> C["按 orderNo 幂等更新订单为 PAID"]
C --> D["同事务写支付事件 outbox"]
D --> E["提交本地事务"]
E --> F["异步投递 MQ"]
F --> G["积分服务幂等消费"]
F --> H["通知服务幂等消费"]
F --> I["ES 同步服务幂等消费"]
I --> J["失败重试 / 死信 / 重建索引"]核心 SQL:
update t_order
set status = 'PAID',
paid_at = now(),
pay_no = ?
where order_no = ?
and status = 'WAIT_PAY';更新行数为 1,说明这次回调真正推动订单从待支付到已支付,需要写事件;更新行数为 0,说明订单可能已经处理过,要查询订单和支付流水后幂等返回成功。
商业场景拆解:库存扣减怎么选
库存场景要区分“展示库存”和“最终扣减”。
| 阶段 | 推荐策略 | 原因 |
|---|---|---|
| 商品详情展示库存 | 缓存或 ES,可短暂不准 | 展示不承担最终承诺 |
| 提交订单锁库存 | 条件更新或 TCC 冻结 | 防止超卖 |
| 支付超时释放库存 | 状态机 + 延迟任务 + 幂等释放 | 防止库存永久冻结 |
| 支付成功确认库存 | Confirm 或本地状态确认 | 把冻结转为已售 |
条件更新示例:
update t_sku_stock
set available = available - ?,
frozen = frozen + ?
where sku_id = ?
and available >= ?;这条 SQL 自带并发控制:库存不足时更新行数为 0,不会扣成负数。高并发库存不能只靠“先查库存再扣库存”,因为查询和扣减之间会被其他请求插入。
3PC 为什么很少落地
3PC 在 2PC 基础上增加 CanCommit、PreCommit、DoCommit,试图降低阻塞。
flowchart TD
A["CanCommit<br/>询问能否提交"] --> B["PreCommit<br/>预提交"]
B --> C["DoCommit<br/>真正提交"]但 3PC 仍然无法彻底解决网络分区下的不确定性,而且实现复杂、生态支持少。商业 Java 系统里更常见的是 XA/JTA、TCC、Saga、本地消息表、事务消息和 Seata。面试知道 3PC 的目的和为什么少用即可,不需要把它当主流落地方案。
线上问题排查总流程
flowchart TD
A["发现数据不一致"] --> B["确定业务主数据源"]
B --> C["查业务状态机"]
C --> D["查事务或消息日志"]
D --> E["查是否重复请求或重复消费"]
E --> F["查补偿任务和死信队列"]
F --> G["查下游服务日志和 traceId"]
G --> H["修复数据并补齐防线"]排查时先确认“谁是事实来源”。例如商品搜索不一致,MySQL 是事实来源,ES 可以重建;支付金额不一致,支付流水和渠道账单都要对账确认,不能随便改。
常见问题和处理
| 现象 | 可能原因 | 排查 | 处理 |
|---|---|---|---|
| 订单已支付但状态待支付 | 支付回调失败、消息消费失败 | 查支付流水、回调日志、MQ、订单状态 | 重放消息或执行补偿 |
| 库存被冻结不释放 | TCC Cancel 失败、超时任务缺失 | 查冻结流水、TCC 分支状态 | 重试 Cancel 或人工释放 |
| 重复扣款 | 幂等号缺失、回调重复处理 | 查支付流水唯一键 | 增加唯一约束和状态机 |
| ES 数据旧 | MQ 消费失败、同步任务失败 | 查同步记录、ES 文档更新时间 | 补偿同步或重建索引 |
| XA 事务卡住 | 协调者异常、资源锁等待 | 查事务管理器日志和 DB 锁 | 恢复事务、缩小事务范围 |
| Saga 补偿失败 | 补偿动作不可逆或下游失败 | 查 Saga 步骤表 | 重试、人工处理、重新设计补偿 |
设计检查表
上线分布式事务前,至少检查这些问题:
| 检查项 | 说明 |
|---|---|
| 一致性级别明确 | 强一致、最终一致还是尽力而为 |
| 主数据源明确 | 出问题时以哪个系统为准 |
| 状态机明确 | 每个状态能流向哪里 |
| 幂等设计明确 | 重复请求、重复消息、重复补偿都安全 |
| 超时设置明确 | 每个远程调用都有超时 |
| 重试策略明确 | 重试次数、间隔、最大上限 |
| 补偿机制明确 | 已知失败如何修复 |
| 对账机制明确 | 未知失败如何发现 |
| 告警明确 | 谁负责处理长期失败 |
| 人工兜底明确 | 自动修复不了时怎么处理 |
代码 Demo:统一幂等模板
@Transactional
public void executeWithIdempotent(String bizType, String bizKey, Runnable action) {
boolean inserted = idempotentRepository.tryInsert(bizType, bizKey);
if (!inserted) {
return;
}
try {
action.run();
idempotentRepository.markSuccess(bizType, bizKey);
} catch (Exception ex) {
idempotentRepository.markFailed(bizType, bizKey, ex.getMessage());
throw ex;
}
}唯一索引:
create unique index uk_idempotent_biz
on t_idempotent_record (biz_type, biz_key);这个模板可以用于 MQ 消费、支付回调、补偿任务等场景。
什么时候不要上分布式事务框架
- 只是发短信、发邮件。
- 只是同步搜索索引。
- 只是加积分或发通知。
- 本来可以通过本地事务解决。
- 业务边界拆错了,强一致数据被拆散。
- 团队还没有幂等、补偿、对账、告警能力。
框架不能替代业务设计。没有状态机和幂等,上 Seata 也会出问题。
本章小结
分布式事务选型要从业务一致性出发,而不是从框架出发。能本地事务就本地事务;能最终一致就不要强一致;能消息解耦就不要拉长同步链路;必须强一致时,再考虑 XA 或 TCC。
线上排查时先找事实来源,再查状态机、事务日志、消息日志、补偿任务和对账结果。最终要把问题沉淀成幂等、重试、补偿、告警和设计约束。
