精通Mybatis之缓存体系

前言

之前小编写了mybatis中的执行器,今天来讲一下mybatis的缓存,大家都知道mybatis有二级缓存,一级缓存是默认开启的,而二级缓存是可以配置的,其实如果看完小编上次的执行器,大家可以知道,一级缓存是在BaseExecutor中实现的,而二级缓存是在CachingExecutor中,二级缓存开启可以配置在xml中也可以在接口上加入@CacheNamespace注解,不了解的小伙伴可以看精通Mybatis之Executor执行器这篇文章。那我们接下来详细讲解一下mybatis的一级和二级缓存,他们的命中场景,源码分析,和spring集成时缓存失效的原因等。进入正题。

一级缓存

这次小编先写结论然后通过代码示例证明。
一级缓存数据结构:
通过底层源码可以知道缓存的数据结构就是一个Map而且是HashMap。

命中场景

先看下图:
在这里插入图片描述
关于一级缓存的命中可大致分为两个场景,满足所有运行参数,第二不触发或不配置清空缓存方法。
上面图上很清楚就是得满足上面两个场景才可以的。
下面小编用示例代码来说明,运行参数相关的代码:

public class SqlSessionTest {
    private SqlSessionFactory factory;

    private SqlSession sqlSession;

    @Before
    public void init() throws SQLException {
        // 获取构建器
        SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
        // 解析XML 并构造会话工厂
        factory = factoryBuilder.build(ExecutorTest.class.getResourceAsStream("/mybatis-config.xml"));
        sqlSession = factory.openSession();
    }

    //不同会话
    @Test
    public void firstCacheTest() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        UserMapper mapper2 = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid(10);
        User user2 = mapper2.selectByid(10);
        System.out.println(user == user2);
    }
    //相同sql相同参数
    @Test
    public void firstCacheTest1() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid(10);
        User user2 = mapper.selectByid(10);
        System.out.println(user == user2);
    }
    //不同的statementId
    @Test
    public void firstCacheTest2() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        //xxx.xxx.xxx.UserMapper.selectByid
        User user = mapper.selectByid(10);
        //xxx.xxx.xxx.UserMapper.selectByid3
        User user2 = mapper.selectByid3(10);
        System.out.println(user == user2);
    }
    //不同的RowBounds
    @Test
    public void firstCacheTest3() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid(10);
        RowBounds rowBounds = RowBounds.DEFAULT;
        List<User> userList = sqlSession.selectList("xxx.xxx.xxx.UserMapper.selectByid", 10, rowBounds);
        System.out.println(user == userList.get(0));
        rowBounds =new RowBounds(0,10);
        List<User> userList2 =sqlSession.selectList("xxx.xxx.xxx.UserMapper.selectByid",10,rowBounds);
        System.out.println(user == userList2.get(0));
    }
}

上面执行结果分别是:

//firstCacheTest
false
//firstCacheTest1
true
//firstCacheTest2
false
//firstCacheTest3
true
false

是不是很简单,上面参数如果查询的id不同当然命中不了缓存了,这个小编就省略了
操作配置相关代码示例

@Test
    public void firstCacheConfigTest() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid(10);
        sqlSession.clearCache();
        User user2 = mapper.selectByid(10);
        System.out.println(user == user2);
    }

    @Test
    public void firstCacheConfigTest1() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid3(10);
        User user2 = mapper.selectByid3(10);
        System.out.println(user == user2);
    }


    @Test
    public void firstCacheConfigTest2() {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid(10);
        //无论update是哪个id都会清空
        mapper.setName(11,"ok");
        User user2 = mapper.selectByid(10);
        System.out.println(user == user2);
    }
	//上面firstCacheConfigTest1时加入了Options
 	@Select({" select * from users where id=#{1}"})
    @Options(flushCache = Options.FlushCachePolicy.TRUE)
    User selectByid3(Integer id);

上面执行结果分别是:

//firstCacheConfigTest
false
//firstCacheConfigTest1
false
//firstCacheConfigTest2
false

还有一个是全局的配置localCacheScope的配置STATEMENT注意这里需要大小写,这样缓存也就失效了

<settings>
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <setting name="localCacheScope" value="STATEMENT"/>
    </settings>

好了讲完了一级缓存的命中场景,咱们分析一下源码吧。

源码分析

前言中小编阐明了一级缓存中BaseExecutor里面,下面小编先画个缓存逻辑操作的流程图:
在这里插入图片描述
上图流程非常简单,无法就是查询的时候是否有缓存有就返回,没有就使用子类查询,查询完毕后封装进缓存然后返回结果。当然看源码的时候其实还有各种判断,比方说会话是否关闭,请求的结果是否需要处理,包括是否要清除缓存和请求参数缓存等等。
源码阅读以及关键注释

public abstract class BaseExecutor implements Executor {
	protected int queryStack;
	private boolean closed;
	protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
	protected PerpetualCache localCache;
	protected PerpetualCache localOutputParameterCache;


  @SuppressWarnings("unchecked")
  @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.");
    }
    
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      //清空缓存 条件第一次查询并且配置了flushCache=true,对子查询不受影响
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      //从缓存中取值
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      if (list != null) {
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
        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
        //情况缓存 配置文件里缓存作用域为STATEMENT 同样对子查询不受影响
        clearLocalCache();
      }
    }
    return list;
  }
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 {
      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;
  }
	@Override
  public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    //更新操作
    clearLocalCache();
    return doUpdate(ms, parameter);
  }
  @Override
  public void rollback(boolean required) throws SQLException {
    if (!closed) {
      try {
      	//回滚清空缓存
        clearLocalCache();
        flushStatements(true);
      } finally {
        if (required) {
          transaction.rollback();
        }
      }
    }
  }

  @Override
  public void commit(boolean required) throws SQLException {
    if (closed) {
      throw new ExecutorException("Cannot commit, transaction is already closed");
    }
    //提交情况缓存
    clearLocalCache();
    flushStatements();
    if (required) {
      transaction.commit();
    }
  }
}

下面是缓存key的结构,这里就能明白为什么命中参数有那么多条件,这边小编稍微说明一下,会话是不用再次说明的,环境参数在使用的时候一般不会多套,可以忽略
在这里插入图片描述

这样源码就和结论对起来了,注意clearLocalCache()清空所有一级缓存,

spring集成时一级缓存失效问题

很多人发现,mybatis集成spring一级缓存后会话失效了,以为是spring Bug ,真正原因是Spring 对SqlSession进行了封装,通过SqlSessionTemplae ,使得每次调用Sql,都会重新构建一个SqlSession,具体参见SqlSessionInterceptor。而根据前面所说的命中场景,一级缓存必须是同一会话才能命中,所以在这些场景当中不能命中。
怎么解决呢,给Spring 添加事务即可。添加事务之后,SqlSessionInterceptor(会话拦截器)就会去判断两次请求是否在同一事务当中,如果是就会共用同一个SqlSession会话来解决。
在这里插入图片描述

@Test
    public void testBySpring(){
        ClassPathXmlApplicationContext context=new ClassPathXmlApplicationContext("spring.xml");
        UserMapper mapper = context.getBean(UserMapper.class);
        // mapper ->SqlSessionTemplate --> SqlSessionInterceptor-->SqlSessionFactory
        DataSourceTransactionManager transactionManager =
                (DataSourceTransactionManager) context.getBean("txManager");
        // 手动开启事务
        TransactionStatus status = transactionManager
                .getTransaction(new DefaultTransactionDefinition());
        // 每次都会构造一个新会话 发起调用
        User user = mapper.selectByid(10);
        // 每次都会构造一个新会话 发起调用
        User user1 =mapper.selectByid(10);
        System.out.println(user == user1);
    }

上面如果没有开启事务,结果为false,开启事务就为true
大家如果调试代码的话记得打断点在
org.mybatis.spring.SqlSessionUtils#getSqlSession方法,下面是小编断点的堆栈图大家有空可以看一下

这边插一嘴大家还记得mybatis和spring的集成原理吗?可以看小编之前写的文章

在这里插入图片描述

二级缓存

二级缓存也称作是应用级缓存,与一级缓存不同的,是它的作用范围是整个应用,而且可以跨线程使用。所以二级缓存有更高的命中率,适合缓存一些修改较少的数据。在流程上是先访问二级缓存,再访问一级缓存。

缓存的完整方案

核心功能包括存储方案和溢出淘汰算法
存储方案:

  • 内存:最简单就是在内存当中,不仅实现简单,而且速度快。内存弊端就是不能持久化,且存储有限。
  • 硬盘:可以持久化,容量大。但访问速度不如内存,一般会结合内存一起使用。
  • 第三方集成:在分布式情况,如果想和其它节点共享缓存,只能第三方软件进行集成。比如Redis.

溢出淘汰

  • FIFO:先进先出
  • LRU:最近最少使用
  • WeakReference: 弱引用,将缓存对象进行弱引用包装,当Java进行gc的时候,不论当前的内存空间是否足够,这个对象都会被回收
  • SoftReference:软引用,与弱引用类似,不同在于只有当空间不足时GC才才回收软引用对象。

非核心功能:

  • 过期清理:指清理存放数据过久的数据
  • 线程安全:保证缓存可以被多个线程同时使用
  • 写安全:当拿到缓存数据后,可对其进行修改,而不影响原本的缓存数据。通常采取做法是对缓存对象进行深拷贝。

还有其他一些需求这边小编就不一一举例了,这个主要是对大家以后设计功能的时候的多重考虑。

Mybatis二级缓存结构以及实现

上面小编说了设计缓存需要一套完整的解决方案,那咱们来看一下Mybatis的二级缓存是在如何完成以上功能的情况下还有很好的扩展和设计模式。首先我们来看下二级缓存的结构图(mybatis不止这些cache,大家有空自己研究一下,小编只是大致罗列):
在这里插入图片描述
上面每一个功能都会对应一个组件类,并基于装饰者加责任链的模式,将各个组件进行串联。在执行缓存的基本功能时,其它的缓存逻辑会沿着这个责任链依次往下传递。
设计优点
1、职责单一:各个节点只负责自己的逻辑,不需要关心其它节点。
2、扩展性强:可根据需要扩展节点、删除节点,还可以调换顺序保证灵活性。(PerpetualCache里面没有delegate属性)
3、松耦合:各节点之间不没强制依赖其它节点。而是通过顶层的Cache接口进行间接依赖。
代码示例

public class SecondCacheTest {
    private SqlSessionFactory factory;
    private SqlSession sqlSession;
    private Configuration configuration;

    @Before
    public void init() throws SQLException {
        // 获取构建器
        SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
        // 解析XML 并构造会话工厂
        factory = factoryBuilder.build(ExecutorTest.class.getResourceAsStream("/mybatis-config.xml"));
        sqlSession = factory.openSession();
        configuration = factory.getConfiguration();
    }
    @Test
    public void secondCacheTest(){
        Cache cache = configuration.getCache("xxx.xxx.xxx.UserMapper");
        cache.putObject("user",new User());
        cache.getObject("user");
    }

}

断点调试:
在这里插入图片描述
这边大家是否和小编一样,那mybatis对这些缓存的组装是在哪儿的,然后各个缓存组件做了什么功能?看源码:
首先是组件的实现以上面断点调试为例:(其他小伙伴自己看啊)
SynchronizedCache

//加入线程同步
public synchronized void putObject(Object key, Object object) {
    delegate.putObject(key, object);
  }

LoggingCache

  //啥都没做
  @Override
  public void putObject(Object key, Object object) {
    delegate.putObject(key, object);
  }
  //取出来的时候做了命中率
  @Override
  public Object getObject(Object key) {
    requests++;
    final Object value = delegate.getObject(key);
    if (value != null) {
      hits++;
    }
    if (log.isDebugEnabled()) {
      log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
    }
    return value;
  }

SerializedCache(跨线程远程调用的时候需要序列化,保证安全性同时序列化哈反序列话是需要时间,效率就会变慢)

 @Override
  public void putObject(Object key, Object object) {
    if (object == null || object instanceof Serializable) {
      delegate.putObject(key, serialize((Serializable) object));
    } else {
      throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
    }
  }

  @Override
  public Object getObject(Object key) {
    Object object = delegate.getObject(key);
    return object == null ? null : deserialize((byte[]) object);
  }

LruCache(默认溢出淘汰缓存 最久没用的淘汰)

public void setSize(final int size) {
	//使用linkedHashMap每次放入是最新的,当到达最大的数量时,将最久的移出即可
	//为什么使用LinkedHashMap,删除和添加的效率比较高
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;

      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          eldestKey = eldest.getKey();
        }
        return tooBig;
      }
    };
  }

  @Override
  public void putObject(Object key, Object value) {
    delegate.putObject(key, value);
    cycleKeyList(key);
  }

  @Override
  public Object getObject(Object key) {
  	//访问后原本的顺序就修改了
    keyMap.get(key); //touch
    return delegate.getObject(key);
  }
  private void cycleKeyList(Object key) {
    keyMap.put(key, key);
    if (eldestKey != null) {
      delegate.removeObject(eldestKey);
      eldestKey = null;
    }
  }

PerpetualCache

private Map<Object, Object> cache = new HashMap<>();
@Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }

然后配置缓存策略
二级默认缓存默认是不开启的,需要为其声明缓存空间才可以使用,通过@CacheNamespace 或为指定的MappedStatement声明。声明之后该缓存为该Mapper所独有,其它Mapper不能访问。如需要多个Mapper共享一个缓存空间可通过@CacheNamespaceRef 或进行引用同一个缓存空间。@CacheNamespace 详细配置见下表:

配置说明
implementation指定缓存的存储实现类,默认是用HashMap存储在内存当中(PerpetualCache)
eviction指定缓存溢出淘汰实现类,默认LRU ,清除最少使用
flushInterval设置缓存定时全部清空时间,默认不清空。
size指定缓存容量,超出后就会按eviction指定算法进行淘汰
readWritetrue即通过序列化复制,来保证缓存对象是可读写的,默认true
blocking为每个Key的访问添加阻塞锁,防止缓存击穿
properties为上述组件,配置额外参数,key对应组件中的字段名。Property values for a implementation object.

注:Cache中责任链条的组成即通过@CacheNamespace 指导生成。具体逻辑详见CacheBuilder
大家可以对缓存做扩展,在缓存策略中修改@CacheNamespace指定的参数后,比方说将implementation 指定为第三方存储(需要实现Cache接口)等,其实在调用的时候完全没有影响,大家可以试着做一下修改。这边小编其实在学习过程中做了一系列改动的,包括改动淘汰溢出策略等等,这边就没贴出源码了,希望各位小伙伴试一下,来增加印象。

缓存其他配置
除@CacheNamespace 还可以通过其它参数来控制二级缓存()

字段配置域说明
cacheEnabled二级缓存全局开关,默认开启
useCache<select/update/insert/delete>指定的statement是否开启,默认开启
flushCache<select/update/insert/delete>执行sql前是否清空当前二级缓存空间,update默认true。query默认false
< cache/>缓存空间与@CacheNamespace类似,如果xml和mapper同时配置会报错
< cache-ref/>引用缓存空间 与@CacheNamespaceRef类似

@CacheNamespace和@CacheNamespaceRef的区别以及使用

注意:< cache/>与@CacheNamespace是不能同时用的会报错(用在相同的namespace里面),如果接口里面的方法查询走的是xml则@CacheNamespace不起作用,那就需要使用到< cache-ref/>配置了可能这么说大家不明白,那小编下面给了代码示例。或者反一些也行,即接口里面用@CacheNamespaceRef 注解xml中用 < cache/>,同时注意CacheNamespaceRef 必须指定name或value属性

@CacheNamespace
public interface UserMapper {

    @Select({" select * from users where id=#{1}"})
    User selectByid(Integer id);
	//这个不会被二级缓存
    List<User> selectByUser(User user);
}

在xml中配置

 <cache-ref namespace="xxx.xxx.xxx.UserMapper"/>
    <select id="selectByUser" resultMap="result_user" parameterMap="paramter_user">
        select * from users where 1=1
        <if test="id!=null">
            and id=#{id}
        </if>
        <if test="name!=null">
            and name=#{name}
        </if>
        <if test="age!=null">
            and age=#{age}
        </if>

    </select>

二级缓存命中场景

二级缓存命中条件先看下图(除了一个条件与一级缓存不同其他都差不多):
在这里插入图片描述
这边小编写了一个代码示例(会话提交必须手动提交后才可以):

	@Test
    public void hitRateTest(){
        //两个会话
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByid(10);
        //需要提交,否则不会命中
        sqlSession.commit();
        UserMapper mapper2 = sqlSession.getMapper(UserMapper.class);
        User user2 = mapper2.selectByid(10);
    }

执行结果
在这里插入图片描述
这边只能通过日志查看,不可能通过两个user相同,因为里面会经过序列化缓存。从上图可以看出命中率,第一次查询为0,第二次命中,那命中率的算法就是命中次数除以请求数,所以为0.5。

为什么需要提交后才能命中缓存?

二级缓存命中与一级缓存唯一不同的参数条件就是得提交。
在这里插入图片描述

如上图两个会话在修改同一数据,当会话二修改后,假如它实时填充到二级缓存,而会话一就能过缓存获取修改之后的数据,但实质是修改的数据回滚了,并没真正的提交到数据库。这样就产生了脏读。所以为了保证数据一致性,二级缓存必须是会话提交之才会真正填充,包括对缓存的清空,也必须是会话正常提交之后才生效。
要满足上面的条件,二级缓存的结构设计又上升了一个难度。为了实现会话提交之后才变更二级缓存,MyBatis对每个会话设立了若干个暂存区,当前的会话对指定缓存空间的变更,都存放在对应的暂存区,当前会话提交之后才会提交到每个暂存区对应的缓存空间。每个会话都有一个唯一的事务缓存管理器,来统一管理这些暂存区。这里暂存区也可叫做事务缓存。
下面小编使用一张图来说明上面的文字:
在这里插入图片描述
证明:
在这里插入图片描述

二级缓存执行流程

原本会话是通过Executor实现SQL调用,这里基于装饰器模式使用CachingExecutor对SQL调用逻辑进行拦截。然后嵌入二级缓存相关逻辑。流程图如下

在这里插入图片描述

查询 query

当会话调用query() 时,会基于查询语句、参数等数据组成缓存Key,然后尝试从二级缓存中读取数据。读到就直接返回,没有就调用被装饰的Executor去查询数据库,然后填充至对应的暂存区。

更新 update

当执行update操作时,同样会基于查询的语句和参数组成缓存KEY,然后在执行update之前清空缓存。这里清空只针对暂存区,同时记录清空的标记,以便当会话提交之时,依据该标记去清空二级缓存空间。

提交 commit

当会话执行commit操作后,会将该会话下所有暂存区的变更,更新到对应二级缓存空间去。

源码阅读

大家可以根据以下示例调试,具体源代码就不贴出来了:

@Test
    public void hitRateTest3(){
        //两个会话
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        //同一个会话查询的
        User user = mapper.selectByid(10);
        sqlSession.commit();
        User user = mapper.selectByid(10);
         System.out.println("第一个会话查询提交==="+user);
        mapper.setName(10,"bob");
        User user = mapper.selectByid(10);
        System.out.println("第一个会话没提交update查询==="+user);
        UserMapper mapper2 = factory.openSession().getMapper(UserMapper.class);
        User user1 = mapper2.selectByid(10);
        System.out.println("第二个会话查询第一个还没提交update==="+user1);
        sqlSession.commit();
        User user2 = mapper2.selectByid(10);
        System.out.println("第二个会话查询第一个提交update的==="+user2);
    }

大家一定要好好走一遍啊,会涉及到很多细节的,如果是口述还可以如果是文字的话小编不断贴代码反而会绕晕大家的。

总结

今天小编讲mybatis的多级缓存体系一网打尽了。文章有点长,如果看起来就枯燥乏味了,下次小编想着讲这样的文章分为几篇讲解,这样会不会更好。好了今天就到这儿。如果你能坚持到最后,并且完全理解那你就是最棒的。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

木兮君

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

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

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

打赏作者

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

抵扣说明:

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

余额充值