某次核心链路压测排查中,接手了一个处于“植物人”状态的订单系统。现象极其惨烈:压测刚打到 500 QPS,订单库和库存库的 HikariCP 连接池瞬间 100% 耗尽,大量请求报 Connection timeout,99线从 30ms 飙升至 45s,系统完全夯死。
直接抛结论:这是典型的分布式事务滥用惨案。研发在面向 C 端的高并发链路上无脑贴 @GlobalTransactional 强行使用 Seata AT(2PC 变种)模式,导致底层资源被全局锁(Global Lock)和本地行锁双重绞杀。而在随后的紧急改造中,改用 TCC 模式却没做“防悬挂”和“空回滚”处理,导致网络抖动时出现大量脏数据。
高并发 C 端链路绝对不能碰强一致性的 2PC/AT 模式,老老实实用基于本地消息表或 MQ 的最终一致性(Saga/可靠消息),这是铁律。
案发现场:被一把 @GlobalTransactional 瘫痪的数据库
排查伊始,监控大屏上一片惨红。登录 DB 节点,直接 show processlist 和抓取 InnoDB 状态:
-- 大量线程处于 Lock wait 状态
mysql> SELECT * FROM information_schema.innodb_trx\G
trx_state: LOCK WAIT
trx_query: UPDATE inventory SET stock = stock - 1 WHERE sku_id = '10086'
-- 查看锁等待
mysql> SELECT * FROM sys.innodb_lock_waits;
同时,应用层的日志疯狂输出 Seata TC(Transaction Coordinator)交互超时的报错:
io.seata.core.exception.RmTransactionException: Response[ TransactionException[BranchRegister timeout] ]
...
Caused by: io.seata.core.exception.TransactionException: Global lock acquire failed, xid: 192.168.1.10:8091:123456789
原理还原:为什么 AT 模式会引发连接池雪崩? Seata AT 模式本质上是两阶段提交(2PC)的优化版。在 Phase 1,本地业务 SQL 执行完后,不会立刻提交数据库事务,而是要向 TC 申请全局锁(Global Lock)。 问题就出在这里:
-
事务 A 执行了
UPDATE inventory,拿到了 DB 的本地行锁。 -
事务 A 通过 RPC 去请求 TC 拿全局锁,此时网络抖动或 TC 负载高,RPC 阻塞。
-
事务 A 的数据库连接无法释放(因为事务没提交)。
-
事务 B、C、D 涌入,全部卡在等 DB 本地行锁上,迅速吃干整个 HikariCP 连接池。
这种设计将 网络 I/O 延迟与数据库本地事务生命周期强绑定,在低频后台(B端)业务里用用也就罢了,拿到核心交易链路来跑,纯粹是嫌命长。
踩坑续集:TCC 悬挂防线击穿实战
在被勒令下线 AT 模式后,研发团队决定“重构”,引入 TCC(Try-Confirm-Cancel)模式。没过几天,客服开始反馈大量“库存扣了但订单取消”的客诉。
我翻开他们的 TCC 补偿代码,差点没绷住:Cancel 方法里直接硬编码写了 UPDATE inventory SET stock = stock + 1。没有任何前置状态判断,完全把分布式网络当成了理想国。
在分布式环境下,RPC 调用存在三大顽疾:丢包、延迟、乱序。这就必然导致 TCC 面临三个致命缺陷:
-
空回滚(Empty Rollback):
Try请求因为网络超时压根没到达参与者,但 TC 引擎认为超时了,直接触发Cancel。参与者收到Cancel时,如果直接把库存 +1,凭空造出了资产。 -
幂等性失效(Idempotency):网络重试导致
Confirm或Cancel被多次调用,库存被反复加减。 -
悬挂(Suspension):最隐蔽的杀手。
Try请求发出后遇到极大的网络延迟,TC 等不及了,触发了Cancel(此时属于空回滚,防住了没造成危害)。但在Cancel执行完后,那个迟到的Try请求终于到了,并成功扣减了库存。此时全局事务早已结束,这个Try造成的改变将永远不会被回滚。这就是“悬挂”。
把分布式事务当成本地 @Transactional 这种黑盒注解来用,缺乏对底层网络状态机的敬畏,出大事故是迟早的事。
绝地反击:防御性 TCC 状态机落地实现
要解决 TCC 的上述三大顽疾,千万不要在业务逻辑里用复杂的 if/else 去查业务表状态,极其容易出现并发竞态条件。
标准且优雅的做法是:建立一张独立的 TCC 事务控制表(tcc_tx_log),利用数据库的唯一索引(UK)和行锁来做防御。
表结构核心字段:xid(全局事务ID), branch_id(分支事务ID), status(TRY, CONFIRM, CANCEL)。联合唯一索引:uk_xid_branch_id。
实战防御伪代码/SQL:
1. Try 阶段(防悬挂 + 防重复):
// 尝试插入一条状态为 TRY 的记录
int rows = jdbc.update("INSERT INTO tcc_tx_log (xid, branch_id, status) VALUES (?, ?, 'TRY')", xid, branch_id);
// 如果抛出 DuplicateKeyException,说明两条路:
// 1. Try 被重复执行(幂等拦截)
// 2. Cancel 已经先执行过了(防悬挂拦截,Cancel 阶段会预埋一条 CANCEL 记录)
if (exception) throw new TccException("并发重复执行或已发生悬挂");
// 执行业务逻辑...
2. Cancel 阶段(防空回滚 + 防悬挂 + 幂等):
// 核心逻辑:Insert on duplicate key update
// 如果记录不存在(说明 Try 没执行或者迟到了),直接插入一条 CANCEL 记录。
// 这步极为关键:一旦插入了 CANCEL,后续迟到的 Try 就会在 Insert 时报主键冲突,彻底斩断悬挂!
int rows = jdbc.update(
"INSERT INTO tcc_tx_log (xid, branch_id, status) VALUES (?, ?, 'CANCEL') " +
"ON DUPLICATE KEY UPDATE status = 'CANCEL' WHERE status = 'TRY'",
xid, branch_id
);
if (rows == 1 && inserted) {
// 空回滚场景:记录不存在,直接插入了 CANCEL 状态。业务无需补偿,直接返回成功。
return true;
} else if (rows == 2 && updated) {
// 正常回滚场景:把 TRY 更新成了 CANCEL。执行业务补偿逻辑。
doBusinessRollback();
return true;
} else {
// 幂等场景:状态已经是 CANCEL 了,直接返回成功。
return true;
}
这套基于 DB 唯一索引的状态机,才是真正具备“防御性”的分布式事务工程实现。
排查清单与避坑指南 (Troubleshooting Checklist)
- DB 连接池与事务超时监控:
-
在使用任何 2PC 方案时,务必对比监控
HikariCP Active Connections与TC Timeout的指标关联性。若连接数飙升且慢查询中含大量等待global_table锁的操作,立即降级熔断。 -
TCC 三防自检(防空回滚、防悬挂、幂等):
-
Code Review 时直接搜索
Cancel和Confirm方法,如果没有事务控制表(或类似 Redis Lua 状态机)的介入,直接打回重做。严禁裸写业务补偿逻辑。 -
架构选型纪律:
- C端高并发(如下单、秒杀):绝对禁用 2PC/AT/XA。只允许使用 Saga + 状态机 或 本地消息表 + MQ 最终一致性。
- 跨服务复杂长事务(如履约、资金清算):推荐使用 Saga 模式,按节点推进并做正向重试/逆向补偿。
- 内部后台低并发强一致(如配置同步、基础数据分配):可以使用 Seata AT 提升开发效率。