前言

事情的起因还要从 Transaction rolled back because it has been marked as rollback-only] with root cause 说起,公司的一个业务,Kafka Consumer消费了来自上游的消息处理并写入数据,整个逻辑很复杂,由最外层的方法注解 @Transactional(rollbackFor = Exception.class) 来保证整个过程数据的一致性,但其中有一个业务不需要事务保证,所以前人写了一个 try catch 来防止子业务流异常阻断全局事务的提交。简化后大概类似于这样:

@Service
public class ServiceA {

    @Resource
    private ServiceB serviceB;
    @Resource
    private CompanyService companyService;

    /**
     * 模拟消费外部的消息, 调用者A
     */
    @Transactional(rollbackFor = Exception.class)
    public void test(String username) {
        // 1. 首先保存自己的数据, 模拟业务操作
        Company company = new Company();
        company.setName("家里蹲");
        company.setAddress("localhost");
        companyService.save(company);

        // 2. 然后调用Spring的代理方法B
        serviceB.doBusiness(username);
    }
}

@Service
public class ServiceB {

    @Resource
    private UserService userService;

    @Transactional(rollbackFor = Exception.class)
    public void doBusiness(String username) {
        // 3. 这里还有一系列事务操作, 但和这次问题无关, 就不放这里干扰了
        try {
            User user = new User();
            user.setUsername(username);
            user.setAddress("shenzhen");
            // 4. 罪魁祸首的代码, 此处前人为了不干扰主逻辑, catch了异常
            // 为了达到"以为的"异常不干扰外部事务
            userService.saveBatch(Collections.singletonList(user));
        } catch (DuplicateKeyException e) {
            // 5. 此处有日志 log.error("xxx业务异常", e)
            e.printStackTrace();
        }
    }
}

上面的代码看起来平平无奇,按照“常规”理解,代码块4位置的流程异常,不会干扰代码块1、3处的事务提交,达到编写者的目的。

但如果是这么简单,我也不会有这次问题的排查过程,也不会有这篇笔记了...

现象

我们从日志监控注意到,代码块5有频繁的异常,并且代码块1、3的数据最终并未写入成功,通过MySQL追踪事务发现,1、3处的语句执行后,均被rollback了。
这就引出了前言中的问题分析来了,在没有确认问题出现在代码块4时,还在找哪些地方有异常可能造成回滚,但通过跟踪程序日志发现,1、3处被回滚的数据,基本都发生在代码块5的日志打印出来之后,并且程序中也发现了下面的异常:


org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
.......

Transaction rolled back because it has been marked as rollback-only,说明事务被标记为只能回滚,并且根据行号(上述堆栈未标注业务行号)推断出,是发生代码块2处,调用doBusiness方法发生了回滚。

分析

我们都知道,Spring本身没有事务,只有数据库有事务,Spring提供@Transactional以及事务传播机制,只是利用数据库给我们提供的 begin/commit/rollback 来结合 Spring AOP 实现的统一事务管理,通过 PlatformTransactionManager 工具类来实现对事务的管理。(这方面网上优秀的博客太多了,就不细讲了,相信每一个Spring Boy都熟知)

那么,结合对Spring事务管理器和AOP的理解,我们画一张图来理解上面这个“嵌套事务”的过程。

结合流程图来分析,按“常理”理解的话,catchuserService.saveBatch,内部的异常就不会造成外面的回滚,因为异常“被”吞了。

但实则不然,Spring事务管理器采用的是 标记位Savepoint 方法,只要被事务管理器感知到了异常出现,就会将当前线程的事务标记成为rollbackOnly状态,后面的事务再想提交就不行了。这也就导致了ServiceB.doBusiness方法在“想要”提交事务时,发现了事务已经被标记了,就抛出了UnexpectedRollbackException异常,该异常进一步的造成了ServiceA.test方法的代理类检测到了异常,ServiceA.test的代理类也开始处理回滚,进一步造成了整个 Kafka Consumer的事务造成了回滚。

源码

通过搜索“Transaction rolled back because it has been marked as rollback-only”异常,可以很快定位到异常流程在源码中的位置,org.springframework.transaction.support.AbstractPlatformTransactionManager#processRollback
而通过跟踪该方法的调用方,可以很快定位到org.springframework.transaction.support.AbstractPlatformTransactionManager#commit方法,该方法正印证我们上方的 ServiceB.doBusiness想要 commit的时候,defStatus.isGlobalRollbackOnly()检测到当前事务已经被标记了rollbackOnly(org.springframework.transaction.support.ResourceHolderSupport#rollbackOnly)

解决

其实该问题在分析之初,大家并没有定位到是因为userService.saveBatch()上面有Mybatis-Plus加的@Transactional注解干扰到 catch 异常这么顺利,而是都在怀疑:为什么我catch了,还把我上层的事务给回滚了!后面虽然怀疑到userService.saveBatch()的事务注解了,又觉得 try 里面的事务,不会影响到外层(没有想到标记位rollbackOnly这里),后面又想到是不是可以在ServiceB.doBusiness()上调整事务传播机制为Propagation.REQUIRES_NEW,但其实都是徒劳,因为始终都已经被Mybatis-Plus的@Transactional标记为rollbackOnly了。

解决方案:

  1. 调用无事务注解的save()方法
  2. 改用Db.saveBatch方法
  3. 改用异步线程(切换了事务管理器)处理 try 中的流程

后记

下面这篇文章,在我们排查问题的过程中,给了很大的帮助,帮我们确定了问题的方向:
告警:MyBatis-Plus中慎用@Transactional注解,坑的差点被开了...

关于是否可以取消saveBatch方法上事务注解的讨论,可以看Mybatis-Plus官方的issue区:
(3.5.7之后,取消了IService,都使用Mapper方法,彻底解决.......)

  1. 建议取消数据层的saveBatch等方法的@Transactional注解,交由业务层自行管理 #6333
  2. saveBatch下的事务问题

我新建了一个工程,用来复现该问题:
复现定位Spring多层嵌套事务问题