首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >生产级踩坑实录:Seata AT模式悬挂、空回滚与全局锁死锁调优实战

生产级踩坑实录:Seata AT模式悬挂、空回滚与全局锁死锁调优实战

原创
作者头像
IT大佬 jzit-top
修改2026-07-25 10:44:00
修改2026-07-25 10:44:00
320
举报

生产级踩坑实录:Seata AT模式悬挂、空回滚与全局锁死锁调优实战

笔者在负责某电商中台订单拆分项目时,基于Seata 1.6.1搭建分布式事务体系。经历了从初期AT模式“无脑接入”导致频繁死锁,到深入源码改造TCC防悬挂机制的完整历程。本文不重复赘述基础理论,直接复盘生产环境中三大高频痛点及其解决方案。 (本文部分调优思路参考了黑马博学谷《狂野架构师》关于分布式事务源码解析的底层逻辑)

一、现象复盘:引入Seata AT后TPS断崖式下跌

项目初期为了快速落地,我们采用Seata AT(自动补偿)模式。上线首日大促流量进来,订单服务出现大量Lock wait timeout exceeded异常,TPS从预期的2000跌至80,且有部分分支事务一直处于UnKnown状态无法回滚。

通过Skywalking链路追踪发现,耗时卡在DataSourceProxyexecute方法上。根本原因:AT模式基于undo_log全局锁(Global Lock) 保证隔离性。业务逻辑中@Transactional(本地锁)与@GlobalTransactional(全局锁)的加锁顺序不当,导致A服务持有本地锁等待全局锁释放,B服务持有全局锁等待本地锁释放,形成死锁环

二、痛点一:AT模式全局锁与本地锁的死锁破解

2.1 死锁日志分析

通过SHOW ENGINE INNODB STATUS看到典型事务:

代码语言:javascript
复制
TRANSACTION 1: 持有 t_order 的 RECORD LOCK,等待 Seata global_lock 表锁
TRANSACTION 2: 持有 Seata global_lock 表锁,等待 t_order 的 RECORD LOCK

2.2 源码层面定位加锁顺序

查看Seata核心类 AbstractLockManager,全局锁在业务SQL执行前由TC(Server端)获取。若业务方法加了Spring @Transactional,本地数据库连接会先开启隐式锁。

解决方案(二选一)

  1. 调整@Transactional传播属性:将内部本地事务的传播级别改为Propagation.REQUIRES_NEW,让本地锁在子事务中快速释放,避免长时间持有锁等待全局锁。
  2. 减少事务粒度(推荐):不要将Feign远程调用包裹在Spring事务中。代码如下:
代码语言:javascript
复制
@Service
public class OrderService {

    // 正确姿势:全局事务注解只包裹业务编排,本地事务只针对单表insert
    @GlobalTransactional(timeoutMills = 300000, name = "create-order")
    public void createOrder(OrderDTO dto) {
        // 1. 本地事务单独控制(使用编程式事务或脱离大事务)
        orderDao.insert(order); 
        
        // 2. 远程调用(剥离出Spring事务上下文)
        storageFeign.decrease(dto.getProductId());
        accountFeign.decrease(dto.getUserId());
    }
}

调优结果:调整后锁等待超时异常降低98%,在同等压测下RT从2.3s降至650ms。


三、痛点二:TCC模式中的“空回滚”与“悬挂”源码级解决

由于AT模式的锁性能瓶颈,核心库存扣减服务改为TCC模式。但在引入TCC后,日志中频繁出现Cancel method invoked without prior TryTry method invoked after Cancel报警,即经典的空回滚悬挂问题。

3.1 为什么Seata 1.5.x之前无法完全防悬挂?

虽然Seata官方声称支持防悬挂,但在早期版本(1.4.x)中,如果Try阶段因网络抖动超时,事务协调者(TC)会触发Cancel。此时Cancel先执行,清除了上下文。随后迟到的Try请求到达,执行了资源预留,导致资源永远无法释放(悬挂)。

3.2 手写幂等控制逻辑(基于Seata 1.6.1的优化)

Seata 1.6.1在 TccActionInterceptor 中增加了对BusinessActionContextisBranchRegistered校验。但仍需业务层配合。我们在每个TCC接口的Try方法里增加以下防御性逻辑:

代码语言:javascript
复制
@Slf4j
@Component
public class StorageTccActionImpl implements StorageTccAction {

    @Autowired
    private FreezeMapper freezeMapper;

    @Override
    @TwoPhaseBusinessAction(name = "deductStorage", commitMethod = "commit", rollbackMethod = "rollback")
    public boolean tryDecuct(BusinessActionContext context, Long productId, Integer count) {
        // 1. 【防悬挂校验】先检查是否已经有该事务ID的Cancel日志
        String xid = context.getXid();
        if (freezeMapper.existsCancelLog(xid)) {
            log.warn("检测到Cancel已提前执行,拒绝本次Try,防止悬挂。Xid: {}", xid);
            return false; // 直接返回,不预留资源
        }
        
        // 2. 【幂等校验】检查是否已Try过
        if (freezeMapper.existsTryLog(xid)) {
            log.info("Try已执行过,幂等返回。Xid: {}", xid);
            return true;
        }

        // 3. 核心业务:扣减可用库存,增加冻结库存
        storageMapper.decreaseAvailable(productId, count);
        FreezeRecord record = new FreezeRecord();
        record.setXid(xid);
        record.setProductId(productId);
        record.setCount(count);
        freezeMapper.insert(record); // 记录Try日志
        
        // 4. 将上下文参数存入context,供Commit/Cancel使用
        context.getActionContext().put("productId", productId);
        context.getActionContext().put("count", count);
        return true;
    }

    @Override
    public boolean commit(BusinessActionContext context) {
        // 幂等提交:删除冻结记录,实际扣减已在Try中完成
        return freezeMapper.deleteByXid(context.getXid()) > 0;
    }

    @Override
    public boolean rollback(BusinessActionContext context) {
        // 【防空回滚】若没有Try日志,则不执行任何业务回滚
        if (!freezeMapper.existsTryLog(context.getXid())) {
            log.warn("空回滚检测,无Try记录,直接返回成功。Xid: {}", context.getXid());
            return true;
        }
        // 释放冻结库存,增加可用库存
        FreezeRecord record = freezeMapper.selectByXid(context.getXid());
        storageMapper.increaseAvailable(record.getProductId(), record.getCount());
        return freezeMapper.deleteByXid(context.getXid()) > 0;
    }
}

3.3 关键点提炼

  • 防悬挂:Try中判断existsCancelLog
  • 防空回滚:Cancel中判断existsTryLog
  • 所有这些状态基于XID(全局事务ID)在业务表中做隔离,确保分布式事务上下文失效时的最终一致性。

四、痛点三:Saga状态机超时导致补偿失效

在订单完成后的长流程(通知ERP、清缓存、发券)中使用了Saga模式。某次ERP接口响应超时(接口未熔断),Saga状态机默认超时时间(10s)到达后触发全局补偿,但此时ERP线程池中积压的任务后续又执行成功了,导致补偿后业务又被错误执行

4.1 解决方案:状态机配置业务超时 + 服务端重试熔断

调整seata: saga: state-machine: retry-policy: max-attempts: 3 delay: 1000,并在JSON状态机定义中开启isAsync同步转异步,增加timeout配置:

代码语言:javascript
复制
{
  "name": "orderPostProcess",
  "states": [
    {
      "name": "callErp",
      "type": "serviceTask",
      "serviceName": "erpFacade",
      "serviceMethod": "syncOrder",
      "timeout": 3000, 
      "compensateState": "erpCompensate"
    }
  ]
}

核心逻辑:在erpFacade中结合Hystrix线程池隔离,超时即抛出TimeoutException,Saga引擎捕获后立即触发补偿,而非等待最终响应,杜绝了业务不一致。


五、压测数据对比与最终选型推荐

笔者经过3轮全链路压测,结合生产稳定性数据,给出如下选型底线逻辑:

场景特点

最终选定模式

代价与妥协

核心资产(库存/余额)

TCC(手动编码)

需额外建设冻结表,开发量+30%,性能最优(RT < 100ms)

非核心关联(日志/埋点)

可靠消息(RocketMQ事务)

异步解耦,容忍2-5s延迟

查询类/只读事务

不加任何分布式事务

利用CQRS模式彻底规避

遗留老系统改造(无源码)

AT模式(严格压测锁竞争)

必须配置@GlobalTransactional(timeout)并拆分本地事务粒度

关于XA模式:虽然Seata支持,但在生产环境中不建议使用,其阻塞式协议在MySQL 5.7默认隔离级别(RR)下会放大锁范围,不适合互联网高并发项目。


六、总结:架构师的职责是做“确定性”取舍

分布式事务本质上是时间与一致性的博弈。作为系统架构师,面对这类问题不能仅停留在“用哪个框架”,而要深入框架源码边界(如Seata的锁机制、TCC拦截器链),结合业务特性(资金、库存、日志)做精细化治理。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 生产级踩坑实录:Seata AT模式悬挂、空回滚与全局锁死锁调优实战
    • 一、现象复盘:引入Seata AT后TPS断崖式下跌
    • 二、痛点一:AT模式全局锁与本地锁的死锁破解
      • 2.1 死锁日志分析
      • 2.2 源码层面定位加锁顺序
    • 三、痛点二:TCC模式中的“空回滚”与“悬挂”源码级解决
      • 3.1 为什么Seata 1.5.x之前无法完全防悬挂?
      • 3.2 手写幂等控制逻辑(基于Seata 1.6.1的优化)
      • 3.3 关键点提炼
    • 四、痛点三:Saga状态机超时导致补偿失效
      • 4.1 解决方案:状态机配置业务超时 + 服务端重试熔断
    • 五、压测数据对比与最终选型推荐
    • 六、总结:架构师的职责是做“确定性”取舍
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档