背景
12月的某一天,生产环境出现大量锁等超时导致某个业务出现了问题,我们当天进行了回滚,并进行了数据的修复,问题比较诡异,我花了较多时间进行排查,报错提示如下
Cause: java.sql.BatchUpdateException: Lock wait timeout exceeded; try restarting transaction
; SQL []; Lock wait timeout exceeded; try restarting transaction; nested exception is java.sql.BatchUpdateException: Lock wait timeout exceeded; try restarting transaction
at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:259)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:73)
at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:88)
at com.baomidou.mybatisplus.extension.toolkit.SqlHelper.executeBatch(SqlHelper.java:185)
at
排查过程
根据此次上线merge的代码一步步缩小问题范围,将业务代码去掉后,确定是以下写法导致的问题,这个过程非常需要耐心
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void test() {
log.info("test, {}", System.currentTimeMillis());
// 发送事件,也是事务提交后执行业务代码
publisher.publishEvent(new UserListener("222"));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 事务提交后执行下面的代码
userDao.list();
}
});
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void listener(UserListener event) throws Exception {
log.info("userListener");
iUserService.saveUser();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void saveUser() {
User user = new User();
user.setId(1L);
user.setUsername("222");
userMapper.insert(user);
User byId = userMapper.selectById(1L);
SqlHelper.executeBatch(User.class, this.log, sqlSession -> {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
byId.setUsername("3333");
mapper.updateById(byId);
});
}
单纯看这段代码,是毫无头绪,因为问题都隐藏在框架源码中,在DBA大佬的协助排查下,从数据库的角度看到的现象是
A线程写了数据之后,提交前,会有一个表级的意向排他锁(IX)
B线程去select,如果不是指定了当前读(比如select ... for update),默认是共享读,不需要加任何锁,是读不到数据
但update不一样,update默认是当前读,需要加X锁,X锁跟任何锁都冲突,所以只能等
结合以上的代码,猜测是有两个session,其中一个session进行insert,另一个session拿上一个session insert未提交的数据进行update,导致了锁等
验证猜想
不是同一个sqlSession
根据堆栈,先查看 userMapper.insert(user) 的sqlSession
再查看 mapper.updateById(byId); 的sqlSession
很明显,不是同一个session
不是同一个connection
我们都知道,对数据库进行事务操作必须先获取数据库连接,那一个数据库连接可以有多个sqlSession,然后Spring对多个sqlSession进行事务控制吗?
问了一下Copilot,是可以的,当然Copilot不一定是对的,但是我们结合代码中的 @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) ,我们可以大胆假设数据库连接也不是同一个,要证明这个问题,只能进行代码的debug了
根据上面的代码进行debug
图一:public void test() {} 方法
图二:public void saveUser() {} 方法
图三:public void saveUser() {} 方法中的updateById();
创建数据库连接的代码位置在
org.springframework.transaction.support.AbstractPlatformTransactionManager#getTransaction org.springframework.transaction.support.AbstractPlatformTransactionManager#handleExistingTransaction org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
可以看到,因为saveUser()方法中新开了一个事务,所以框架新建了一个数据库连接,而批处理的sqlSession是基于新的数据库连接来创建了,所以就出问题,总结一下就是
- 在test()方法中新建了数据库连接A,test()方法中无sql操作,无sqlSession创建
- 进入afterCommit()方法后,沿用数据库连接A, 有sql操作 ,新建sqlSessionA
- 进入saveUser()方法后,新建数据库连接B,insert()与selectById()沿用sqlSessionA
- 进入collect()方法中的updateById() 方法,基于数据库连接B新建sqlSessionB,sqlSessionB拿sqlSessionA查询出来的数据进行更新,但sqlSessionA未提交,导致锁等问题产生
为什么sqlSessionA会在新事务中进行沿用
问题的原因清晰之后,产生一个新问题,理论上新事务中的方法应该是采用新的sqlSession,为什么会使用了上一个事务中的session,对于这个问题,毫无头绪,所以我稍微修改了一下代码,正常代码和异常代码,看看源码的流程有什么区别,下面是正常的代码
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void test() {
log.info("test, {}", System.currentTimeMillis());
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 事务提交后执行下面的代码
userDao.list();
}
});
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 事务提交后执行下面的代码
log.info("collect");
userService.saveUser();
}
});
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void listener(UserListener event) throws Exception {
log.info("userListener");
iUserService.saveUser();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void saveUser() {
User user = new User();
user.setId(1L);
user.setUsername("222");
userMapper.insert(user);
User byId = userMapper.selectById(1L);
SqlHelper.executeBatch(User.class, this.log, sqlSession -> {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
byId.setUsername("3333");
mapper.updateById(byId);
});
}
最终debug出来,在挂起旧事务相关资源这里有点区别,propagation = Propagation.REQUIRES_NEW 的原理是将旧事务挂起,创建新事务,代码路径为
org.springframework.transaction.support.AbstractPlatformTransactionManager#getTransaction
org.springframework.transaction.support.AbstractPlatformTransactionManager#handleExistingTransaction
org.springframework.transaction.support.AbstractPlatformTransactionManager#suspend
这里有个判断,如果TransactionSynchronizationManager.isSynchronizationActive()为true,在挂起旧事务的同时还是挂起旧session,这样session就不会沿用到新的事务中,但是TransactionSynchronizationManager.isSynchronizationActive()为false
false的原因是triggerAfterCompletion这个阶段,Spring事务框架把所有的事务同步器清空了
为什么框架要这样写代码呢?问了一下Copilot,我理解就是防止代码重复执行
但是这个解释还是不能使我信服,于是我再次修改代码,使用框架中推荐的propagation = Propagation.* REQUIRES_NEW* 的形式对代码进行debug
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void test() {
log.info("test, {}", System.currentTimeMillis());
// 发送事件,也是事务提交后执行业务代码
publisher.publishEvent(new UserListener("222"));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 事务提交后执行下面的代码
SpringContextUtil.getBean(IUserService.class).test2();
}
});
}
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void test2() {
log.info("list");
userDao.list();
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void listener(UserListener event) throws Exception {
log.info("userListener");
iUserService.saveUser();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void saveUser() {
User user = new User();
user.setId(1L);
user.setUsername("222");
userMapper.insert(user);
User byId = userMapper.selectById(1L);
SqlHelper.executeBatch(User.class, this.log, sqlSession -> {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
byId.setUsername("3333");
mapper.updateById(byId);
});
}
debug到这里的时候,我认为已经找到了问题的答案,在事务提交后,session是会在afterCompletion的方法中close掉的,没有close掉是因为这个事务没有结束,总结一下就是
- 在test()方法中新建了事务A,并且在这个事务中注册afterCommit()和afterCompletion()两个同步器,afterCompletion()是开启了新事务的
- 进入afterCommit()方法后,因为没有新开事务,所以沿用了事务A,执行过程中新建了sqlSessionA
- 框架本来是在afterCompletion()中关闭sqlSessionA, 岂料我们也注册了一个,并且先执行了,进入saveUser()方法后,新建事务B,insert()与findById()沿用sqlSessionA
- 进入saveUser()方法中的updateByIdBatch()方法,基于数据库连接B新建sqlSessionB,sqlSessionB拿sqlSessionA查询出来的数据进行更新,但sqlSessionA未提交,导致锁等问题产生
解决方案
- 使用afterCommit()开启新事务,这是框架注解中的建议,见下图
- 因为afterCommit()是同步执行的,如果代码逻辑较长,会长期占用数据库连接,导致连接过多风险,所以有大佬建立使用异步进行处理