跳到正文

分布式事务 ​

一句话:分布式事务是在多个独立数据库之间模拟「一起成功或一起失败」,强一致方案用同步协调换确定性,最终一致方案用可见的中间状态换可用性。

适用:Seata 2.x、RocketMQ 5.x;MySQL 8.0+;协议细节以各项目官方文档为准。

为什么需要 ​

单体应用一个 @Transactional 就能覆盖订单、库存、账户三张表。拆成三个服务、三个数据库后,本地事务只保证自己那一份:订单创建成功、库存扣减失败时,已经写入的订单不会自动消失。

跨库操作不能靠「多试几次」解决,因为失败可能发生在提交之后、响应之前。系统必须能判断「这笔业务到底成没成」,并让未完成的部分可回滚或可补偿。

核心机制 ​

2PC:同步协调的代价 ​

协调者先让所有参与者 Prepare(锁资源、写 undo/redo、投票),全部同意后再 Commit,否则 Rollback。

问题不在协议本身,而在等待:Prepare 后资源一直被锁,协调者宕机时参与者不知道下一步,只能阻塞。第二阶段部分节点收到 Commit、部分没收到时,还会出现数据不一致。所以 2PC 适合少量参与方、短事务,不适合高并发核心链路。

TCC:把锁下沉到业务 ​

Try 预留资源,Confirm 确认,Cancel 释放。库存的 Try 是冻结而不是直接扣减,Confirm 才真正消耗冻结量。

TCC 必须处理两个时序问题:

  • 空回滚:Cancel 先于 Try 到达。Cancel 发现没有冻结记录,不能报错,而要记录「已取消」。
  • 悬挂:Cancel 已经执行,Try 才迟到。Try 看到「已取消」必须拒绝,不能重新冻结。

做法是一张事务状态表,Try/Cancel 都先按业务主键做原子插入或状态抢占;Confirm/Cancel 还要幂等,允许重复调用。

Saga:长流程的补偿 ​

Saga 把长事务拆成一串本地事务,每一步都有对应的补偿操作。编排(Orchestration)由一个协调器决定下一步,链路清晰;协同(Choreography)由事件驱动,服务间松耦合但链路难追。

Saga 没有隔离性:中间状态对其他事务可见。涉及资金、库存时,要用业务状态(待确认、已冻结)或语义锁挡住脏读。

本地消息表与事务消息 ​

本地消息表把业务写入和消息记录放在同一个本地事务里:

sql
BEGIN;
INSERT INTO orders(id, ...) VALUES (...);
INSERT INTO outbox(id, event_type, payload, status)
VALUES ('evt-1', 'ORDER_CREATED', '...', 'PENDING');
COMMIT;

后台任务扫描 status='PENDING' 投递 MQ,成功后改 SENT;消费端按业务主键幂等。消息表和业务表同库,才能保证「业务成功则消息一定留下」。

同一业务流程需要顺序时,可以让后一条消息携带前一条的 prev_message_id,消费端检查前序是否已处理;未处理就等待或重新拉取。代价是阻塞和复杂度,只用在订单状态流转这类必须有序的链路。

RocketMQ 事务消息把「本地事务 + 发消息」合成半消息流程:先发不可见的半消息,再执行本地事务,成功则 Commit,失败则 Rollback;Broker 收不到确认会回查生产者。它解决的是「本地事务与发消息的原子性」,不是两个数据库之间的事务。

Seata AT:本地提交 + 全局锁 ​

AT 的一阶段不是「只 Prepare 不提交」。它执行并提交本地事务,同时记录 undo_log(beforeImage/afterImage)。本地提交后,数据库行锁立即释放。

那靠什么防止别人改同一行?TC 会为被修改记录的主键加全局锁。其他全局事务要改同一行时拿不到全局锁,会重试或阻塞。这个全局锁在二阶段提交或回滚完成后尽快释放,不会像 2PC 那样一直锁到整个分布式事务结束。所以准确说法是「短期的全局锁 + 本地事务已提交」,不是「全程锁定」,也不是「完全不锁」。

二阶段提交:异步删除 undo_log,释放全局锁。二阶段回滚:先比对当前数据与 afterImage,确认没人脏写,再用 beforeImage 还原,最后删除 undo log。比对不一致说明有旁路写入,只能告警并人工处理。

实战场景 ​

一、TCC 的 Cancel 先到,Try 后到,冻结资源永久泄漏 ​

现象:营销发券用 TCC:Try 冻结库存、Confirm 消耗、Cancel 解冻。一次上游超时后发起补偿,日志里出现 cancel failed: no freeze record;随后原 Try 请求因为网络重传迟到执行,把券库存冻结了,而这次事务早已结束——没有任何后续会来解冻。三天内累计“冻结中”库存 1200 张无法售卖(经验值,需校准)。

排查:TCC 的两个时序问题是协议自带的,不处理就是资源泄漏。

定位层取证据判定
空回滚Cancel 日志早于 Try,且找不到冻结记录Cancel 先于 Try 到达,报错就是没做兼容
悬挂Try 执行时状态表里已有 CANCELED 记录Try 迟到且未拒绝,产生“无人认领的冻结”
状态表写入Try/Cancel 是否按业务主键做原子插入或状态抢占非原子即存在竞态窗口
幂等性重复 Confirm/Cancel 是否只生效一次重复消耗或重复解冻
收敛手段是否有“长期冻结”扫描与自动解冻没有就靠人工发现
sql
-- 事务状态表:Try 与 Cancel 都先抢这一行,靠唯一键把时序固定下来
CREATE TABLE tcc_transaction (
  xid          VARCHAR(64) NOT NULL,     -- 全局事务 ID(业务主键派生,可重入)
  branch       VARCHAR(32) NOT NULL,
  status       VARCHAR(16) NOT NULL,     -- INIT / TRIED / CONFIRMED / CANCELED
  created_at   DATETIME(3) NOT NULL,
  PRIMARY KEY (xid, branch)
);
java
// Try:插入状态行,插入失败说明 Cancel 已经先到 → 拒绝冻结(防悬挂)
public boolean tryDeduct(String xid, long couponId, int qty) {
    if (!txRepo.insertIfAbsent(xid, "stock", Status.TRIED)) {   // 唯一键冲突
        log.warn("try rejected, xid={} already terminated", xid);
        return false;
    }
    return stockRepo.freeze(couponId, qty) > 0;
}

// Cancel:没有 Try 记录也要落一行 CANCELED,并返回成功(防空回滚)
public void cancel(String xid, long couponId, int qty) {
    if (txRepo.insertIfAbsent(xid, "stock", Status.CANCELED)) {   // Try 还没来
        return;                                                   // 记录“已取消”,不报错
    }
    Status s = txRepo.status(xid, "stock");
    if (s == Status.CONFIRMED) return;                            // 已确认,交给业务补偿
    if (s == Status.TRIED) { stockRepo.unfreeze(couponId, qty); txRepo.mark(xid, "stock", Status.CANCELED); }
}
text
1. Confirm/Cancel 必须幂等:允许上游重复调用,重复执行结果一致。
2. 兜底扫描:status=TRIED 且创建时间超过阈值(例如 30 分钟)的冻结记录,
   自动走 Cancel 并告警,把“悬挂靠人工发现”变成“自动收敛”。
3. 冻结语义要在业务上可见:库存展示为“冻结中/可售”两栏,
   运营能看见异常冻结量,这是最快的发现渠道。

复盘要点:判据是“超过 30 分钟的 TRIED 冻结数量恒为 0”和“Cancel 返回失败次数为 0”——Cancel 报错意味着空回滚没做。TCC 的成本在业务侧(三套接口 + 幂等 + 状态表),不是换个注解就能上,所以它适合“资金/库存这类必须强预留”的少数接口,不要全链路铺开。

二、Saga 的中间态被读到,用户看到“已扣款未发货”后又变回未扣款 ​

现象:下单流程用 Saga 编排:扣款 → 锁库存 → 建履约单,每步都有补偿。一次“建履约单”失败触发回滚,用户在 8 秒内先后看到“支付成功、待发货”和“订单未支付”两种状态,客服工单一天 30 余单(经验值,需校准)。更糟的是补偿调用库存服务超时,订单停在“已扣款 + 库存已锁”的中间态两小时。

排查:Saga 没有隔离性,中间状态天然对外可见;补偿本身也可能失败。

定位层取证据判定
中间态是否暴露状态查询接口读的是哪张表、是否有“处理中”语义直接读各步骤结果就会暴露中间态
补偿是否失败Saga 状态表里 COMPENSATING 超时/失败的记录卡在中间态的直接原因
补偿是否幂等同一补偿重复执行是否安全不幂等则重试制造新问题
是否有超时上限每步补偿的最大重试次数与终态无限重试会让流程永不结束
人工出口重试耗尽后是否进人工队列没有出口就只能靠对账发现
语义锁是否用业务状态(待确认/已冻结)挡住脏读无标记则其他事务会基于中间态决策

对症做法:

java
// 1. 对外只暴露“聚合状态”,中间态统一显示为处理中,不逐步透出
public enum OrderViewStatus { PROCESSING, CONFIRMED, CANCELLED, NEED_REVIEW }

// 2. 用业务状态做语义锁:涉及资金/库存的中间态,其他事务不能据此推进
//    “已扣款未确认”期间禁止退款、禁止再次支付
UPDATE order_state SET flag='PAY_HELD' WHERE id=? AND flag IS NULL;

// 3. 补偿要有超时、有限重试与明确终态
for (int i = 0; i < 5 && !done; i++) {
    try { done = stockGateway.release(freezeKey); }         // 幂等键保证重复安全
    catch (TimeoutException e) { sleepWithJitter(base * (1L << i)); }
}
if (!done) { saga.mark(orderId, NEED_REVIEW); alert.p2("saga stuck at " + step, orderId); }
text
4. 编排而不是协同用于资金链路:
   编排(Orchestration)有协调器记录每一步,链路可查、可重放;
   协同(Choreography)靠事件驱动,松耦合但链路难追,出问题最难定位。
5. 定时器扫描非终态流程:
   任何 NOT IN (COMPLETED, COMPENSATED, FAILED_FINAL) 且更新时间超过 30 分钟的都进待处理队列。
6. 中间态要能被对账覆盖:
   “扣款流水 vs 订单状态 vs 库存冻结”三方核对,日级发现异常而不是等投诉。

复盘要点:判据是“非终态 Saga 的最长停留时间”有预算并接告警。Saga 换到的是可用性与跨系统可编排,付出的是隔离性与补偿复杂度——涉及资金时,要么用业务状态把中间态挡住,要么把这几步合进同一个本地事务,而不是接受“用户看到状态回跳”。与 AT/XA 的取舍见 Seata 与分布式事务。

三、核心链路用 XA/2PC,高峰吞吐掉到三成 ​

现象:为保证“订单 + 库存 + 账务”绝对一致,DBA 建议上 XA。上线后压测正常,晚高峰 TPS 从 3000 掉到 900,SHOW ENGINE INNODB STATUS 里大量事务在等行锁;一次网络抖动导致协调者重启,十几分钟后仍有事务卡在 PREPARED 状态。

排查:2PC 的问题不在协议本身,而在等待。

定位层取证据判定
锁持有时长information_schema.INNODB_TRX.trx_started 与事务总时长Prepare 到 Commit 之间资源一直被锁
阻塞关系data_lock_waits 的等待链长度一个 XA 事务卡住一批本地事务
参与者数量同一全局事务涉及的库/服务数越多则最慢者决定整体
协调者故障表现参与者中是否有长期 PREPARED 的记录协调者宕机,参与者不知下一步
第二阶段不一致部分节点收到 Commit、部分没有需要靠恢复日志与人工收敛
TPS 曲线与并发数关系(线性恶化还是台阶)锁竞争型衰减即命中

对症做法:

text
1. 重新划分边界,先消掉跨库事务:
   同一聚合内的写放进同一个本地事务(见 S02 的 DDD 战术设计),
   跨聚合改事件最终一致 + 对账。
2. 必须跨库时,按数据重要性分级:
   - 账务类:本地事务 + 本地消息表 + 幂等消费(可接受秒级延迟,不接受不一致);
   - 展示/统计类:异步投影,允许旧数据。
3. XA 保留给低并发、强一致的管理操作(结算关账、批量调整),
   不放核心交易链路。
4. 若坚持使用 XA,必须具备:
   协调者恢复日志、悬挂事务扫描与自动收敛、参与者超时上报,
   并且容量按“锁持有时间”而不是“网络往返时间”评估。
5. 容量口径换算:单事务锁持有时间 × 并发数决定吞吐上限;
   降低隔离级别或缩短事务,都比加机器更直接。

复盘要点:判据是“核心链路上不存在跨库 XA 事务”和“单事务锁持有时长 P99 小于 50ms”(经验值,需按业务校准)。选型顺序始终是:能不用分布式事务就不用 → 本地消息表 + 补偿 → AT → TCC/Saga。这次事故的成本不是协议选错,而是把“管理场景的正确做法”照搬进了高并发路径。

最小上手 ​

启动 Seata Server 2.x(单机,仅用于验证):

bash
docker run -d --name seata-server \
  -p 8091:8091 -p 7091:7091 \
  -e SEATA_IP=127.0.0.1 \
  seataio/seata-server:2.0.0

业务服务引入 seata-spring-boot-starter,配置 application.yml:

yaml
seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_tx_group
  registry:
    type: file
  config:
    type: file
  service:
    vgroup-mapping:
      my_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

业务方法加 @GlobalTransactional(rollbackFor = Exception.class),每个参与库建 undo_log 表。验证方式:让第二个服务的 SQL 故意报错,观察第一个服务的本地写入是否被回滚、undo_log 是否被清理。

生产动作 ​

  • TC 高可用:生产不要用单机 file 配置;注册中心用 Nacos,事务日志存数据库,TC 至少两节点。
  • undo_log:每个业务库都要有;client.undo.logTable=undo_log、client.undo.logSerialization=jackson(Seata 1.5+/2.x)。服务端 server.undo.logSaveDays=7、server.undo.logDeletePeriod=86400000,按保留要求调整。
  • 锁冲突:client.rm.lock.retryInterval=10(毫秒)、client.rm.lock.retryTimes=30(Seata 1.5+/2.x,以官方文档为准)。冲突持续升高说明热点行被多个全局事务争抢,应拆分业务或避开 AT。
  • 消息表:扫表频率、单批大小、最大重试次数都要有上限。项目经验值可参考每 5 秒扫一次、最多重试 5 次、消费去重记录保留 7 天,但要按自己的消息保留期调整。
  • 排障:全局事务卡住先看 TC 的 branch 状态和全局锁持有者;回滚失败看 undo_log 的 beforeImage/afterImage 是否与当前数据一致。

边界 ​

  • AT 不适合长事务、大批量更新和热点行竞争;全局锁会把并发压回串行。
  • TCC 的 Confirm/Cancel 是业务代码,写错比框架 bug 更常见;空回滚、悬挂、幂等三件事必须一起做。
  • Saga 没有隔离性,补偿也可能失败;资金类业务要配合对账,不能只靠补偿。
  • 本地消息表和事务消息都只保证最终一致,消费端不做幂等就一定会重复。
  • 能用一个库本地事务解决,就不要引入分布式事务;能接受延迟,就优先消息表而不是强一致协调。

讲清楚 ​

分布式事务先问一致性要求。强一致、参与方少、事务短,可以用 2PC 或 Seata AT;AT 的关键是本地事务先提交、靠 undo_log 回滚、靠 TC 的全局锁防止并发脏写,全局锁在二阶段完成后尽快释放,不是全程锁行。核心链路、能写补偿逻辑的,用 TCC,但要处理空回滚、悬挂和幂等。长流程用 Saga。大多数业务用本地消息表或 RocketMQ 事务消息就够了:业务和消息同库提交,后台投递,消费端幂等,再用对账兜底。

把一门技术讲透到既懂也会用