一次afterCommit()导致的锁等问题排查

405 阅读5分钟

背景

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

image.png

再查看 mapper.updateById(byId); 的sqlSession

image.png

很明显,不是同一个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

image.png

image.png

image.png

可以看到,因为saveUser()方法中新开了一个事务,所以框架新建了一个数据库连接,而批处理的sqlSession是基于新的数据库连接来创建了,所以就出问题,总结一下就是

  1. 在test()方法中新建了数据库连接A,test()方法中无sql操作,无sqlSession创建
  2. 进入afterCommit()方法后,沿用数据库连接A, 有sql操作 ,新建sqlSessionA
  3. 进入saveUser()方法后,新建数据库连接B,insert()与selectById()沿用sqlSessionA
  4. 进入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

image.png

这里有个判断,如果TransactionSynchronizationManager.isSynchronizationActive()为true,在挂起旧事务的同时还是挂起旧session,这样session就不会沿用到新的事务中,但是TransactionSynchronizationManager.isSynchronizationActive()为false

image.png

false的原因是triggerAfterCompletion这个阶段,Spring事务框架把所有的事务同步器清空了

image.png

为什么框架要这样写代码呢?问了一下Copilot,我理解就是防止代码重复执行

image.png

但是这个解释还是不能使我信服,于是我再次修改代码,使用框架中推荐的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);
    });
}

image.png

debug到这里的时候,我认为已经找到了问题的答案,在事务提交后,session是会在afterCompletion的方法中close掉的,没有close掉是因为这个事务没有结束,总结一下就是

  1. 在test()方法中新建了事务A,并且在这个事务中注册afterCommit()和afterCompletion()两个同步器,afterCompletion()是开启了新事务的
  2. 进入afterCommit()方法后,因为没有新开事务,所以沿用了事务A,执行过程中新建了sqlSessionA
  3. 框架本来是在afterCompletion()中关闭sqlSessionA, 岂料我们也注册了一个,并且先执行了,进入saveUser()方法后,新建事务B,insert()与findById()沿用sqlSessionA
  4. 进入saveUser()方法中的updateByIdBatch()方法,基于数据库连接B新建sqlSessionB,sqlSessionB拿sqlSessionA查询出来的数据进行更新,但sqlSessionA未提交,导致锁等问题产生

解决方案

  • 使用afterCommit()开启新事务,这是框架注解中的建议,见下图

image.png

  • 因为afterCommit()是同步执行的,如果代码逻辑较长,会长期占用数据库连接,导致连接过多风险,所以有大佬建立使用异步进行处理