多线程事务在Junit、Mybatis中使用

前言

最近发现项目中跑测试耗时较长,200多个测试需要十几分钟分钟,影响部署和使用,决定定位优化下。看了下代码,因为项目使用了flyway,在跑测试时,为了防止跑测试过程中对数据有修改,影响后面的测试,对有更新操作的测试在测试结束后利用flyway重建数据库,重建过程中既有数据的操作,又有表结构的操作,跑测试过程中对表结构其实是没有影响的,数据修改也不会影响其他表,所以中间做了很多没必要的操作。

因此决定不用flyway,采用事务回滚的方式,每次跑完测试,自动回滚当前测试对数据库的修改内容,保证测试之间不相互影响。具体做法是测试类加上@Transactional注解,或者测试类继承AbstractTransactionalJUnit4SpringContextTests类。

事务竟然无法回滚

然而,理想很丰满现实很骨感,当我加上@Transactional注解时,跑完测试并没有回滚。因为我的测试是基于接口的测试,使用了Moscow测试框架,在跑测试的时候会请求Controller的接口,这时候会使用新的线程去执行接口的代码(这块不清楚没关系,后面会写一篇微服务架构下使用Moscow和wiremock进行接口测试),特地在测试代码和Controller代码中打印了当前线程ID,结果如下图

因为事务控制必须在同一个连接内,不同的线程获取的不一定是同一个连接,所以在Controller中对数据的操作是无法回滚的(如果是单元测试,在测试代码中操作数据库是可以回滚的),为了能把所有操作都回滚,那就要保证在这两个线程中获取的是同一个数据库连接。

多线程下如何获取同一个数据库连接

因为项目中使用了mybatis框架,可以利用mybatis拦截器,在每次操作数据库获取连接时,将连接替换成同一个连接,这样即使是不同的线程都是同一个连接。下面上相关代码

首先是测试类

/**
 * 测试基类,后续所有测试都继承该测试类,做一些通用的处理逻辑
 */
@RunWith(SpringRunner.class)
@SpringBootTest(classes = PandoraApplication.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
//加这个属性是标识是跑测试,后面拦截器有使用到
        properties = {"isRollback=true"})
@Slf4j
public abstract class MyBaseTest extends AbstractTransactionalJUnit4SpringContextTests {
    
    @Autowired
    private DataSource dataSource;
    
    /**
     * 跑每个测试之前,设置要用到的连接
     */
    @Before
    public void setUp() {
        Connection connection = DataSourceUtils.getConnection(this.dataSource);
        CurrentConnectionHolder.setConnection(connection);
    }
    
    /**
     * 跑完测试之后清除当前测试的连接
     */
    @After
    public void destroy() {
        CurrentConnectionHolder.clear();
    }
    
}
/**
 * 保存一个全局的数据库链接, 并不是放在ThreadLocal中,只是static修饰的
 */
public class CurrentConnectionHolder {

    private static Connection connection = null;

    public static Connection getConnection() {
        return connection;
    }

    public static void setConnection(Connection connection) {
        CurrentConnectionHolder.connection = connection;
    }

    public static void clear() {
        CurrentConnectionHolder.connection = null;
    }

}
/**
 * mybatis拦截器类,只有在本地测试有isRollback属性时才使用,其他开发、测试、线上环境都不用
 * 拦截StatementHandler的prepare方法
 */
@Component
@ConditionalOnProperty(name = "isRollback", havingValue = "true")
@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class ConnectionInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //执行prepare方法之前,如果当前有设置连接,就使用当前的数据库连接,如果没有就无需操作
        Connection connection = CurrentConnectionHolder.getConnection();
        if (Objects.nonNull(connection)) {

            Method method = invocation.getMethod();
            Object target = invocation.getTarget();
            return method.invoke(target, connection, 50000);
        } else {
            return invocation.proceed();
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }
}

这样就可以多线程下控制事务,进行回滚,在测试代码中对修改的数据进行查询,也是能查询出来的,测试跑完去数据库查看数据是查询不到的,至此多线程下事务回滚就完成了。

原理就是mybatis拦截器替换连接,简单说下拦截器是如何起作用的,如下图

在执行BaseStatementHandler的prepare方式时,会先执行我们定义的拦截器,拦截器中有这段代码:method.invoke(target, connection, 50000); 也就是说调用prepare方法时,传进去的Connection是我们自己的,并不是拦截器前面获取的连接,正常获取的数据库连接是和线程相关的,不符合当前场景的要求。

数据库操作日志无法显示

多线程事务回滚的问题解决完,在跑测试的时候发现SQL的操作日志都没有了,像带预处理的SQL、SQL具体参数、更新记录数等都没法显示,如下图

没有日志可能会影响测试的调试。一种方法是继续利用拦截器打印出要执行的SQL和需要的信息,但是感觉还是有点复杂,总觉得可以更简单一点,继续往下看。

mybatis正常打印日志流程

因为项目使用的是lombok的slf4j打印日志,而且mybatis打印日志使用的都是org.apache.ibatis.logging.Log类型的实例,直接在Slf4jImpl类的void debug(String s)打上断点,并把拦截器去掉,看正常情况下mybatis是如何打印日志的。

可以看到86行调用了connection的prepareStatement方法,而且注意传进来的Connection的参数是一个代理类,我们点到invoke方法里面

在调用connection的prepareStatement方法时,这里其实是调用了ConnectionLogger的invoke,invoke的第53行就是调用Log的实现类去打印日志。这种情况下是日志能正常打印。

加了拦截器后日志打印流程

加了拦截器以后可以看到传进来的Connection参数是HikariProxyConnection,这样的话86行调用connection.prepareStatement方法就是调用HikariProxyConnection类的prepareStatement方法,这个方法就没有打印mybatis日志相关的逻辑。

到这日志无法打印的原因就找到了,也就是在跑测试设置连接前,连接不能随便拿,而是要用ConnectionLogger代理出来的对象,这样才能进到ConnectionLogger的invoke方法,调用日志实现类打印日志。

日志可以打印了

原因找到了,继续顺着堆栈往上找,看传到prepare方法的Connection是怎么来的,在SimpleExecutor类里面看到有个getConnection方法,如下图

点进去,代码如下

protected Connection getConnection(Log statementLog) throws SQLException {
    Connection connection = transaction.getConnection();
    if (statementLog.isDebugEnabled()) {
        return ConnectionLogger.newInstance(connection, statementLog, queryStack);
    } else {
        return connection;
    }
}

return那句直接复制粘贴,拿来吧你,放到@Before方法里面,这里面还有一个Log参数怎么获取的问题,我直接顺着源码往上找,继续复制粘贴,@Before方法修改后的完整代码如下

@Before
public void setUp() {
    // sqlSessionFactory直接是@Autowired的
    Configuration configuration = SqlSessionUtils.getSqlSession(sqlSessionFactory).getConfiguration();
    /**
     * 源码里面是通过statement拿的,因为日志实现类可以对每个Mapper设置日志实现类,因为我项目没有特殊设置,都是一样的,
     * 这里我随便用了Mapper的statement, 读者可以使用configuration看下有没有其他方式获取Log的实例
     * 获取连接还是和之前一样,这里只是将连接和Log实例封装在一起
     */
    MappedStatement mappedStatement =
        configuration.getMappedStatement("com.pandora.domain.holiday.mapper.TradeDayMapper.isWorkDay");
    Log log = mappedStatement.getStatementLog();
    Connection connection = ConnectionLogger.newInstance(DataSourceUtils.getConnection(this.dataSource), log, 1);
    CurrentConnectionHolder.setConnection(connection);
}

用最新代码跑下测试,SQL日志又回来啦!跑完测试数据库是没有数据的

总结

  • 经过修改,测试时间从十几分钟缩短到了5分钟左右,最短有时候3分钟就可以了,代码提交完不用等十几分钟才能部署完生效了。

  • 重点是多线程下使用同一个事务,基于拦截器这个思路可以应用到其他场景,比如异步测试需要保持一致性。

  • 日志不打印的问题其实影响不大,只是跑测试没有SQL日志,通过这个问题了解到了mybatis日志打印的原理。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AE86Jag

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值