mybatis中SqlSession的线程安全性讨论

 前言

        对绝大数Java开发者而言mybatis并不陌生,从经典的SSM(Spring,spring-mvc,mybatis)框架,到现在流行的Springboot,随处可见mybatis的身影。mybatis作为比较主流的orm框架,支持用户定制sql,灵活又方便,颇受开发者喜爱。我们在使用mybatis难免会遇到各种坑,其中SqlSession的线程安全性问题也总会遇到。

       SqlSession作为一个接口,其并没有线程安全性的问题,我们常说的线程安全问题是SqlSession的一个实现类DefaultSqlSession,mybatis的作者也对此类加以"Note that this class is not Thread-Safe"的注释。此外SqlSession还有两个实现类SqlSessionManagerSqlSessionTemplate,这两个实现类是线程安全的。

线程不安全的DefaultSqlSession

        我们都知道DefaultSqlSession是线程不安全的,也会有很多博主讲解"SqlSessionTemplate是如何保证DefaultSqlSession线程安全的",但是DefaultSqlSession不安全的体现是什么?不安全产生的原因在哪?今天作者通过一个例子给读者演示下并发情况下DefaultSqlSession线程不安全的表现,以及源码追踪产生线程安全问题的源头。

样例代码如下:

  •  配置文件mybatis-config.xml,简单配置保证能正常运行

    
    
    
        
            
            
                
                
                
                
            
        
    
    
        
    	
    
  • mapper映射文件



    
        
        
        
        
        
    

    
  • mapper接口
public interface StudentMapper {
    List getStudents();
}
  • 实体类
@Getter
@Setter
@ToString
public class Student {
    private Integer stdId;
    private String stdName;
    private Date stdBirth;
    private int stdGrade;
    private int stdSex;
}
  • 测试类
public class MybatisApp {

    private static final int COUNT = 10;
    // 使用CountDownLatch 来模拟并发,并发量10个
    private static CountDownLatch cdl = new CountDownLatch(COUNT);
    private SqlSession sqlSession;

    //初始工作,用于初始化sqlSession此处获得是DefaultSqlSession的实例
    @Before
    public void init() {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        InputStream inputStream = loader.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder()
                                                    .build(inputStream);
        sqlSession = sqlSessionFactory.openSession();
    }

    @Test
    public void testThreadSafe() throws InterruptedException {
        for (int i = 0; i < COUNT; i++) {
            new Thread(() -> {
                try {
                    cdl.await();
                } catch (Exception e) {
                    e.printStackTrace();
                }
                // 调用查询方法
                getStudents();
            }).start();
            cdl.countDown();
        }

        Thread.sleep(5000);
    }
    
    public void getStudents() {
        // 使用statementId方法调用
        String statementId = "idin.sun.study.mapper.StudentMapper.getStudents";
        List students = sqlSession.selectList(statementId);
    }
}

运行testThreadSafe()方法,控制台输出如下内容

Exception in thread "Thread-8" Exception in thread "Thread-4" org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.lang.ClassCastException: org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List
### The error may exist in idin/sun/study/mapper/StudentMapper.xml
### The error may involve idin.sun.study.mapper.StudentMapper.getStudents
### The error occurred while executing a query
### Cause: java.lang.ClassCastException: org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:150)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:141)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:136)
	at app.MybatisApp.getStudents1(MybatisApp.java:133)
	at app.MybatisApp.lambda$testThreadSafe$0(MybatisApp.java:99)
	at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.ClassCastException: org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List
	at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:152)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:109)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:83)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:148)
	... 5 more
org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.lang.ClassCastException: org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List
### The error may exist in idin/sun/study/mapper/StudentMapper.xml
### The error may involve idin.sun.study.mapper.StudentMapper.getStudents
### The error occurred while executing a query
### Cause: java.lang.ClassCastException: org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:150)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:141)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:136)
	at app.MybatisApp.getStudents1(MybatisApp.java:133)
	at app.MybatisApp.lambda$testThreadSafe$0(MybatisApp.java:99)
	at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.ClassCastException: org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List
	at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:152)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:109)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:83)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:148)
	... 5 more

        通过控制台的输出报错,说明在并发情况下使用同一个DefaultSqlSession的实例做查询是有问题的(读者可以尝试编写增加、删除、修改方法的并发),抛出的异常是类型转换异常(java.lang.ClassCastException:org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List),从上述报错信息来看报错发生在BaseExecutor类中第152行,作者将该涉及这行的方法全粘贴过来,一起研究下:

///方法来自类
@SuppressWarnings("unchecked")
@Override
public  List query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    if (closed) {
        throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    List list;
    try {
        queryStack++;
        // 下面这行代码即是源码中的第152行
        list = resultHandler == null ? (List) localCache.getObject(key) : null;
        if (list != null) {
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            // 调用queryFromDatabase,queryFromDatabase方法见下文
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    } finally {
        queryStack--;
    }
    if (queryStack == 0) {
        for (DeferredLoad deferredLoad : deferredLoads) {
            deferredLoad.load();
        }
        // issue #601
        deferredLoads.clear();
        if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            // issue #482
            clearLocalCache();
        }
    }
    return list;
}
// queryFromDatabase方法
private  List queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List list;
    // 缓存值
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
        list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
        localCache.removeObject(key);
    }
    localCache.putObject(key, list);
    if (ms.getStatementType() == StatementType.CALLABLE) {
        localOutputParameterCache.putObject(key, parameter);
    }
    return list;
}

    BaseExecutor类中第152行,是一个三元表达式,用于判断resultHandler 是否是null,这里给出结论:此处的resultHandler =null(作者的测试用例中使用selectList方法,而selectList方法传入的resultHandler就是个null,读者可翻阅mybatis源码验证 ),所以三元表单式会成为:list = (List) localCache.getObject(key) ,这里出现了强制类型装换,说明问题出在localCache取得值。

这里的localCache是我们常说的mybatis一级缓存,其原理作者在此不讲,也是给出结论:

localCache的key值生成策略,与查询方法、参数等有关,完全一样的查询,生成的key是一样的。读者可以查看源码

BaseExecutor#createCacheKey

        查询时,由于第一次查询是不存在缓存的,此时"list = (List) localCache.getObject(key) "中"list=null",代码会进入" list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);",在queryFromDatabase方法中第二句代码"localCache.putObject(key, EXECUTION_PLACEHOLDER)",先给localCache存了一个EXECUTION_PLACEHOLDER,而EXECUTION_PLACEHOLDER是枚举类ExecutionPlaceholder的一个枚举项。由于BaseExecutor中的方法都不是同步方法,在并发的情况下,就会出现这样的场景:

        Thread1进入query方法,用key取缓存localCache数据不存在,则进入了queryFromDatabase,并执行了"localCache.putObject(key, EXECUTION_PLACEHOLDER)",而此时Thread2进入了query方法,用key取缓存localCache数据,此时取出来的是Thread1刚缓存的EXECUTION_PLACEHOLDER,然后执行类型转换,由于EXECUTION_PLACEHOLDER不是list类型,所以转换抛出异常(java.lang.ClassCastException:org.apache.ibatis.executor.ExecutionPlaceholder cannot be cast to java.util.List)。 

       通过上述场景,读者应该明白了吧,是BaseExecutor中缓存机制(mybatis的一级缓存)导致了并发问题。这种并发问题,产生原因:并发操作使用了同一个DefaultSqlSession的实例,而同一个DefaultSqlSession的实例使用的是同一个Executor对象,当缓存命中时就会出现异常或者数据不完整的情况。

总结

         关于SqlSessionTemplate如何保证线程安全性的博文太多,而且很多博主讲的都特别详细,作者不再老生常谈。我们已经知道DefaultSqlSession线程安全问题的产生原因,故避免线程安全问题就得避免多个线程并发使用同一个DefaultSqlSession的实例。SqlSessionTemplate中也是这么做的,这也是SqlSessionTemplate中一级缓存失效的原因,因为一级缓存是基于同一个DefaultSqlSession实例实现的。

"知其然知其所以然",我们在学习工作中,不仅要知道结果,还要知道解决办法,更要知道产生的原因。追源溯本,融会贯通,从一个知识点可以衍生出很多知识!

 

你可能感兴趣的:(mybatis)