Mybatis源码阅读之--本地(一级)缓存实现原理分析

Posted autumnlight

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Mybatis源码阅读之--本地(一级)缓存实现原理分析相关的知识,希望对你有一定的参考价值。

前言:
Mybatis为了提升性能,内置了本地缓存(也可以称之为一级缓存),在mybatis-config.xml中可以设置localCacheScope中可以配置本地缓存的作用域,包含两个值session和statement,其中session选项表示本地缓存在整个session都有效,而statement只能在一条语句中有效(这条语句有嵌套查询--nested query/select)。
下面分析一下mybatis本地缓存的实现原理。

本地缓存是在Executor内部构建,Executor包含了四个实现类,SimpleExecutor,BatchExecutor以及CachingExecutor和RoutingExecutor,其中CachingExecutor是开启了二级缓存才会用到的,这里先不说,而RoutingExecutor是负责路由的,它本身包含了Executor,也先不管,这里主要是SimpleExecutor和BatchExecutor,他们都实现了BaseExecutor,而BaseExecutor中正是进行了一级缓存的处理。

public abstract class BaseExecutor implements Executor {
  protected PerpetualCache localCache; // 一级缓存,实质就是一个HashMap<Object, Object>
  protected PerpetualCache localOutputParameterCache; // 出参一级缓存,当statment为callable的时候使用
}

在BaseExecutor中定义了一个PerpetualCache类型的localCache属性,用来保存一级缓存
而PerpetualCache类的主要功能如下:

public class PerpetualCache implements Cache {
  private final String id; // 该缓存的id
  private final Map<Object, Object> cache = new HashMap<>();
  // ...其他一些获取缓存数据、移除缓存数据的方法
}

其中包含了两个属性,id表示缓存的唯一标识,cache是一个HashMap类型的对象,里面存放所有已经缓存的数据
也就是是说Mybatis的一级缓存实质就是一个HashMap。

再回过头看一看BaseExecutor中的一级缓存处理过程(下述中的代码片段都是BaseExecutor类中的,不会再把类加上了):

  1. select添加缓存
  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);
    // 根据cachekey执行查询
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
  }

首先创建缓存键key,然后根据key再查询。
下面代码展示了根据key进行查询的逻辑

@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()) {
      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
        clearLocalCache();
      }
    }
    return list;
  }

先看第二个if--如果当前的查询语句设置了清除缓存的属性为true,那么就要把一级缓存清除
当然里面还需要满足queryStack==0的条件,这个条件涉及到了嵌套查询(nested select/query),如果是嵌套查询的最外层查询(第一个查询),才进行缓存的清理动作,否则不进行。这里的queryStack是查询的层级,取决于nested select的层数,例如一个Blog有一个Author,一个Author有一个Account,其中Author和Account都使用了嵌套查询,并且不是延迟加载(fetchType设置),那么Author查询的时候queryStack就会是1,Account查询的时候queryStack为2。针对嵌套查询这里就说这么多,后续会专门写一篇嵌套查询原理的文章,包括非延迟加载以及延迟加载的不同情况的处理方式。

    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }

下面代码片段展示了从缓存中取数据的逻辑

  list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
``
直接调用localCache的getObject方法,但是需要在resultHandler不为null的情况,因为如果查询数据是传入了ResultHandler,那么会返回null,数据由ResultHandler进行处理。
如果缓存中查到了数据,那么会处理缓存的出参(出参只有在MappedStatement类型为Callable时才会有,其他的STATEMENT/PREPAREDSTATMENT都没有)
如果没有查到数据,那么从数据库中查询
```java
    list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);

数据查询完了之后执行queryStack--操作。
进入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 {
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      // 删除占位
      localCache.removeObject(key);
    }
    // 将数据放入到缓存中
    localCache.putObject(key, list);
    // 对于callable的statement来说,出参也需要缓存,而出参也是放在了入参中
    // 因此这里缓存了入参
    if (ms.getStatementType() == StatementType.CALLABLE) {
      localOutputParameterCache.putObject(key, parameter);
    }
    return list;
  }

主要步骤:

  1. 先在一级缓存中设置一个占位符,EXECUTION_PLACEHOLDER,
    此处代码的作用就是为了防止嵌套查询是查询了相同的数据
    举个例子,一个Blog有一个Author,而Author中又嵌套了一个Blog,那么Blog还没有放到缓存中,但是嵌套查询现在查Author,Author中的Blog又是第一个Blog查询的数据,这里放置一个占位符就是为了说明,这个Blog已经在查询了,结果还没出来而已,不要急,等结果出来了再进行配对。
  2. 执行子类的doQuery方法,查询数据
  3. 删除缓存占位、将查询出的数据放入到缓存中。
  4. 如果此查询语句是CALLABLE类型的,那么要把出参也缓存
    以上四部做完之后从数据库中查询数据就结束了,其中第一步可能有些人还是很困惑,大家可以执行一些测试看一看。

再次将思路返回到query方法中,

    if (queryStack == 0) { // 最外层的查询已经结束
      // 所有非延迟的嵌套查询也已经查完了,那么就可以把嵌套查询的结果放入到需要的对象中
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }

当最外层查询结束时,需要执行一些清理动作:

  1. 执行所有嵌套查询的连接操作,上面例子中的Blog->Author->Blog,会把author中的Blog设置正确
  2. 清除嵌套查询
  3. 如果当前语句的一级缓存作用域是statement的话,要把一级缓存清空
    上面的第一步和第二部需要结合ResultSetHandler共同分析,后面分析嵌套查询的时候再做详细的介绍,这里大家心中有个了解即可。
    至此,查询过程的缓存处理就已经结束了
    下面简单看一下cleanLocalCache方法
  public void clearLocalCache() {
    if (!closed) {
      localCache.clear();
      localOutputParameterCache.clear();
    }
  }

也很简单,就是把localCache和localOutputParameterCache置空。

接下来就分析update(其中insert/update/delete都统称为update)时,一级缓存如何处理:

  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();
    // 使用子类的doUpdate方法
    return doUpdate(ms, parameter);
  }
``
先把缓存清空,然后调用子类的doUpdate执行具体的更新操作

另外事务的提交以及回滚都会清空以及缓存,代码如下:
```java
  public void commit(boolean required) throws SQLException {
    if (closed) {
      throw new ExecutorException("Cannot commit, transaction is already closed");
    }
    clearLocalCache(); // 清除缓存
    flushStatements();
    if (required) {
      transaction.commit();
    }
  }
  public void commit(boolean required) throws SQLException {
    if (closed) {
      throw new ExecutorException("Cannot commit, transaction is already closed");
    }
    clearLocalCache(); // 清理缓存
    flushStatements();
    if (required) {
      transaction.commit();
    }
  }
  public void rollback(boolean required) throws SQLException {
    if (!closed) {
      try {
        clearLocalCache(); // 清理缓存
        flushStatements(true);
      } finally {
        if (required) {
          transaction.rollback();
        }
      }
    }
  }

因此,在一个sqlSession执行了commit或者rollback方法后,一级缓存已经没有了数据,如果再次执行相同的查询操作,那么会重新从数据库中查询。
一级缓存需要注意的事项:
在实际开发中,有可能对查询数据进行一些操作,比如修改一些字段,或者一个列表中删除/添加一些数据,再次执行相同的查询,返回的不会是数据库中的数据,而是经过修改的数据,因此最好不要对Mybatis返回的数据进行修改操作。

以上是关于Mybatis源码阅读之--本地(一级)缓存实现原理分析的主要内容,如果未能解决你的问题,请参考以下文章

mybatis源码分析之05一级缓存

如何设计一个本地缓存

通过源码分析MyBatis的缓存

Mybaits 源码解析 ----- 全网最详细,没有之一:一级缓存和二级缓存源码分析

mybatis源码阅读

MyBatis源码分析五MyBatis的缓存