Skip to content

分布式事务选型与排查

分布式事务最重要的不是记住很多方案,而是能根据业务场景选对方案,并知道线上出问题时怎么查。

选型第一问:能不能避免分布式事务

很多分布式事务问题,其实可以通过建模避免。

做法说明
合并服务边界强一致的数据不要过早拆到不同服务
同库事务能放一个数据库事务就不要跨库
异步化非核心动作积分、通知、ES 同步不要放主链路
状态机用状态流转减少“回滚”需求
预生成流水用唯一业务流水保证幂等

如果订单和订单明细强绑定,拆成两个服务反而会制造不必要的一致性问题。

选型流程

mermaid
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 事务消息、本地消息表、回调重试最终一致可接受,但要对账
订单完成加积分本地消息表可延迟,消费幂等即可
商品同步 ESMQ + 定时补偿ES 是搜索视图,可重建
订单履约流程Saga长流程,可补偿
发短信通知最大努力通知失败可重试,不需要强事务
秒杀扣库存数据库条件更新、Redis 预扣、TCC 谨慎高并发热点,不适合重型全局事务

选型背后的原理:先看事实源和可逆性

选分布式事务方案不要先问“用不用 Seata”,而要先问四个问题:

问题为什么重要例子
谁是事实源出现不一致时必须知道以谁为准ES 以 MySQL 为准,缓存以 DB 为准
操作能不能撤销不能撤销就不能随便 Saga 补偿已经给用户打款就不能简单反向删除记录
资源能不能预留能预留才适合 TCC库存冻结、余额冻结、优惠券锁定
用户能否接受延迟能接受延迟才适合最终一致积分、通知、搜索索引

为什么强一致贵

强一致方案要让多个参与方在同一时刻对“提交还是回滚”达成共识。只要有一个参与方慢、挂、网络抖动,其他参与方就可能等待。

mermaid
flowchart TD
    A["全局事务开始"] --> B["参与方 A 锁资源"]
    A --> C["参与方 B 锁资源"]
    B --> D["等待统一决议"]
    C --> D
    D --> E{"协调者是否正常"}
    E -- "正常" --> F["统一提交或回滚"]
    E -- "异常" --> G["资源可能长时间占用"]

这就是为什么 XA/2PC 不适合把订单、库存、支付、物流、通知、搜索同步全部包成一个巨大事务。事务越长,锁持有越久,失败面越大,可用性越差。

为什么最终一致常用

最终一致方案把“主链路成功”和“后置动作成功”拆开。主链路先把事实源写正确,后置动作通过消息、重试、补偿慢慢追平。

mermaid
flowchart TD
    A["订单本地事务"] --> B["订单状态写入 MySQL"]
    B --> C["写本地消息表"]
    C --> D["事务提交"]
    D --> E["投递订单事件"]
    E --> F["库存、积分、通知、ES 各自消费"]
    F --> G["失败重试与补偿"]

它牺牲的是实时强一致,换来的是更短的主事务、更高吞吐、更容易隔离故障。适合业务上允许短暂延迟的场景。

方案成本排序

从简单到复杂大致是:

  1. 本地事务。
  2. 本地事务 + MQ 最终一致。
  3. 本地消息表 / Outbox。
  4. MQ 事务消息。
  5. Saga。
  6. TCC。
  7. XA/JTA/2PC。

这不是绝对性能排序,而是工程复杂度和治理成本的粗略排序。越靠后越要谨慎使用。

各方案失败点对比

方案最怕的问题为什么会发生必须配套
XA/JTA/2PCprepare 后协调者故障、资源锁长时间占用参与方已经准备好但不知道最终提交还是回滚事务恢复日志、短事务、锁监控
TCC空回滚、悬挂、重复 Confirm/CancelTry 超时但 Cancel 先到,或网络重试导致重复调用分支事务状态表、幂等、防悬挂
Saga补偿失败或补偿不可逆每一步已经本地提交,只能执行反向业务动作Saga 实例表、步骤状态、人工兜底
本地消息表消息长期投递失败业务已提交,消息还没送到 MQ 或下游扫描任务、重试次数、异常表、告警
MQ 事务消息回查逻辑错误、消费者失败MQ 只保证本地事务和消息发送关系,不保证消费成功本地事务状态查询、消费幂等、死信
Seata AT全局锁冲突、undo_log 膨胀、SQL 不兼容框架要记录前后镜像并控制全局写冲突短事务、SQL 规范、undo_log 清理
最大努力通知对方长时间不可达通知类天然无法强制对方成功重试、主动查询、对账、人工处理

商业场景拆解:支付成功改订单并同步 ES

这个场景很常见:第三方支付回调成功后,本地订单要变成已支付,还要发通知、加积分、同步 ES。

不推荐做法

java
@Transactional
public void payCallback(PayCallback callback) {
    orderRepository.markPaid(callback.orderNo());
    pointClient.addPoint(callback.userId(), callback.amount());
    esClient.updateOrder(callback.orderNo());
    smsClient.sendPaidMessage(callback.phone());
}

问题:

  1. @Transactional 只能管理本地数据库,管不了远程积分、ES、短信。
  2. ES 或短信慢会拖住支付回调主链路。
  3. 远程调用成功后本地事务回滚,会产生更复杂的不一致。
  4. 支付渠道可能重复回调,没有幂等会重复处理。

推荐做法

mermaid
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:

sql
update t_order
set status = 'PAID',
    paid_at = now(),
    pay_no = ?
where order_no = ?
  and status = 'WAIT_PAY';

更新行数为 1,说明这次回调真正推动订单从待支付到已支付,需要写事件;更新行数为 0,说明订单可能已经处理过,要查询订单和支付流水后幂等返回成功。

商业场景拆解:库存扣减怎么选

库存场景要区分“展示库存”和“最终扣减”。

阶段推荐策略原因
商品详情展示库存缓存或 ES,可短暂不准展示不承担最终承诺
提交订单锁库存条件更新或 TCC 冻结防止超卖
支付超时释放库存状态机 + 延迟任务 + 幂等释放防止库存永久冻结
支付成功确认库存Confirm 或本地状态确认把冻结转为已售

条件更新示例:

sql
update t_sku_stock
set available = available - ?,
    frozen = frozen + ?
where sku_id = ?
  and available >= ?;

这条 SQL 自带并发控制:库存不足时更新行数为 0,不会扣成负数。高并发库存不能只靠“先查库存再扣库存”,因为查询和扣减之间会被其他请求插入。

3PC 为什么很少落地

3PC 在 2PC 基础上增加 CanCommit、PreCommit、DoCommit,试图降低阻塞。

mermaid
flowchart TD
    A["CanCommit<br/>询问能否提交"] --> B["PreCommit<br/>预提交"]
    B --> C["DoCommit<br/>真正提交"]

但 3PC 仍然无法彻底解决网络分区下的不确定性,而且实现复杂、生态支持少。商业 Java 系统里更常见的是 XA/JTA、TCC、Saga、本地消息表、事务消息和 Seata。面试知道 3PC 的目的和为什么少用即可,不需要把它当主流落地方案。

线上问题排查总流程

mermaid
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:统一幂等模板

java
@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;
    }
}

唯一索引:

sql
create unique index uk_idempotent_biz
on t_idempotent_record (biz_type, biz_key);

这个模板可以用于 MQ 消费、支付回调、补偿任务等场景。

什么时候不要上分布式事务框架

  1. 只是发短信、发邮件。
  2. 只是同步搜索索引。
  3. 只是加积分或发通知。
  4. 本来可以通过本地事务解决。
  5. 业务边界拆错了,强一致数据被拆散。
  6. 团队还没有幂等、补偿、对账、告警能力。

框架不能替代业务设计。没有状态机和幂等,上 Seata 也会出问题。

本章小结

分布式事务选型要从业务一致性出发,而不是从框架出发。能本地事务就本地事务;能最终一致就不要强一致;能消息解耦就不要拉长同步链路;必须强一致时,再考虑 XA 或 TCC。

线上排查时先找事实来源,再查状态机、事务日志、消息日志、补偿任务和对账结果。最终要把问题沉淀成幂等、重试、补偿、告警和设计约束。