深入剖析Mybatis中一级缓存线程不安全的原因


思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。

作者:毅航😜


在专栏文章Mybatis流程分析(三):构建SqlSession实现Mybatis的会话管理中我们对SqlSession进行了详细的分析,不了解的SqlSession相关知识的可翻看往期文章~~

事实上,如果你有留心看过Mybatis中有关SqlSession实现类的的代码的话,你会看到在DefaultSqlSession类的注释中有这样一段话,即 DefaultSqlSession this class is not Thread-Safe

换句话说,在Mybatis内部对于SqlSession的默认实现DefaultSqlSession而言,其在使用过程中存在线程安全问题的 。今天我们便来对DefaultSqlSession线程不安全的原因进行一次深入的探究,希望文章能有助于你理解MybatisDefaultSqlSession的理解。

前言

当在Java中谈论线程安全时,通常指的是 "多线程环境下的代码或数据结构是否能够在并发执行时保持正确的行为,而不会导致不确定的结果或数据损坏" 。同时,线程安全还是多线程编程中的一个关键概念,其核心目的是确保在多个线程同时访问共享资源时,不会出现竞态条件、数据不一致或其他并发问题。

为了实现线程安全的目标,通常可使用Synchronized、final等关键字的来进一步确保资源状态的一致性,或者通过并发类容器来确保数据在并发操作时的有序性,从而避免因竞态条件的发生而导致数据的不一致性。

虽然Java中提供了很多行之有效的方式来保证多线程环境下数据访问的一致性, 但是如果你有深入了解过DefaultSqlSession的话,你会发现在DefaultSqlSession内部其实并没使用任何行之有效的手段来确保数据资源访问的一致性,因此其才会在类上给出 DefaultSqlSession this class is not Thread-Safe的提示。

注:当然确保线程安全也不一定非要使用JUC所提供的工具类,完全可以自己实现,只不过比较繁琐而已~

说了这么多,你可能会觉得,你说线程不安就不安全吗?怎么我使用的时候怎么没发现Mybatis有线程不安全的问题?为了消除你心中的疑惑,我们来通过下面这个例子来直观的感受下MybatisSqlSession线程不安全性。

复现Mybatis的线程不安全问题

为了更深刻的理解MybatisSqlSession的线程不安全性,我们将通过入一下这段简单的代码来快速模拟MybatisSqlSession的线程不安全性,并在此基础上再深入剖析导致SqlSession线程不安全的原因。

但在开始之前我们有必要对用到的数据库表进行一个简单的介绍,其大致内容如下:

可以看到测试所用到的数据表也非常简单,其中主要会包括user_name、age、id三个字段。且在初始时表中一共有三条数据。我们的测试代码如下所示:

java 复制代码
public void test() throws IOException {

  
        // 省略配置文件读取细节......
        
        // 线程1
        Thread thread1 = new Thread(() -> {
            // 线程1中尝试向数据库插入一条数据
             userMapper.insert("Yi_hang");
             try {
                Thread.sleep(1000);
             } catch (InterruptedException e) {
                    throw new RuntimeException(e);
           }
        
          // 查询所有
          List<User> users = userMapper.selectUsers();
          log.info("Thread1 User size is [{}] " , users.size());
      });

       // 线程2
       Thread thread2 = new Thread(() -> {
           // 线程2中尝试执行数据库操作
           List<User> users = userMapper.selectUsers();
           log.info("Thread2 User size is [{}] " , users.size());

      });

      thread1.start();
      thread2.start();

      thread1.join();
      thread2.join();
  
}

在上述实例代码中,我们分别启动了两个线程线程1线程2。其中线程1的内部主要会完成两件操作,即会首先向数据库和中插入一条数据,然后休眠1s后再进行查询。而线程2则会直接查询数据库。 执行上述代码后,得到的日志如下所示:

log 复制代码
UserMapper.insert - ==>  Preparing: insert into t_user values(?,1,1)
UserMapper.selectUsers - ==> Parameters
UserMapper.insert - ==> Parameters: Yi_hang(String)
UserMapper.insert - <==    Updates:1 
UserMapper.selectUsers - <==      Total:3 
MybatisTest - Thread2 User size is [3]
MybatisTest - Thread1 User size is [3] 

不难发现,由于我们的初始数据有3条,因此对于线程2输出的User size is [3],这是完全没问题的。与此同时我们也注意到,对于线程1而言,其内部的数据插入insert语句已经执行成功了,但是查询用户数据时其返回的结果依旧为3

对于线程1打印出Thread1 User size is [3]这样合理吗?当然不合理!

因为通过分析打印的日志可以发现,对于Mybatis内部其实其已经将相关的数据进行了插入,并且也已经插入成功。但即使是在数据插入成功的这一大前提下,线程1在查询数据时,其查询到的却是未插入数据之前的总量信息。

那究竟是什么导致这一问题的发生呢?带着这一的问题我们进入到Mybatis源码内部进行剖析。

剖析Mybatis的线程不安全性

在之前的文章我们曾分析过在Mybatis内部中执行sql的任务通常会交给Executor来进行处理,具体来说,所有的执行器都会继承自BaseExecutor这个父类。而在BaseExecutor中,有关查询的逻辑则主要委托于query方法来进行执行。这部分源码如下:

java 复制代码
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)
    throws SQLException {
  BoundSql boundSql = ms.getBoundSql(parameter);
  CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
  return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
    CacheKey key, BoundSql boundSql) throws SQLException {
   
  // 省略无关代码......
  List<E> list;
  try {
    queryStack++;
    // <1> 尝试从缓存中获取信息
    list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
    if (list != null) {
      handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    } else {
     // <2> 当缓存中数据不存在时尝试查询数据库
      list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
  } 
   // 省略无关代码......
  return list;
}

不难发现,其实在query方法中的两个核心方法即queryFromDatabaselocalCache.getObject(key)

明确了待分析重点后,我们首先先来分析 queryFromDatabase的内部逻辑。其大致逻辑如下:

java 复制代码
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    // 存入站位符信息 (此处主要为了解决Mybatis)内部循环依赖的问题
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      localCache.removeObject(key);
    }
    // 数据放入缓存
    localCache.putObject(key, list);
   
    return list;
  }

不难发现,在queryFromDatabase内部主要完成了如下两步操作:

  1. 执行sql语句完成对数据库的查询。
  2. 将查询到的数据返回,同时保留一份存入localCache中,方便下次使用。

而这里的localCache其实就是就是我们常谈及到的一级缓存,通常Mybatis内部会使用PerpetualCache来进行缓存。而PerpetualCache的内部也很简单,就是通过一个HashMap来将相关的结果进行缓存处理。谈及到HashMap的结构,其本身就是key-value的形式。

具体到此处,我们知道其中的value即为我们从数据中的查询的数据信息,而key的确定则是通过 createCacheKey(ms, parameter, rowBounds, boundSql);来确定的。通过如参信息,不难发现对于Mybatis中缓存key的确定,主要受如下几点的影响:

  1. 同一个SqlSession会话
  2. Sql语句相同,参数相同
  3. 相同的StatementID
  4. RowBounds相同 (默认会相同)

注:这便是一级缓存在构造key的关键影响因素

明白了queryFromDatabase中的逻辑,再回看 query中的localCache.getObject(key)方法,是不是也就明白了其get方法所取内容的设定时机,即 每当sql第一次执行时,Mybatis内部会将相关的执行结果存入到一个Map中,以备下次使用。其中key 通过 上述的 4条因素影响,value则是sql语句本次执行的结果。

明白了sqlSession内部执行sql时缓存的原理后,我们再来看之前测试代码查询出错的原因:

  1. 首先,对于线程2而言,其使用select * from t_user 进行查询。由于数据中最开始只有3条数据,因此其查询出3条数据。同时Mybatis内部会对其执行结果进行缓存。
  2. 进一步,对于线程1而言,其首先会首先插入一条数据,然后再去查询数据总量。当其执行查询操作时,由于其与线程2查询sql一致、且共用一个sqlSession所以其在执行createCacheKey(ms, parameter, rowBounds, boundSql)计算得到 key线程2查询时构建的缓存Key一致,所以其在查询时并不会去数据库中查询,而是直接取出缓存中的内容[3], 这样就导致了线程2的缓存,进而也就导致了线程1查询结果的不正确。

明确了问题发生的原因后,那有没解决之道呢?当然是有的!可以通过sqlsessionclearCache()方法,来每次执行sql完毕后清理掉缓存的内容,这样就可以避免缓存所引起的线程安全问题!

总结

其实对于Mybatis一级缓存所引起的线程安全问题,可以说是面试八股文经常提到的一个点。笔者也相信当你在面试中遇到这类问题时,你可以脱口而出答案。但是如果我继续追问,你是如何断定一级缓存所导致这个问题的呢?面对这一问题阁下又该如何应对呢?

注:笔者并不反对面试背八股文,请理性看待问题~

最后,如果觉得文章对你理解Mybatis有所帮助的话,不妨点赞+收藏。当然如果觉得笔者文章还可以不妨加个关注~~~

相关推荐
救救孩子把9 分钟前
深入理解 Java 对象的内存布局
java
落落落sss11 分钟前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
万物皆字节17 分钟前
maven指定模块快速打包idea插件Quick Maven Package
java
夜雨翦春韭23 分钟前
【代码随想录Day30】贪心算法Part04
java·数据结构·算法·leetcode·贪心算法
我行我素,向往自由30 分钟前
速成java记录(上)
java·速成
一直学习永不止步36 分钟前
LeetCode题练习与总结:H 指数--274
java·数据结构·算法·leetcode·数组·排序·计数排序
邵泽明36 分钟前
面试知识储备-多线程
java·面试·职场和发展
Yvemil71 小时前
MQ 架构设计原理与消息中间件详解(二)
开发语言·后端·ruby
程序员是干活的1 小时前
私家车开车回家过节会发生什么事情
java·开发语言·软件构建·1024程序员节
煸橙干儿~~1 小时前
分析JS Crash(进程崩溃)
java·前端·javascript