关于MyBatis一级缓存、二级缓存那些事

前言

什么是MyBatis的一级缓存、二级缓存呢,它们的作用又是什么呢?其实很简单,MYBatis的一级缓存和二级缓存的作用都是为了减少数据库查询,对于相同的DQL语句和相同的查询参数复用之前的查询结果。它们之间的区别在于缓存范围。

一级缓存也称为本地缓存,其缓存范围是SqlSession级别,即使用同一个SqlSession进行相同条件的N次查询,实际只会查询数据库一次(注意,不能在这N次查询之间进行增删改,以及调用close方法,这会让缓存失效),默认开启;而二级缓存范围是全局的,也可以简单理解为接口级别(深究的话,如果使用XML,那就是namespace + 查询语句的id,如果使用注解就是接口的全限定名 + 方法名,通过接口名来隔离不同接口中方法名相同问题,说是接口级别也没毛病),默认关闭。

以下是摘录自官方文档中对本地缓存二级缓存的描述(部分)。

Mybatis 使用到了两种缓存:本地缓存(local cache)和二级缓存(second level cache)。
每当一个新 session 被创建,MyBatis 就会创建一个与之相关联的本地缓存。任何在 session 执行过的查询结果都会被保存在本地缓存中,所以,当再次执行参数相同的相同查询时,就不需要实际查询数据库了。本地缓存将会在做出修改、事务提交或回滚,以及关闭 session 时清空。

默认情况下,只启用了本地的会话缓存,它仅仅对一个会话中的数据进行缓存。 要启用全局的二级缓存,只需要在你的 SQL 映射文件中添加一行:<cache/>
基本上就是这样。这个简单语句的效果如下:
1.映射语句文件中的所有 select 语句的结果将会被缓存。
2.映射语句文件中的所有 insert、update 和 delete 语句会刷新缓存。
3.缓存会使用最近最少使用算法(LRU, Least Recently Used)算法来清除不需要的缓存。
4.缓存不会定时进行刷新(也就是说,没有刷新间隔)。
5.缓存会保存列表或对象(无论查询方法返回哪种)的 1024 个引用。
6.缓存会被视为读/写缓存,这意味着获取到的对象并不是共享的,可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。
二级缓存是事务性的。这意味着,当 SqlSession 完成并提交时,或是完成并回滚,但没有执行 flushCache=true 的 insert/delete/update 语句时,缓存会获得更新。

注:@CacheNamespace注解启用二级缓存。下文将从源码角度对MyBatis一级缓存和二级缓存进行分析,对使用方式不做过多讲解,MyBatis在这方面还是很贴心的,有中文版的文档,需要查看的同学请移步官方文档

一级缓存带来的问题

大家可以想一想一级缓存会引发什么问题呢?

先来看下代码:

/**
* 测试MyBatis一级缓存(本地缓存)
* @author 君战
*/
public static void testLocalCache(){
    try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")){
        SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
        SqlSessionFactory sessionFactory = factoryBuilder.build(in);
        try (SqlSession sqlSession = sessionFactory.openSession(true);
             SqlSession sqlSession2 = sessionFactory.openSession(true)){
            TUserMapper userMapper = sqlSession.getMapper(TUserMapper.class);
            TUserMapper userMapper2 = sqlSession2.getMapper(TUserMapper.class);
            // 先删除
            userMapper2.delete(1);
            // 使用userMapper进行第一次查询
            TUserDO tUserDO = userMapper.selectById(1);
            System.out.println("t_user表中不存在id = 1的记录:" + (tUserDO == null));
            tUserDO = new TUserDO();
            tUserDO.setEmail("weibincnwork@163.com");
            tUserDO.setName("君战");
            tUserDO.setId(1);
            // 使用userMapper2插入一条记录
            userMapper2.insert(tUserDO);
            // 使用userMapper进行第二次查询
            tUserDO = userMapper.selectById(1);
            System.out.println("因为使用了本地缓存,所以这次查询也是为空 : " +(tUserDO == null));
        } catch (Exception e){
            throw new RuntimeException(e);
        }
    } catch (IOException ex){
        throw new RuntimeException(ex);
    }
}

控制台打印:

t_user表中不存在id = 1的记录:true
因为使用了本地缓存,所以这次查询也是为空 : true

Process finished with exit code 0

和文档描述的一样,两次从同一个SqlSession中查询到的数据是一样的(如果没有使用缓存,那么第二次查询的数据不会为空,因为调用了userMapper2去插入了ID为1的数据)。

我们都知道数据库有四个隔离级别,分别为读未提交(Read uncommitted)、读已提交(Read committed)、可重复读(Repeatable read)、串行化读(Serializable )。

MySQL的默认隔离级别是可重复读。MyBatis这个优化符合可重复读规则,在一个会话中多次读取到的数据一致。但如果把数据库隔离级别设置为已读提交,我们期望每次都能读到数据库最新提交的记录,却发现在数据库中该条记录明明已经更新,但代码读到的还是之前的版本,如下图所示,一脸懵逼。
在这里插入图片描述
怎么解决这个问题呢?如果是注解 + SQL方式的话,可以在接口方法上添加org.apache.ibatis.annotations.Options注解(该注解只能加在方法上),然后设置其flushCache属性(flushCache = Options.FlushCachePolicy.TRUE)。

@Options(flushCache = Options.FlushCachePolicy.TRUE)
@Select("SELECT * FROM t_user WHERE ID = #{id}")
TUserDO selectById(@Param("id")int id);

或者在MyBatis配置文件中,在属性中添加:

<setting name="localCacheScope" value="STATEMENT"/>

这两者的效果是一样的。

t_user表中不存在id = 1的记录:true
因为使用了本地缓存,所以这次查询也是为空 : false

Process finished with exit code 0

如果是XML方式的话,将标签的flushCache属性设置为true即可。

<select id="selectByExample" flushCache="true">
	<!--your sql-->
</select>

二级缓存带来的问题

前面我们已经说过二级缓存是全局性的,接口级别的。接下来我们就用注解@CacheNamespace启用二级缓存。

/**
* TUserMapper 
* @author 君战
* @since 2021/5/20
*/
@CacheNamespace
public interface TUserMapper {

//    @Options(flushCache = Options.FlushCachePolicy.TRUE)
    @Select("SELECT * FROM t_user WHERE ID = #{id}")
    TUserDO selectById(@Param("id")int id);

    @Insert("INSERT INTO t_user values(#{id},#{email},#{name})")
    int insert(TUserDO userDO);

    @Delete("DELETE FROM t_user WHERE id = #{id}")
    int delete(int id);

}

启动测试代码:

/**
* 测试MyBatis二级缓存 
* @author 君战
*/
private static void testSecondLevelCache(){
    try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")){
        SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
        SqlSessionFactory sessionFactory = factoryBuilder.build(in);
        try (SqlSession sqlSession = sessionFactory.openSession(true);
             SqlSession sqlSession2 = sessionFactory.openSession(true)){
            TUserMapper userMapper = sqlSession.getMapper(TUserMapper.class);
            TUserMapper userMapper2 = sqlSession2.getMapper(TUserMapper.class);

            // 使用userMapper进行第一次查询
            TUserDO tUserDO = userMapper.selectById(1);
            System.out.println(tUserDO);
            sqlSession.close();
            TUserDO tUserDO2 = userMapper2.selectById(1);
            System.out.println(tUserDO2);
        } catch (Exception e){
            throw new RuntimeException(e);
        }
    } catch (IOException ex){
        throw new RuntimeException(ex);
    }
}

查看控制台:
在这里插入图片描述
可以看出第二次查询已经命中缓存。

这会带来什么问题呢?假设现在有一个叫TUserMapper2的接口也会去更新t_user表,这时就出现问题了,因为二级缓存是接口级别的,别的接口更新该条数据,不会去清除TUserMapper的缓存,导致TUserMapper读到的都是旧数据。这也是MyBatis默认关闭二级缓存的一个原因。

一级缓存和二级缓存源码分析

之所以要比这两个缓存实现放到一起分析,是因为它们是紧密相连的。

对于每一个DQL是先查询二级缓存(如果启用),再去查询以及缓存。二级缓存实现源码位于CachingExecutor的query方法中。

// org.apache.ibatis.executor.CachingExecutor#query
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
    throws SQLException {
  Cache cache = ms.getCache();
  // 如果缓存不为空。如果未启用二级缓存,这个判断就不会成立。
  if (cache != null) {
  	// 如果需要刷新缓存
    flushCacheIfRequired(ms);
    // 如果启用缓存并且ResultHandler 为空
    if (ms.isUseCache() && resultHandler == null) {
      ensureNoOutParams(ms, boundSql);
      @SuppressWarnings("unchecked")
      // 去缓存中获取
      List<E> list = (List<E>) tcm.getObject(cache, key);
      if (list == null) {// 如果获取到的值为空
      	// 委派执行。这里使用到的delegate为SimpleExecutor,SimpleExecutor并未实现query方法,调用的是其父类BaseExecutor
      	// 的query方法。其实就是去一级缓存中查询
        list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
        // 将查询结果保存到二级缓存中。
        tcm.putObject(cache, key, list); // issue #578 and #116
      }
      return list;
    }
  }
  // 未启用二级缓存,直接查询
  return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

在这里插入图片描述
这不就是典型的装饰者模式应用。

再来看下BaseExecutor实现的query方法。

// org.apache.ibatis.executor.BaseExecutor#query
@Override
public <E> List<E> 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.");
  }
  // 一级缓存是无法关闭的。但是我们可以通过配置类似@Options(flushCache = Options.FlushCachePolicy.TRUE)这种方式
  // 来强制刷新缓存。如果我们配置了, ms.isFlushCacheRequired()方法就会返回true,然后就会执行clearLocalCache方法。
  if (queryStack == 0 && ms.isFlushCacheRequired()) {
    clearLocalCache();
  }
  List<E> list;
  try {
    queryStack++;
    list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;// 去缓存中查询
    if (list != null) {
      handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    } else {// 缓存获取到的值为空,去数据库查询,那么查询结果是何时保存到一级缓存中的呢?答案就在这个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方法中完成的。

// org.apache.ibatis.executor.BaseExecutor#queryFromDatabase
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
  List<E> list;
  localCache.putObject(key, EXECUTION_PLACEHOLDER);
  try {// doQuery是真正去数据库查询的方法
    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;
}

下图是我根据源码画图的简略流程图,希望能更便于大家理解一级缓存和二级缓存。在这里插入图片描述

总结

通过以上分析,我们可以得知,一级缓存是默认开启的,如果使用的事务隔离级别就是可重复读,其实没什么问题。但如果使用的隔离级别是已提交读,那就需要注意MyBatis的一级缓存,可以通过配置来每次刷新一级缓存或者让一级缓存失效。其实这部分也不用担心,因为现代的大部分应用都是基于Spring + MyBatis,而MyBatis和Spring整合后,MyBatis的一级缓存就失去了效果。

对于一级缓存和二级缓存的源码实现并没有展开分析,只是简单分析了下它们的调用流程,之后我可能会再写一篇博客来详细分析MyBatis的缓存实现。这部分其实挺有意思的,例如缓存类的各种包装,层层嵌套。

附录

DO

@Data
public class TUserDO implements Serializable {

    private int id;
    private String email;
    private String name;
}

DDL

CREATE TABLE `t_user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `email` varchar(64) NOT NULL,
  `name` varchar(32) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_email` (`email`),
  KEY `name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值