深入 Seata AT 陷阱排查:@GlobalTransactional 滥用引发的全局锁竞争与 DB 连接池耗尽雪崩实战

近期,核心交易链路在一次大促压测起步阶段便遭遇惨烈雪崩。订单服务接口 P99 延迟从 50ms 瞬间飙升至 8000ms,随后触发熔断,上游网关出现大量 503 HTTP 状态码。排查结论非常明确:业务研发在 C 端高并发下单接口上,极其粗暴地滥用了 Seata AT 模式(一种 2PC 的变体)。他们在包裹了三次跨网络 RPC 调用的上帝方法上打了一个 @GlobalTransactional 注解,导致本地 DB 连接被长时间挂起,加上 Seata TC(Transaction Coordinator)端的全局锁竞争,瞬间打穿了 HikariCP 连接池,最终引发微服务级联崩溃。

分布式事务领域没有银弹,把 2PC 思想的框架当成本地 @Transactional 盲目使用,是对系统稳定性的严重破坏。

案发现场:连接池耗尽与死锁报警

排查过程中,订单服务最先弹出的核心告警是 DB 连接池被打满。查看应用日志,满屏都是 HikariCP 的获取连接超时异常:

java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30005ms.
    at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:695)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)

同时,MySQL 侧监控显示 Threads_running 飙升,通过 show engine innodb status\G 查看,发现大量事务处于 LOCK WAIT 状态。

再去扒 Seata TC Server(我们部署的是集群模式,DB 存储)的日志,发现了大量全局锁获取失败的报错:

[timeoutChecker_1] ERROR io.seata.server.coordinator.DefaultCore - Global lock acquire failed, xid: 192.168.1.10:8091:123456789, branchId: 987654321

这一切的元凶,指向了订单服务中的一段“经典”代码:

@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
@Transactional
public Order createOrder(OrderDTO dto) {
    // 1. 本地 DB 扣减库存 (热点商品)
    inventoryMapper.decrease(dto.getSkuId(), dto.getQty());
    // 2. RPC 调用:扣减积分
    pointClient.decrease(dto.getUserId(), dto.getPoints());
    // 3. RPC 调用:核销优惠券
    couponClient.use(dto.getCouponId());
    // 4. 本地 DB 创建订单
    orderMapper.insert(order);
    return order;
}

致命的架构逻辑缺陷

在微服务架构下,写出上面这种代码是不可原谅的。这不仅暴露了对分布式事务底层机制的无知,更是直接违背了“防御性编程”中关于资源控制的基本原则。

1. 连接池被强行劫持(The I/O Trap) 在 Seata AT 模式的一阶段(Phase 1),业务数据和回滚日志(undo_log)必须在同一个本地事务中提交。当方法被 @Transactional 和 @GlobalTransactional 同时修饰时,Spring 会在方法一开始就从 HikariCP 借出一个 DB 连接。 随后发生的 pointClient 和 couponClient 是跨网络 RPC 调用。假设每个 RPC 耗时 200ms,这个 DB 连接就会被白白挂起至少 400ms,期间不执行任何实质性的 DB 读写。如果在 2000 QPS 的并发下,一个最大连接数为 200 的 HikariCP 连接池,不到 1 秒钟就会被彻底耗尽。

2. 全局锁与本地行锁的双重绞杀(Lock Contention) Seata AT 为了保证隔离性(写隔离),在本地事务提交前,必须向 TC 注册分支事务并获取全局锁(Global Lock)。 在上述代码中,如果 dto.getSkuId() 是一个热点商品(例如秒杀),本地 MySQL 已经对该 SKU 所在的行加上了独占锁(X锁)。接着,应用带着这个状态去向 TC 申请该记录的全局锁。如果此时 RPC 调用出现哪怕一点网络抖动,或者 TC 端处理能力到达瓶颈,本地事务就会一直处于 WAIT 状态,死死抱着 MySQL 的行锁不放。这不仅会导致当前分布式事务超时,更会阻塞所有针对该热点 SKU 的普通更新操作,最终演变成 DB 层的锁雪崩。

拆解与重构:抛弃 2PC,走向补偿与最终一致性

对于 C 端高并发主链路,2PC(包括基于 2PC 演进的 AT 模式)永远是反模式。2PC 的同步阻塞特性决定了它只适合后台管理系统的低并发、强一致场景。

面对这个烂摊子,我们在现场果断勒令研发团队进行重构,砍掉 Seata AT,改用 本地消息表(Outbox Pattern)+ 消息队列 的最终一致性方案,部分强依赖链路降级为 TCC(Try-Confirm-Cancel) 模式。

方案一:RocketMQ 事务消息(最终一致性) 核心思路是把分布式事务降维打击成单机事务。 将扣减库存和创建订单放在一个本地事务中,利用 RocketMQ 的事务消息机制,确保本地 DB 提交和 MQ 消息发送的原子性。下游的积分和优惠券服务通过订阅 MQ 进行异步扣减。这种方式将跨服务的强一致解耦为最终一致,彻底释放了 DB 连接池压力。

方案二:TCC 模式的工程取舍 如果某些业务场景强依赖严格的资源预留(例如不超卖、不超发),我们采用 TCC 模式。但在落地 TCC 时,必须在框架层死磕以下三个防御性设计:

  1. 防空回滚:Cancel 阶段被触发时,如果 Try 阶段由于网络延迟根本没执行,Cancel 逻辑必须识别并直接返回成功。我们在 TCC 事务控制表里增加一个状态字段,未找到 Try 记录的 Cancel 视为“空回滚”。

  2. 防悬挂:Cancel 接口比 Try 接口先到达(网络乱序)。应对方案是 Cancel 执行时不仅要判断是否空回滚,还要在控制表中插入一条“已回滚”记录。当迟到的 Try 请求到达时,发现该记录已经存在,则直接拒绝执行。

  3. 幂等性:Confirm 和 Cancel 极大概率会被重试,业务表必须引入 tx_id 唯一约束,确保重复请求被拦截。

排查清单与避坑指南

针对分布式事务(2PC / Saga / TCC)的落地实践,建议梳理以下排查清单,供排错和架构 Review 时对照:

  1. DB 连接泄漏审查:排查所有标注了分布式事务注解(如 @GlobalTransactional、@Compensable)的方法,严禁在事务上下文中包裹长耗时的外部 I/O 操作(RPC/HTTP 调用、耗时计算)。

  2. 锁超时配置对齐:必须确保 MySQL innodb_lock_wait_timeout > Seata client rm.lock-retry-timeout。否则会导致 MySQL 本地事务先于 Seata 释放锁,从而产生脏写(Dirty Write),破坏 undo_log,引发二阶段 Rollback 崩溃。

  3. TCC 防御性控制表校验:确认 TCC 参与者服务中是否实现了标准的事务状态表。必须同时包含 xid, branch_id, status 字段,并在 Try/Confirm/Cancel 阶段对其使用 select ... for update 或基于唯一索引进行状态机流转。

  4. TC 高可用与存储瓶颈:如果仍在使用 Seata AT,检查 TC Server 的存储模式。对于生产环境,坚决弃用 File 模式,改用 DB 或 Redis 模式,并监控 TC 端的 global_table 和 branch_table 的行数,防范僵尸事务堆积。