Mybatis源码之缓存机制

了解过Mybatis的同学,大概都知道Mybatis有一级缓存和二级缓存;但是我们使用Mybatis时一般使用默认的配置,对缓存的原理知之甚少。之前介绍Executor的文章中提到了Mybatis缓存相关的内容,但是比较琐碎,不成体系。

今天通过这篇文章对Mybatis的缓存机制做下详细解读,对一级缓存、二级缓存的执行流程、工作原理有个全面的认识,方便开发过程中合理使用。

Mybatis源码之缓存机制
image.png


还是先通过一个“全景图”对Mybatis的缓存机制有个整体了解,然后结合整体流程,对每个环节逐个剖析。如图所示,Mybatis一级缓存是在BaseExecutor中实现,并且一级缓存仅在SqlSession生命周期内有效;二级缓存是在CachingExecutor实现,二级缓存是在namespace维度且全局共享。


若一二级缓存同时开启,当执行查询时:Mybatis先查询二级缓存;如果二级缓存未命中,则继续查询一级缓存;若一级缓存未命中,则查询数据库并更新一二级缓存。默认情况下,一级缓存是开启的,而且没有办法关闭;二级缓存默认关闭。大体了解Mybatis缓存机制后,我们逐个了解一下一二级缓存分别是怎么实现的。

一级缓存

先来看下比较简单的一级缓存。Mybatis一级缓存在BaseExecutor中实现,使用Cache接口实现类PerpetualCache作为缓存存储的容器,通过源码可知其内部就是一个HashMap。这就跟我们平时自己做K-V存储或者使用Redis做缓存的思路类似。那么,我们自然而然的需要弄清楚以下几个问题:

  • Mybatis是如何写缓存、何时写缓存?

  • Mybatis如何读缓存、何时读缓存?

  • Mybatis何时使缓存失效,缓存失效采用了什么策略?

  • Cache接口及实现类提供了哪些接口方法,Mybatis是如何使用它们的?

  • 一级缓存是应用内的本地缓存,在分布式场景下会不会导致脏读等不一致问题?该如何解决?

呃,好像问题挺多的!前面三个是缓存的使用问题,第四个是源码层面的,最后一个是结合一级缓存特点如何使用的问题。带着这几个问题,我们进入正题。

Cache接口及PerpetualCache

Cache接口位于org.apache.ibatis.cache包下,是Mybatis实现缓存的核心接口,Mybatis一级、二级缓存的都依赖于它,通过下面的类图简单了解一下继承关系,以体现Cache的重要地位:

Mybatis源码之缓存机制
image.png


一级缓存仅用到了PerpetualCache,所以这个阶段我们先通过源码认识Cache接口及PerpetualCache实现。先看下Cache接口的注释:


 1/**
2 * SPI for cache providers.
3 * One instance of cache will be created for each namespace.
4 * The cache implementation must have a constructor that receives the cache id as an String parameter.
5 * MyBatis will pass the namespace as id to the constructor.
6 *
7 * <pre>
8 * public MyCache(final String id) {
9 *  if (id == null) {
10 *    throw new IllegalArgumentException("Cache instances require an ID");
11 *  }
12 *  this.id = id;
13 *  initialize();
14 * }
15 * </pre>
16 *
17 * @author Clinton Begin
18 */

作者Clinton Begin说,Cache接口定义了Mybatis cache体系的SPI,任何一个cache实例都是为namespace创建的,并且通过示例要求每个Cache接口实现类应该通过构造方法接收namespace作为cache的id作为唯一标识。这也就是说,每个Cache实例都是有唯一标识的(id)。再来看下Cache的接口定义:

 1public interface Cache {
2
3  /**
4   * 获取缓存的唯一标识
5   * @return The identifier of this cache
6   */

7  String getId();
8
9  /**
10   * 向缓存中添加内容,这里的key使用了CacheKey这个类
11   *
12   * @param key Can be any object but usually it is a {@link CacheKey}
13   * @param value The result of a select.
14   */

15  void putObject(Object key, Object value);
16
17  /**
18   * 根据key从缓存中查询
19   * @param key The key
20   * @return The object stored in the cache.
21   */

22  Object getObject(Object key);
23
24  /**
25   * 从缓存中移除key对应的内容
26   *
27   * @param key The key
28   * @return Not used
29   */

30  Object removeObject(Object key);
31
32  /**
33   * Clears this cache instance,清空缓存
34   */

35  void clear();
36}

Cache接口很少,它提供了对缓存添加、查询、删除、清空的基本操作方法,其中添加、查询、删除都需要使用类型为CacheKey的key来操作。CacheKey是Mybatis用来生成缓存索引的工具类,通过一定的规则来确保散列的鲁棒性。再看PerpetualCache,刚才也提到,它使用HashMap作为缓存的存储容器,再结合Cache接口定义的方法我们自然就可以知道它是对HashMap元素的操作了,节省篇幅,代码就不再贴了。

缓存管理

一级缓存管理涉及PerpetualCache创建、查询缓存、添加缓存、清空缓存几个过程,除了缓存创建外,其他几个过程都是由Executor的update/query/commit/rollback等方法执行时触发。

缓存创建

PerpetualCache的初始化工作在BaseExecutor构造方法中完成,并指定id为LocalCache,所以一级缓存也叫本地缓存。初始化代码如下所示:

 1  protected BaseExecutor(Configuration configuration, Transaction transaction) {
2    this.transaction = transaction;
3    this.deferredLoads = new ConcurrentLinkedQueue<>();
4    // 缓存初始化
5    this.localCache = new PerpetualCache("LocalCache");
6    this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
7    this.closed = false;
8    this.configuration = configuration;
9    this.wrapper = this;
10  }

缓存查询与添加

缓存的目的是为了提高查询效率,所以查询缓存必然是在BaseExecutor#query触发。如果查询过程中命中缓存,可直接把缓存内容返回;如果未命中,则需要从数据库中查询,然后再把数据库查询结果添加到缓存中,以保证下次的查询效率。查询过程中的缓存管理流程如下图所示:

Mybatis源码之缓存机制
image.png


以上流程分别涉及了BaseExecutor#query()、BaseExecutor#queryFromDatabase两个方法,大家可以对照流程图及代码过一下:


 1  public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
2    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
3    if (closed) {
4      throw new ExecutorException("Executor was closed.");
5    }
6    if (queryStack == 0 && ms.isFlushCacheRequired()) {
7      clearLocalCache();
8    }
9    List<E> list;
10    try {
11      queryStack++;
12      // 检查是否命中缓存,如果命中则取出结果
13      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
14      if (list != null) {
15        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
16      } else {
17        //未命中时,调用方法查询数据库
18        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
19      }
20    } finally {
21      queryStack--;
22    }
23    if (queryStack == 0) {
24      for (DeferredLoad deferredLoad : deferredLoads) {
25        deferredLoad.load();
26      }
27      // issue #601
28      deferredLoads.clear();
29      // 如果本地缓存(一级缓存)的作用范围是STATEMENT,则清空缓存
30      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
31        // issue #482
32        clearLocalCache();
33      }
34    }
35    return list;
36  }
37
38  private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
39    List<E> list;
40    //通过占位符预占缓存
41    localCache.putObject(key, EXECUTION_PLACEHOLDER);
42    try {
43      // 查询数据库
44      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
45    } finally {
46      // 移除占位符
47      localCache.removeObject(key);
48    }
49    // 把数据库查询结果添加到缓存中
50    localCache.putObject(key, list);
51    if (ms.getStatementType() == StatementType.CALLABLE) {
52      localOutputParameterCache.putObject(key, parameter);
53    }
54    return list;
55  }

清空缓存

当数据源发生改变时,为了保证数据一致性,清空缓存中的失效数据,从而保证再次查询时从数据库加载最新数据。所以,引起数据源变更的操作即为清空缓存的时机。

从BaseExecutor的接口来看,update、commit、rollback等方法都会导致数据源的变更,通过源码来看,这些方法内部确实调用了clearLocalCache方法来清空缓存。代码如下所示:

 1  public int update(MappedStatement ms, Object parameter) throws SQLException {
2    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
3    if (closed) {
4      throw new ExecutorException("Executor was closed.");
5    }
6    clearLocalCache();
7    return doUpdate(ms, parameter);
8  }
9
10  public void commit(boolean required) throws SQLException {
11    if (closed) {
12      throw new ExecutorException("Cannot commit, transaction is already closed");
13    }
14    clearLocalCache();
15    flushStatements();
16    if (required) {
17      transaction.commit();
18    }
19  }
20
21  public void rollback(boolean required) throws SQLException {
22    if (!closed) {
23      try {
24        clearLocalCache();
25        flushStatements(true);
26      } finally {
27        if (required) {
28          transaction.rollback();
29        }
30      }
31    }
32  }
33
34  public void clearLocalCache() {
35    if (!closed) {
36      localCache.clear();
37      localOutputParameterCache.clear();
38    }
39  }

这里需要注意的是:在update方法中,如果本地缓存(一级缓存)的作用范围是STATEMENT,则也会调用方法clearLocalCache来清空缓存。

一级缓存总结

生命周期

在之前的文章中我们已经了解到,Executor被SqlSession持有,而一级缓存LocalCache只是BaseExecutor的一个字段。所以,当SqlSession的生命周期结束时,一级缓存也会被回收。也就是说,一级缓存仅能作用于同一个SqlSession的声明周期,不同SqlSession间不可共享。它们之间的关系如下图所示:

Mybatis源码之缓存机制
image.png

分布式系统如何避免数据不一致

既然一级缓存仅可在SqlSession内部共享,那么如果同时存在多个SqlSession或者分布式的环境下,update、commit等引起缓存失效的方法无法对其他SqlSession起作用,必然会带来脏读问题。这个问题可以通过更改一级缓存的作用范围(localCacheScope)为STATEMENT进行避免。

缓存读写时机与原理

一级缓存由PerpetualCache实现,其内部通过HashMap完成对缓存的读写操作,所以本质上讲,一级缓存其实是通过HashMap实现的,Map中的key是由MappedStatement生成CacheKey,来确保查询的唯一性。

一级缓存是为了提高查询效率,在执行query操作前会先查询缓存:若命中缓存,则直接把缓存内容作为结果返回,不再查询数据库;若未命中缓存,则执行数据库查询,然后把查询结果加入缓存内,最终返回结果。

二级缓存

一级缓存生命周期仅限于SqlSession,然而实际使用时我们创建多个SqlSession对象,但是多个SqlSession之间无法共享缓存内容。为了解决这个问题,我们可以使用二级缓存。二级缓存由CachingExecutor实现,作用在一级缓存之前。开启二级缓存后,Executor的查询流程变为:二级缓存->一级缓存->数据库

启用二级缓存

启用二级缓存,需要依次完成以下几个配置:

  • 在Mybatis配置文件中配置cacheEnabled 设置(settings):全局性地开启或关闭所有映射器配置文件中已配置的任何缓存,默认值为true。

1<setting name="cacheEnabled" value="true"/>
  • 在mapper配置文件中增加

    节点。

1<!--默认情况下,这一行就可以--> 
2<cache/>

这样会使用默认的缓存策略,cache节点有如下几个属性可以设置:

  • eviction:清除策略。支持LRU(最近最少使用,默认)、FIFO(先进先出)、SOFT、WEAK。

  • flushInterval:刷新间隔。可以被设置为任意的正整数,设置的值应该是一个以毫秒为单位的合理时间量。默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新。

  • size:缓存数量,默认值是 1024。可以被设置为任意正整数,要注意欲缓存对象的大小和运行环境中可用的内存资源。

  • readOnly(只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实例。因此这些对象不能被修改。这就提供了可观的性能提升。而可读写的缓存会(通过序列化)返回缓存对象的拷贝。速度上会慢一些,但是更安全,因此默认值是 false。

默认情况下(如上配置)起到的效果如下:

  • 映射语句文件中的所有 select 语句的结果将会被缓存。

  • 映射语句文件中的所有 insert、update 和 delete 语句会刷新缓存。

  • 缓存会使用最近最少使用算法(LRU, Least Recently Used)算法来清除不需要的缓存。

  • 缓存不会定时进行刷新(也就是说,没有刷新间隔)。

  • 缓存会保存列表或对象(无论查询方法返回哪种)的 1024 个引用。

  • 缓存会被视为读/写缓存,这意味着获取到的对象并不是共享的,可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。

二级缓存初始化

二级缓存初始化是在mapper对应的xml文件解析时执行的,可以按照以下链路查询解析流程,其中cacheElement方法读取了并为配置信息赋值为默认值,然后调用MapperBuilderAssistant#useNewCache初始化二级缓存对象。

1org.apache.ibatis.session.SqlSessionFactoryBuilder#build(java.io.InputStream, java.lang.String, java.util.Properties)
2org.apache.ibatis.builder.xml.XMLConfigBuilder#parse
3org.apache.ibatis.builder.xml.XMLConfigBuilder#parseConfiguration
4org.apache.ibatis.builder.xml.XMLConfigBuilder#mapperElement
5org.apache.ibatis.builder.xml.XMLMapperBuilder#parse
6org.apache.ibatis.builder.xml.XMLMapperBuilder#configurationElement
7org.apache.ibatis.builder.xml.XMLMapperBuilder#cacheElement
8org.apache.ibatis.builder.MapperBuilderAssistant#useNewCache

cacheElement方法读取配置并初始化配置信息,若未配置属性会采用默认值。

 1  private void cacheElement(XNode context) {
2    if (context != null) {
3      // 读取缓存类型,默认为PERPETUAL
4      String type = context.getStringAttribute("type""PERPETUAL");
5      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
6      // 读取数据淘汰策略并获取其类型,默认为LRU,默认大小1024
7      String eviction = context.getStringAttribute("eviction""LRU");
8      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
9      // 刷新间隔,默认为空,即不会刷新。
10      Long flushInterval = context.getLongAttribute("flushInterval");
11      // 缓存数量:默认为空,会使用LRU的默认大小1024
12      Integer size = context.getIntAttribute("size");
13      boolean readWrite = !context.getBooleanAttribute("readOnly"false);
14      boolean blocking = context.getBooleanAttribute("blocking"false);
15      Properties props = context.getChildrenAsProperties();
16      // 初始化缓存对象
17      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
18    }
19  }

useNewCache方法:依次创建缓存对象PerpetualCache,添加数据淘汰策略(装饰器),设置刷新间隔,设置缓存大小……,这一系列操作采用装饰器模式进行装配,最终得到的是一个层层嵌套的Cache对象。

 1  public Cache useNewCache(Class<? extends Cache> typeClass,
2      Class<? extends Cache> evictionClass,
3      Long flushInterval,
4      Integer size,
5      boolean readWrite,
6      boolean blocking,
7      Properties props)
 
{
8    Cache cache = new CacheBuilder(currentNamespace)
9        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
10        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
11        .clearInterval(flushInterval)
12        .size(size)
13        .readWrite(readWrite)
14        .blocking(blocking)
15        .properties(props)
16        .build();
17    configuration.addCache(cache);
18    currentCache = cache;
19    return cache;
20  }

Cache初始化时,使用currentNamespace(即:mapper文件的namespace)作为Cache的id,最终把该缓存对象存储在全局缓存Configuration中,以此实现了跨Session共享。

缓存使用

如前文所述,二级缓存在CachingExecutor中生效。查看org.apache.ibatis.executor.CachingExecutor#query方法可知,其执行流程如下:

  • 根据MappedStatement、parameterObject等信息生成CacheKey;

  • 获取MappedStatement中缓存的Cache对象;

  • 通过TransactionalCacheManager尝试获取缓存数据:若缓存命中,则直接返回;若缓存未命中,则执行后续查询并把结果缓存在TransactionalCacheManager中。

query方法完成了缓存的查询与添加操作,具体过程可查看如下代码。

 1  @Override
2  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
3      throws SQLException 
{
4    Cache cache = ms.getCache();
5    if (cache != null) {
6      flushCacheIfRequired(ms);
7      if (ms.isUseCache() && resultHandler == null) {
8        ensureNoOutParams(ms, boundSql);
9        @SuppressWarnings("unchecked")
10        List<E> list = (List<E>) tcm.getObject(cache, key);
11        if (list == null) {
12          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
13          tcm.putObject(cache, key, list); // issue #578 and #116
14        }
15        return list;
16      }
17    }
18    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
19  }

缓存失效

为了避免脏读,当CachingExecutor执行update、commit、rollback等操作时,会执行缓存的删除或清空重置操作。

update源码:

 1  @Override
2  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
3    flushCacheIfRequired(ms);
4    return delegate.update(ms, parameterObject);
5  }
6
7  private void flushCacheIfRequired(MappedStatement ms) {
8    Cache cache = ms.getCache();
9    if (cache != null && ms.isFlushCacheRequired()) {
10      tcm.clear(cache);
11    }
12  }

commit源码:

 1//org.apache.ibatis.executor.CachingExecutor#commit  
2@Override
3  public void commit(boolean required) throws SQLException {
4    delegate.commit(required);
5    tcm.commit();
6  }
7
8//org.apache.ibatis.executor.CachingExecutor#rollback
9  @Override
10  public void rollback(boolean required) throws SQLException {
11    try {
12      delegate.rollback(required);
13    } finally {
14      if (required) {
15        tcm.rollback();
16      }
17    }
18  }
19
20//org.apache.ibatis.cache.TransactionalCacheManager#commit
21  public void commit() {
22    for (TransactionalCache txCache : transactionalCaches.values()) {
23      txCache.commit();
24    }
25  }
26
27//org.apache.ibatis.cache.decorators.TransactionalCache#commit
28  public void commit() {
29    if (clearOnCommit) {
30      delegate.clear();
31    }
32    flushPendingEntries();
33    reset();
34  }
35
36  private void reset() {
37    clearOnCommit = false;
38    entriesToAddOnCommit.clear();
39    entriesMissedInCache.clear();
40  }

二级缓存总结

MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。

在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。

全文总结

本文简单梳理了mybatis的缓存机制,主要在于了解其运行原理,虽然在实际工作中不会使用,但是某些参数或特性可能会影响我们应用的运行效果,了解其原理重点在于避坑。另外,其缓存原理也可以为我们实际开发工作提供一些好的思路。


原文始发于微信公众号(码路印记):Mybatis源码之缓存机制

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/23824.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!