MyBatis  缓存

MyBatis 缓存

前面讲的那些遇到缓存相关的就跳过去了,有点不厚道,那就拿这篇文章就来填坑吧。

为啥要有缓存

我们知道数据库中的数据是存储在磁盘上的,磁盘 IO 的效率比直接在内存中查询低好几个数量级。所以如果可以在内存中将查询的结果缓存起来下次一样的 SQL 语句来查询时直接将内存中的结果返回即可,可以极大的提高系统吞吐量。所以就有了这边文章要介绍的一级缓存和二级缓存。

一级缓存

一级缓存的作用域只在当前会话生命周期内,即不同的 SqlSession 不能共享缓存中的数据。

实现方式

通过前面的介绍我们知道,SqlSession 的查询请求会委托给 Executor 完成,其中 CachingExecutor 是为二级缓存准备的,先不管它。所以查询请求的入口就是 BaseExecutor#query 方法,一起来看看它的逻辑
mybatis-localCache
通过在 BaseExecutor 内维护一个 PerpetualCache 类型的 localCache 属性作为本地缓存。其实现了 MyBatis 的 Cache 接口,通过一个 HashMap 来存储数据。所以一级缓存的生命周期和当前 SqlSession 会话的生命周期一致

  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;
  }

如果缓存中没有就去 DB 中查询,并将查询后的结果放入本地缓存 localCache 中。

调用时序图

mybatis-localCache-sequential

如何清除缓存

除了上面看到的通过一些配置属性来清除缓存外,在 Executorupdate 方法中也会清除本地缓存。我们知道 SqlSession 的增删改操作都会委托给 Executor#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();
    return doUpdate(ms, parameter);
  }

总结

  • 一级缓存的生命周期和 SqlSession 一致
  • 有多个 SqlSession 或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为 STATEMENT
  • 可以设置 statementflushCache="true" 来强制清除缓存

二级缓存

不同于一级缓存的作用域,二级缓存可以做到在多个 SqlSession 中共享数据。

实现方式

通过前面的介绍我们知道,二级缓存是通过 CachingExecutor 来实现的,所以想要开启二级缓存首先需要在配置文件中加上 <setting name="cacheEnabled" value="true"/>,这样 SqlSession 所持有的 Executor 实例才是 CachingExecutor。先看一下其工作流程图:
mybatis-cachingExecutor-workflow
同一个 namespace 下的缓存数据可以在多个 SqlSession 中共享。

namespace 是啥

其实就是 mapper.xml<cache/> 节点,对应着 MappedStatementcache 属性,不同的 mapper.xml 之间可通过配置 <cache-ref namespace="{another_mapper_namespace}"/> 使得不同的 MappedStatement 持有同一个 Cache 实例。所以想查询某个 statement 时使用二级缓存还需要在其所在的 mapper.xml 文件中配置 cachecache-ref 节点。

show me the code

  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);//②
      if (ms.isUseCache() && resultHandler == null) {//③
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);//④
        if (list == null) {
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); //⑤
        }
        return list;
      }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }
  • 代码①:从 MappedStatement 中获取 Cache 实例
  • 代码②:判断是否需要清除缓存,即和一级缓存一样,如果当前 ms 配置了 flushCache="true" 则会清除二级缓存
  • 代码③:当前 ms 配置了 useCache="true" 才会查询二级缓存,所以想使用二级缓存的条件又多了一个😺
  • 代码④:通过 tcmcache 中的缓存数据,tcmTransactionalCacheManager,待会再介绍它
  • 代码⑤:如果二级缓存中没有查到则走👆一级缓存的查询逻辑,并将查询结果通过 tcm 放入 cache 所对应的 TransactionalCacheentriesToAddOnCommit 中。注意:此时从并未加入二级缓存,需要执行 commit 方法后其他 sqlSession 才能在二级缓存中查询到

TransactionalCacheManager

每一个 CachingExecutor 实例都会持有一个 TransactionalCacheManager 变量 tcm,所有与二级缓存相关的操作也都是通过该类完成的。

public class TransactionalCacheManager {

  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
  //清除缓存
  public void clear(Cache cache) {
    getTransactionalCache(cache).clear();
  }
  //查询缓存
  public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
  }
  //将缓存加入待提交 map
  public void putObject(Cache cache, CacheKey key, Object value) {
    getTransactionalCache(cache).putObject(key, value);
  }
  //事务提交,并将缓存数据真正的提交
  public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
    }
  }
  //事务回滚,清除代提交缓存数据
  public void rollback() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.rollback();
    }
  }

  private TransactionalCache getTransactionalCache(Cache cache) {
    return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
  }

}

TransactionalCache

从上面 TransactionalCacheManager 的介绍中我们可以看到其通过 transactionalCaches 变量维护着每一个 Cache 实例和与之对应的TransactionalCache,并将二级缓存相关的操作都交给 TransactionalCache 来处理。

public class TransactionalCache implements Cache {
  private final Cache delegate;
  private boolean clearOnCommit;
  //待提交缓存 map
  private final Map<Object, Object> entriesToAddOnCommit;
  //未命中缓存的 key 集合
  private final Set<Object> entriesMissedInCache;

  //对 mappedStatement.cache 进行包装
  public TransactionalCache(Cache delegate) {
    this.delegate = delegate;
    this.clearOnCommit = false;
    this.entriesToAddOnCommit = new HashMap<>();
    this.entriesMissedInCache = new HashSet<>();
  }

  //查询缓存
  public Object getObject(Object key) {
    //查询 mappedStatement.cache 里的缓存数据
    Object object = delegate.getObject(key);
    if (object == null) {
      //缓存未命中加入 entriesMissedInCache 集合
      entriesMissedInCache.add(key);
    }
    if (clearOnCommit) {
      return null;
    } else {
      return object;
    }
  }

  //将从 DB 或一级缓存中查询的数据加入待提交 map
  public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
  }

  //事务提交
  public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    //处理 entriesToAddOnCommit 和 entriesMissedInCache
    flushPendingEntries();
    reset();
  }

  //事务回滚
  public void rollback() {
    //释放 entriesMissedInCache 中的数据
    unlockMissedEntries();
    reset();
  }

  private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      //将待提交缓存数据提交至 mappedStatement.cache 中,这样其他 sqlSession 也能从缓存中查询出数据了
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    for (Object entry : entriesMissedInCache) {
      //未命中缓存的 cacheKey 也加入 mappedStatement.cache 中,value 设为 null
      if (!entriesToAddOnCommit.containsKey(entry)) {
        delegate.putObject(entry, null);
      }
    }
  }

  private void unlockMissedEntries() {
    for (Object entry : entriesMissedInCache) {
      try {
        //删除二级缓存中 value 为 null 的数据
        delegate.removeObject(entry);
      } catch (Exception e) {
        log.warn("Unexpected exception while notifiying a rollback to the cache adapter."
            + "Consider upgrading your cache adapter to the latest version.  Cause: " + e);
      }
    }
  }

  // 省略其他方法......
}

从上面代码可以了解到 TransactionalCacheMappedStatement.cache 的包装,对二级缓存的操作实际是通过 MappedStatement.cache 来完成的,这样才能达到不同 SqlSession 在同一个 namespace 下缓存数据共享的目的。

总结

  • 通过在 MappedStatement 中保存缓存信息,二级缓存相比于一级缓存的作用域更大,能做到在不同 SqlSession 之间共享同一个 namespace 下的缓存数据
  • 增删改操作会清空所在 namespace 下的全部缓存,因为 insert update delete 的 SQL 语句对应的 mappedStatemet.flushCacheRequired 属性默认为 true
  • 在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻

我的看法

现在应用基本都是分布式部署的,所以 MyBatis 的一二级缓存并不能很好的发挥作用,用不好反而会带来一下问题,所以还是拿他作为一个 ORM 框架即可。缓存方案可以采用第三方集中式的缓存中间件,例如 RedisMemcached 即可。

巨人的肩膀