标签: 分布式事务

  • 深入剖析分布式事务的工程取舍:从 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)剧增。