主题
分布式事务
一句话:分布式事务是在多个独立数据库之间模拟「一起成功或一起失败」,强一致方案用同步协调换确定性,最终一致方案用可见的中间状态换可用性。
适用: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 事务消息就够了:业务和消息同库提交,后台投递,消费端幂等,再用对账兜底。