标签: 分布式事务

  • 深入 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 提升开发效率。
  • 深入 TiDB 热点更新雪崩排查:悲观锁引发的 RPC 拥塞与 Wait-For-Graph 内存爆炸实战

    某次生产环境 TiDB (v6.5.0) 核心集群突发 P99 延迟暴增至 8s,QPS 断崖下跌。核心结论:业务对极少热点行高并发 UPDATE,引发 TiKV 悲观锁 RPC 风暴。大量等锁请求致 TiKV 死锁检测器 (Wait-For-Graph) 内存激增与 Scheduler Worker 线程池打满,演变为全局 RPC 拥塞。破局解法:开启 TiKV 内存悲观锁(In-Memory Pessimistic Lock)、调低锁超时触发快速失败,并强推业务层批量更新。

    现场还原:P99 飙升与锁等待超时

    排查过程中接警,某核心支付业务 TiDB 集群 QPS 从 8000 瞬间跌至 300,SQL 99线飙升到 8000ms。登录中控机,使用 tiup cluster display 确认各组件存活,但 Load Average 出现极度倾斜:部分 TiKV 节点 Load 飙升至 80+,而 TiDB Server 节点的 CPU 反而处于闲置状态。

    查看 TiDB 日志,满屏的死锁与超时报错:

    [WARN] [2006] ["Lock wait timeout exceeded; try restarting transaction"] [conn=482910] 
    [WARN] [endpoint.go:616] [error-response] [err="Deadlock found when trying to get lock; try restarting transaction"]
    [INFO] [client.go:683] ["rpc error: code = DeadlineExceeded desc = context deadline exceeded"]
    

    切到 Grafana 监控大盘,几个关键指标印证了猜想:

    1. TiKV-Details -> Scheduler – commitAcquirePessimisticLock 命令的 QPS 极高,且单个耗时超过 2s。

    2. TiKV-Details -> Thread CPUScheduler-worker 线程池 CPU 使用率达到 100%,而 raftstore 线程负载平稳。

    3. TiDB -> KV ErrorsLock ResolveDeadlock 计数器呈指数级上升。

    这典型的由于极度热点数据并发更新,导致的底层分布式锁拥塞惨案。

    为什么高并发热点更新会打爆 TiKV 节点?

    要理解这个故障,必须深入 TiDB 基于 Percolator 分布式事务模型的悲观锁实现。

    原生的 Google Percolator 是一个标准的乐观事务模型(2PC:Prewrite + Commit),只在提交阶段进行冲突检测。但在高并发冲突场景下,乐观事务会导致大面积的 Write Conflict 报错和无意义的重试。为此,TiDB 从 v3.0 开始引入并默认开启了悲观锁

    在悲观锁模式下,TiDB 拦截了 MySQL 的 FOR UPDATE 或 DML 语句,在执行 Prewrite 之前,会提前向 TiKV 发起一次 AcquirePessimisticLock 的 RPC 请求。

    当成千上万个并发请求去 UPDATE 同一行记录(例如扣减某个爆款商品的库存)时,灾难开始了:

    1. 单点 RPC 风暴:热点数据只存在于一个 Region,所有 TiDB 节点的 AcquirePessimisticLock 请求全部涌向该 Region Leader 所在的单一 TiKV 节点。

    2. 死锁检测器 (Wait-For-Graph) 爆炸:TiKV 为了防止多事务相互等待引发死锁,在内存中维护了一个有向图(Wait-For-Graph)。当成千上万个事务在同一个 Key 上排队等锁时,这个图的节点数和边数急剧膨胀。死锁检测算法在遍历这张庞大的图时,消耗了海量的 CPU 周期,直接打满了 Scheduler-worker 线程。

    3. 队列积压与雪崩:等锁的事务占用着资源不释放,后续的 gRPC 请求在 TiKV 端排队。最终超过客户端设定的 Context Timeout,引发 DeadlineExceeded 报错。更致命的是,RPC 队列拥塞拖垮了同一个 TiKV 上的其他非热点请求,爆炸半径扩散,整个集群雪崩。

    深度防御与参数调优实战

    在分布式系统中,遇到这种极端热点,单纯增加硬件节点毫无意义(因为单行数据只会落在单一 Leader 上)。作为运维架构师,必须从“防御性编程”的角度在 DB 层做硬限制,同时开启底层优化特性。

    1. 斩断长连接:调低锁超时机制(Fail-fast)

    TiDB 默认的悲观锁等待超时时间(innodb_lock_wait_timeout)是 50 秒。在 QPS 几千的场景下,让请求挂起 50 秒等同于自杀。必须立刻修改为 Fail-fast 模式。

    在 TiDB 侧全局调整(需要业务端捕获报错并处理):

    -- 将默认的 50s 修改为 3s,快速释放等待队列的资源
    SET GLOBAL innodb_lock_wait_timeout = 3;
    

    2. 核心大招:开启 TiKV 内存悲观锁 (In-Memory Pessimistic Lock)

    在默认机制下,TiKV 获取悲观锁不仅要在内存排队,还要将锁信息通过 Raft 协议写入本地 RocksDB 并同步给 Follower,这个 I/O 路径极度沉重。 TiDB 在 v6.0 引入了内存悲观锁,在 v6.5 中成熟。它允许将悲观锁仅保留在 Region Leader 的内存中,不走 Raft 同步。即使 Leader 宕机,新 Leader 也能在读写前通过唤醒机制安全恢复。

    编辑集群配置 (tiup cluster edit-config ),在 TiKV 模块中注入:

    server_configs:
      tikv:
        pessimistic-txn.in-memory: true
        # 强烈建议配合 pipelined 提交,减少网络往返延迟
        pessimistic-txn.pipelined: true
    

    执行 tiup cluster reload -R tikv 滚动生效。开启后,AcquirePessimisticLock 的 P99 耗时从百毫秒级直接降至亚毫秒级,彻底缓解了 Scheduler Worker 的压力。

    3. 业务层改造:禁止 DB 当 Redis 用

    防御性运维只能保命,不能治本。排查发现业务在用 UPDATE counter SET val = val + 1 WHERE id = 1 做高频计数。 强推研发改写逻辑:

    • 引入 Redis 做前端原子计数和防刷。

    • 业务聚合请求,将单条记录的并发 Update 改为批量合并更新(Batching),或者改用分片插入(Insert on duplicate key update into multiple hash slots),最后再汇总。

    常见问题

    Q1:如何快速在雪崩现场定位是哪个 Key 引发了悲观锁争抢? A:通过 TiDB 自带的系统表,直接查询当前正在等锁的事务和具体对应的 SQL:

    SELECT * FROM information_schema.DATA_LOCK_WAITS;
    SELECT * FROM information_schema.TIDB_TRX WHERE STATE = 'LockWaiting';
    

    配合 TIDB_HOT_REGIONS 可以精准定位到是哪张表的哪个索引正在遭遇写热点。

    Q2:既然高并发下悲观锁这么容易拥塞,我切回乐观锁(Optimistic)可以吗? A:绝对不建议。乐观锁在遇到高并发热点时,会在最后的 Commit 阶段大面积爆出 Write Conflict 报错。虽然它不会引起 TiKV 侧的锁排队阻塞,但会导致客户端无休止地重试(如果开启了事务自动重试机制),白白浪费网络带宽和 TiDB CPU 计算力,最终一样会导致 QPS 下跌。正确的姿势是:保持悲观锁,开启 In-Memory 优化,并严格控制 innodb_lock_wait_timeout

    Q3:开启 In-Memory 悲观锁后,如果 Region Leader 发生网络隔离或宕机,会导致锁丢失引发脑裂吗? A:不会。TiDB 的架构设计非常严谨。如果 Leader 宕机,锁虽然在内存中丢失,但发生 Leader 切换时,新的 Leader 会强制要求新的读写请求推进 ReadIndex 或产生新 epoch。此时旧事务在发起 Commit 阶段的 Prewrite 操作时,由于找不到原来的悲观锁,且 Region epoch 已经改变,事务会被直接中止(Abort),从而保证了分布式事务的严格一致性(Linearizability)。

  • 深入 TiDB 大事务雪崩排查:无脑 DELETE 引发的 Percolator 锁风暴与 TiDB 节点 OOM 惨案

    近期处理了一起极为惨烈的分布式数据库生产事故。核心业务集群(TiDB v6.1)的 P99 延迟在两分钟内从 20ms 直接飙升到 30s,随后多个 TiDB Server 节点接连触发 OOM 被内核直接 Kill,集群 QPS 跌至个位数,几乎处于瘫痪状态。

    排查到底,罪魁祸首是一条没有任何 LIMIT 限制、涉及 8000 万行数据的历史日志清理 SQL(DELETE FROM action_log WHERE create_time < '2023-01-01')。 结论先行:在基于 Percolator 模型的分布式数据库中,将单机关系型数据库的“大事务”思维直接照搬是自杀行为。TiDB 在两阶段提交(2PC)的 Prewrite 阶段需要将所有 Mutate 数据缓存在 TiDB Server 内存中,同时向 TiKV 写入海量 Lock 记录。这不仅会瞬间击穿计算节点的内存配额,还会引发大面积的锁冲突与 ResolveLock 风暴,导致整个集群的 Raft Store 与 Coprocessor 线程池耗尽。

    解决大批量数据修改,必须使用非事务 DML(BATCH ON)或按主键范围切分的批处理脚本。把分布式 DB 当无底洞垃圾桶,它就会把你的业务一起埋了。

    现场还原:从延迟突刺到死亡宣告

    监控大盘上的异动非常典型,呈现出教科书般的“雪崩”曲线:

    1. TiDB 节点内存垂直起飞:某一个 TiDB 节点的内存使用率在 60 秒内从 15% 飙升至 95%。

    2. 锁指标爆炸:TiDB Dashboard 中的 KV Backoff OPSLock Resolve OPS 激增 1000 倍。

    3. gRPC 阻塞:TiKV 的 gRPC message duration P99 飙升至 15s 以上。

    4. 死亡宣告:系统监控捕获到内核级斩首行动: text kernel: [123456.789] Out of memory: Kill process 2333 (tidb-server) score 850 or sacrifice child kernel: [123456.790] Killed process 2333 (tidb-server) total-vm:41943040kB, anon-rss:33554432kB, file-rss:0kB

    查看存活 TiDB 节点的 tidb.log,满屏的 2PC 提交失败与锁冲突报错:

    [WARN] [2pc.go:1234] ["commit failed"] [conn=889922] [error="[kv:9007]Write conflict, txnStartTS=441234567890123456 is stale"]
    [WARN] [backoff.go:234] ["txnLockNotFound"] [conn=889922] [caller="resolveLock"] 
    

    核心原理解析:为什么一条 DELETE 能干趴整个集群?

    很多开发习惯了 MySQL (InnoDB) 的行为,认为一条几千万行的 DELETE 最多就是跑得慢、产生大量 Undo/Redo log、导致主从延迟。但在 TiDB 这种计算与存储分离、基于 Percolator 事务模型的 HTAP 架构中,机制完全不同。

    一条巨型 DELETE 在 TiDB 的执行生命周期,就是一场灾难的酝酿过程:

    1. 计算节点内存撑爆 (TiDB OOM)

    TiDB 为了支持乐观/悲观事务,在事务提交前,会将所有修改(对于 DELETE,就是将被删记录的 Key 和空 Value)缓存在 TiDB Server 的内存中(memDB)。 8000 万行记录,如果每行转化出的 KV 占 200 Bytes,单条事务在内存中就需要硬吃至少 15GB 的堆内存。再加上 Go 语言在应对这种瞬间海量小对象分配时,GC 往往会严重滞后,导致实际 RSS 占用翻倍,轻松击穿 tidb_server_memory_limit 的软限制,直接被 OS OOM-Killer 带走。

    2. Prewrite 阶段的锁风暴 (Lock Storm)

    哪怕服务器内存够大扛住了第一波,在 2PC 的 Prewrite 阶段,TiDB 会向 TiKV 写入分布式的锁:

    • 从这 8000 万个 Key 中选出一个作为 Primary Key (Primary Lock)

    • 将剩余的 7999 万多条记录作为 Secondary Locks 写入 TiKV,并全部指向那个 Primary Lock。

    此时,TiKV 集群被灌入数千万个 Lock CF(Column Family)记录。如果其他正常的业务请求(哪怕是读操作)碰巧访问到了这 8000 万行数据中的任意一行,按照 Percolator 协议,读请求会被锁阻塞。

    3. ResolveLock 级联雪崩

    当正常请求遇到这些锁,且发现锁所属的事务持锁时间过长时,会尝试进行清锁操作(ResolveLock)

    • 读请求会去反查 Primary Lock 的状态,确认那个巨型事务到底提交了没有。

    • 由于巨型事务的 Primary Lock 所在 Region 可能正处于极高的负载中,反查 RPC 出现堆积和超时。

    • 海量的正常请求全部卡在 ResolveLock 阶段,TiKV 的 Coprocessor 线程池和 gRPC 线程池被彻底打满,导致全表甚至全库的请求响应卡死,这就是经典的读写相互阻塞

    防御性加固与解决方案

    修复这个烂摊子,第一步是立刻 Kill 掉那个执行 DELETE 的会话,但这只是止血。为了彻底杜绝此类问题,必须从架构配置和研发规范上进行双重封堵。

    1. 严格限制事务大小与内存配额

    不要指望开发自觉,必须在配置层面进行防御性斩断。检查并调整 TiDB 配置文件:

    [performance]
    # 限制单事务的最大容量,默认 100MB,最大不超过 1GB。绝不给跑百 GB 级别事务的机会。
    txn-total-size-limit = 104857600
    
    [mem-quota]
    # 限制单条 SQL 的内存使用,超过后触发 oom-action
    query = 1073741824 # 1GB
    oom-action = "cancel" # 默认通常是 cancel,确保内存超限时直接终止 SQL 而不是拖死节点
    

    注:在 TiDB v6.1+ 中,全局内存控制 server-memory-quotatidb_server_memory_limit 系统变量已经完善,但精细到 query 级别的 cancel 依然是防范 OOM 的最后一道防线。

    2. 使用非事务 DML 或分批处理

    对于大批量历史数据清理,正确的做法是将其切分为无数个小事务。TiDB 官方提供了一项专用于此类场景的功能:Non-transactional DML

    -- 将大 DELETE 拆分为基于主键或者时间范围的小批量操作
    BATCH ON id LIMIT 5000 
    DELETE FROM action_log WHERE create_time < '2023-01-01';
    

    这条语句会在 TiDB 内部自动按 id 划分范围,每次只在一个小范围内执行 DELETE 并独立提交,从而绕过事务大小限制,彻底避免长事务持有海量锁导致的 OOM 和锁风暴。

    3. TiKV 侧 RocksDB 与 Raft 调优

    排查中发现 TiKV OOM 或高负载,往往是因为写入量太大导致 RocksDB Write Stall。保证 block-cache 配置合理,不超过系统内存的 45%。对于高频批量删除业务,考虑调大 max-background-jobs 加速 Compaction,避免 Tombstone 过多导致后续查询扫描性能断崖式下跌。

    排查清单 (大事务与 OOM 问题速查)

    1. dmesg 与 OOM 确认:快速执行 dmesg -T | grep -i oom,确认 tidb-servertikv-server 是否被内核 Kill,排除网络分区导致的假死。

    2. 排查慢查询与内存大户:查询 INFORMATION_SCHEMA.SLOW_QUERY 或 TiDB Dashboard,按 Mem_maxProcess_time 倒序,揪出未加 LIMIT 或扫描行数极大的问题 SQL。

    3. 核对事务配额参数:检查集群的 txn-total-size-limit 参数是否被违规调大(正常业务不应超过 100MB)。

    4. 监控 Lock 冲突指标:在 Grafana -> TiDB -> KV Errors 面板中,重点观察 KV Backoff OPS (特别是 txnLocktxnLockFast),若该指标激增,说明集群存在大事务或热点记录的严重写冲突。

    5. 垃圾回收 (GC) 状态确认:大批量 DELETE 后,务必通过 mysql.tidb 表检查 GC Safe Point 是否正常推进。大量的无用版本积压会拖慢整个集群的物理读取效率。

  • 深入剖析分布式事务的工程取舍:从 2PC 锁争用泥潭到 TCC 防悬挂实战

    核心结论:高并发核心链路严禁直接使用 XA/2PC 协议,其同步阻塞与全局锁定机制必然导致数据库连接池雪崩。Seata AT 模式虽通过一阶段提交缓解了长事务,但在热点行更新时,全依赖 TC 全局锁,极易造成 P99 延迟飙升。落地高并发分布式事务,最稳妥的解法是 TCC 或 Saga,并必须在底层辅以本地事务防悬挂控制表,实现极致的防御性编程。

    排查与重构高并发交易系统时,分布式事务永远是绕不开的雷区。很多人在架构选型时迷信各种中间件包装好的透明事务,却忽视了 CAP 定理下分布式事务的本质:通过牺牲可用性(锁阻塞)或牺牲一致性(最终一致补偿)来换取系统的流转

    本文以 MySQL 8.0.32 和 Seata 1.6.1 为例,撕开分布式事务底层的工程细节,只谈实际落地时的痛点与防御。

    XA/2PC 的原罪:网络 RTT 与底层锁的致命耦合

    传统 XA 规范(2PC)的逻辑看似无懈可击:Prepare 阶段锁定资源,Commit/Rollback 阶段统一决断。但在实际微服务场景下,这是灾难的设计。

    当业务发起一次 XA 事务,MySQL 底层会执行 XA START -> SQL -> XA PREPARE。此时,InnoDB 引擎已经对涉及的数据行加上了排他锁(X Lock),并且这个锁的释放完全依赖于网络另一端 TM(Transaction Manager)的指令。

    你可以通过以下 SQL 在 MySQL 8.0+ 中观察到 XA 事务持有的锁阻塞情况:

    SELECT 
        p.trx_id, 
        p.trx_state, 
        p.trx_started, 
        l.lock_type, 
        l.lock_mode, 
        l.lock_data
    FROM performance_schema.data_locks l
    JOIN information_schema.innodb_trx p ON l.engine_transaction_id = p.trx_id
    WHERE p.trx_state = 'PREPARED';
    

    雪崩路径:

    1. 阶段一完成后,RM(数据库)持有行锁。

    2. TM 在阶段二由于网络抖动、GC 停顿或节点宕机,迟迟不发送 XA COMMIT

    3. 其他并发请求试图访问该行数据,全部堆积在 innodb_lock_waits 中。

    4. 数据库连接池(如 HikariCP)迅速被占满,拖垮整个服务。

    这就是为什么在 C 端高并发核心链路(如库存扣减、资金转账)中,XA 协议属于绝对的禁区。

    为什么 Seata AT 模式在热点数据下会演变成性能灾难?

    为了解决 2PC 的长时间锁资源问题,Seata AT 模式应运而生。它的核心思想是:一阶段直接提交本地事务释放数据库锁,二阶段通过 undo_log 回滚。这听起来很完美,但它真的能抗住高并发吗?

    在某次大促压测中,我们发现扣减热点 SKU 库存时,TPS 始终卡在 300 左右,且 API 的 P99 延迟高达 3000ms+。抓取 Seata TC Server 的日志发现大量获取全局锁超时:

    [timeoutChecker_1] ERROR io.seata.core.lock.LockManager - Global lock wait timeout, xid: 192.168.1.10:8091:859392134, table: inventory, pk: 1001
    

    底层原理解析: Seata AT 为了防止脏写(Dirty Write),在本地事务提交前,必须向 TC(Transaction Coordinator)申请全局锁(Global Lock)。 如果两个并发请求同时修改同一行数据(例如热点 SKU id=1001):

    1. 事务 A 获取本地锁,修改数据。

    2. 事务 A 申请全局锁 inventory:1001,成功。A 提交本地事务,释放本地锁。

    3. 事务 B 获取本地锁,修改数据。

    4. 事务 B 申请全局锁 inventory:1001失败,事务 A 尚未完成二阶段

    5. 事务 B 必须等待,若超时则抛出 LockWaitTimeoutException,随后回滚本地事务。

    结论: Seata AT 只是把数据库的行锁争用,转移到了 Seata TC Server 的全局锁争用上。在热点行更新场景下,网络 RTT 被放大,性能瓶颈依然存在。AT 模式适合低并发的后台管理系统,绝不适合高并发交易链路。

    TCC 架构的防御性编程:空回滚、幂等与防悬挂实战

    既然底层锁不可靠,我们就必须走向应用层补偿事务,即 TCC(Try-Confirm-Cancel)或 Saga。 TCC 的 Try 阶段预留资源,Confirm 提交,Cancel 释放预留。但 TCC 落地的核心难点根本不是业务逻辑,而是分布式网络三大暗礁:网络重试导致的非幂等、空回滚、悬挂(Suspension)

    • 空回滚:Try 请求因网络丢包未到达,TM 直接发起 Cancel。此时 Cancel 必须能够识别并成功返回。

    • 悬挂:Try 请求超时,TM 发起 Cancel 并执行成功。随后那个被网络延迟的 Try 请求终于到达了参与者。如果 Try 成功执行,预留的资源将永远无法被 Confirm 或 Cancel,造成数据死锁。

    最佳实践:基于本地控制表的 TCC 防御机制

    我们必须在业务数据库中建立一张 TCC 事务控制表,利用本地事务的 ACID 特性来抵抗分布式网络的混乱。

    CREATE TABLE `tcc_branch_control` (
        `xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
        `branch_id` VARCHAR(128) NOT NULL COMMENT '分支事务ID',
        `status` TINYINT NOT NULL COMMENT '状态: 0-Try, 1-Confirm, 2-Cancel',
        `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
        `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        PRIMARY KEY (`xid`, `branch_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    

    Try 阶段的防御代码逻辑:

    在 Try 方法中,我们将业务 SQL 与插入控制表包裹在同一个本地事务中。

    @Transactional(rollbackFor = Exception.class)
    public boolean tryDeduct(String xid, String branchId, String sku, int count) {
        // 1. 防悬挂与幂等检查:尝试插入 Try 记录
        // 如果插入失败(主键冲突),说明 Try 已执行(需处理幂等),或者 Cancel 已经执行(发生悬挂)
        int insertCount = tccControlMapper.insertIgnore(xid, branchId, 0);
        if (insertCount == 0) {
            TccControl record = tccControlMapper.select(xid, branchId);
            if (record.getStatus() == 2) {
                log.warn("防悬挂拦截: Cancel已执行, 丢弃迟到的Try请求, xid: {}", xid);
                return false; 
            }
            log.info("Try 幂等放行, xid: {}", xid);
            return true;
        }
    
        // 2. 正常执行 Try 业务逻辑 (如: 冻结库存)
        inventoryMapper.freeze(sku, count);
        return true;
    }
    

    Cancel 阶段的防御代码逻辑:

    @Transactional(rollbackFor = Exception.class)
    public boolean cancelDeduct(String xid, String branchId, String sku, int count) {
        // 1. 尝试插入 Cancel 记录 (防御空回滚)
        // 如果之前没有 Try 过,这里会插入成功,状态为 2 (Cancel)。
        // 这同时阻断了后续迟到的 Try (防悬挂)。
        int insertCount = tccControlMapper.insertIgnore(xid, branchId, 2);
        if (insertCount > 0) {
            log.info("空回滚执行: 记录Cancel状态, 拦截后续Try, xid: {}", xid);
            return true;
        }
    
        // 2. 检查当前状态
        TccControl record = tccControlMapper.select(xid, branchId);
        if (record.getStatus() == 2) {
            log.info("Cancel 幂等放行, xid: {}", xid);
            return true;
        }
    
        // 3. 执行资源释放,并更新状态为 Cancel
        inventoryMapper.unfreeze(sku, count);
        tccControlMapper.updateStatus(xid, branchId, 2);
        return true;
    }
    

    通过这一张表和一个 INSERT IGNORE 指令,我们在数据库引擎层面完美防范了所有由于网络乱序引发的事务状态异常。

    Saga 模式的取舍:隔离性的彻底放弃

    当你的分布式事务跨越了第三方系统(如调用外部银行接口),你无法要求第三方提供 Try 接口预留资源,此时 TCC 不适用,只能退化为 Saga 模式。

    Saga 也是两阶段:一阶段直接执行正向业务(如直接入账),二阶段执行补偿业务(如扣减入账)。 它的最大缺陷是缺乏隔离性。在正向业务执行完,补偿业务尚未执行的这段时间窗口内,其他事务可能会读取甚至修改这部分数据(脏读、脏写)。

    Saga 防治脏写的底线: 如果采用 Saga,必须引入乐观锁(版本号机制)或状态机。一旦补偿阶段发现数据的版本号被其他事务推进过,绝对不能强行执行回滚逻辑,必须立即阻断补偿链路,抛出异常,转入人工对账异常队列表。自动化的尽头是人工,这是容灾兜底的最后防线。

    常见问题 (FAQ)

    Q1:在 TCC 模式下,如果 Confirm 或 Cancel 阶段执行失败(比如数据库临时宕机),应该怎么处理? A: TCC 的设计前提是 Confirm 和 Cancel 必须最终成功。如果阶段二失败,TM(Transaction Manager)会不断重试。工程实现上,必须保证阶段二的绝对幂等性。如果重试超过一定阈值(如重试 5 次依然报错),通常意味着出现了底层硬故障(如坏块或长期的依赖宕机)。此时 TM 会记录异常日志,触发告警,转由人工介入。绝对不要在阶段二返回业务层面的错误。

    Q2:Saga 模式执行补偿逻辑时,发现数据已经被用户修改过了(脏写),如何进行补偿? A: 这是 Saga 的经典痛点。在设计 Saga 时,必须对被操作的数据加上状态锁或语义锁。例如订单状态变更为“发货中”,此时如果触发补偿,发现状态已经是“已收货”,就不应该直接执行逆向逻辑。一旦检测到脏写(通过乐观锁版本号或状态机流转规则拦截),系统应该停止自动补偿,触发风控或异常对账流程,由运营人员判断是否需要人工冲正。

    Q3:Seata Server (TC) 如果发生 OOM 或者宕机,对正在运行的业务有什么影响? A: 以 Seata 1.6.1 为例,TC 本身无状态,其事务数据存储在 MySQL 或 Redis 中。如果 TC 宕机,客户端的发起的全局事务将无法注册或提交,业务接口会大量抛出 TransactionException,导致新事务完全中断(可用性受损)。对于已经进入二阶段的事务,待 TC 恢复后,会从数据库读取处于 COMMITTINGROLLBACKING 状态的会话,继续下发二阶段指令。监控上会观察到活跃事务数(Active Transactions)剧增。