前言
事情的起因还要从 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的理解,我们画一张图来理解上面这个“嵌套事务”的过程。
结合流程图来分析,按“常理”理解的话,catch
了 userService.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
了。
解决方案:
- 调用无事务注解的
save()
方法 - 改用
Db.saveBatch
方法 - 改用异步线程(切换了事务管理器)处理 try 中的流程
后记
下面这篇文章,在我们排查问题的过程中,给了很大的帮助,帮我们确定了问题的方向:
告警:MyBatis-Plus中慎用@Transactional注解,坑的差点被开了...
关于是否可以取消saveBatch
方法上事务注解的讨论,可以看Mybatis-Plus官方的issue区:
(3.5.7之后,取消了IService,都使用Mapper方法,彻底解决.......)
我新建了一个工程,用来复现该问题:
复现定位Spring多层嵌套事务问题