深入 Seata AT 全局锁雪崩排查:2PC 滥用引发的 DB 连接池耗尽与 TCC 悬挂防线击穿实战

某次核心链路压测排查中,接手了一个处于“植物人”状态的订单系统。现象极其惨烈:压测刚打到 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)。 问题就出在这里:

  1. 事务 A 执行了 UPDATE inventory,拿到了 DB 的本地行锁。

  2. 事务 A 通过 RPC 去请求 TC 拿全局锁,此时网络抖动或 TC 负载高,RPC 阻塞。

  3. 事务 A 的数据库连接无法释放(因为事务没提交)。

  4. 事务 B、C、D 涌入,全部卡在等 DB 本地行锁上,迅速吃干整个 HikariCP 连接池。

这种设计将 网络 I/O 延迟与数据库本地事务生命周期强绑定,在低频后台(B端)业务里用用也就罢了,拿到核心交易链路来跑,纯粹是嫌命长。

踩坑续集:TCC 悬挂防线击穿实战

在被勒令下线 AT 模式后,研发团队决定“重构”,引入 TCC(Try-Confirm-Cancel)模式。没过几天,客服开始反馈大量“库存扣了但订单取消”的客诉。

我翻开他们的 TCC 补偿代码,差点没绷住:Cancel 方法里直接硬编码写了 UPDATE inventory SET stock = stock + 1。没有任何前置状态判断,完全把分布式网络当成了理想国。

在分布式环境下,RPC 调用存在三大顽疾:丢包、延迟、乱序。这就必然导致 TCC 面临三个致命缺陷:

  1. 空回滚(Empty Rollback)Try 请求因为网络超时压根没到达参与者,但 TC 引擎认为超时了,直接触发 Cancel。参与者收到 Cancel 时,如果直接把库存 +1,凭空造出了资产。

  2. 幂等性失效(Idempotency):网络重试导致 ConfirmCancel 被多次调用,库存被反复加减。

  3. 悬挂(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)

  1. DB 连接池与事务超时监控
  2. 在使用任何 2PC 方案时,务必对比监控 HikariCP Active ConnectionsTC Timeout 的指标关联性。若连接数飙升且慢查询中含大量等待 global_table 锁的操作,立即降级熔断。

  3. TCC 三防自检(防空回滚、防悬挂、幂等)

  4. Code Review 时直接搜索 CancelConfirm 方法,如果没有事务控制表(或类似 Redis Lua 状态机)的介入,直接打回重做。严禁裸写业务补偿逻辑。

  5. 架构选型纪律

  6. C端高并发(如下单、秒杀):绝对禁用 2PC/AT/XA。只允许使用 Saga + 状态机本地消息表 + MQ 最终一致性
  7. 跨服务复杂长事务(如履约、资金清算):推荐使用 Saga 模式,按节点推进并做正向重试/逆向补偿。
  8. 内部后台低并发强一致(如配置同步、基础数据分配):可以使用 Seata AT 提升开发效率。