揭秘MyBatis缓存机制

251 阅读5分钟

关注公众号 不爱总结的麦穗 将不定期推送技术好文

什么是一级缓存

  在程序运行过程中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的SQL,它们的结果极有可能完全相同。但是由于查询一次数据库的代价很大(I/O) ,MyBatis提供了一级缓存的方案优化这部分场景,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询。

  接下来,我们通过源码来分析一级缓存的整个生命周期(ps:阅读之前,大家可以先去看看我其他关于MyBatis的文章,了解前置知识。

缓存初始化

  SqlSession只是一个MyBatis对外的接口,SqlSession将它的工作交给了Executor执行器这个角色来完成,负责完成对数据库的各种操作。

  当创建一个SqlSession对象时,MyBatis会为这个SqlSession对象创建一个新的Executor执行器,而缓存信息就被维护在这个Executor执行器中。

image.png

  openSessionFromDataSource方法会调用父类BaseExecutor的构造方法创建执行器

  • BaseExecutor#BaseExecutor
protected BaseExecutor(Configuration configuration, Transaction transaction) {
  this.transaction = transaction;
  this.deferredLoads = new ConcurrentLinkedQueue<>();
  // 创建PerpetualCache对象
  this.localCache = new PerpetualCache("LocalCache");
  this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
  this.closed = false;
  this.configuration = configuration;
  this.wrapper = this;
}

  一级缓存实际上就是使用PerpetualCache维护的,PerpetualCache实现原理其实很简单,其内部就是通过一个简单的HashMap<k,v> 来实现的。

  • PerpetualCache类
public class PerpetualCache implements Cache {

  private final String id;

  private final Map<Object, Object> cache = new HashMap<>();

  // 部分代码省略
}

  对一级缓存的操作实则是对HashMap的操作。

缓存应用

  还是基于之前的简单查询例子分析

  • BaseExecutor#query
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler)
    throws SQLException {
  // 从MappedStatement对象中获取BoundSql对象
  BoundSql boundSql = ms.getBoundSql(parameterObject);
  // 获取缓存Key
  CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
  return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

   CacheKey=Statement Id + Offset + Limmit + Sql + Params。只要两条SQL的这五个值相同,即可以得到相同的CacheKey,也就是同一条Sql语句。

@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
    CacheKey key, BoundSql boundSql) throws SQLException {
    
 // 部分代码省略
 
  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--;
  }
  
 // 部分代码省略
 
  return list;
}

缓存执行时序图

image.png

清空缓存

  • BaseExecutor#commit/rollback
public void commit(boolean required) throws SQLException {
  if (closed) {
    throw new ExecutorException("Cannot commit, transaction is already closed");
  }
  // 清空缓存
  clearLocalCache();
  flushStatements();
  if (required) {
    transaction.commit();
  }
}

  SqlSession 中执行 commit/rollback 操作(插入、更新、删除)会清空 SqlSession 中的一级缓存,保证缓存中始终保存的是最新的信息,避免脏读。

   MyBatis一级缓存的生命周期和SqlSession一致,简单地使用了HashMap来维护。

什么是二级缓存

   MyBatis的二级缓存是mapper级别(Application级别的缓存,多个SqlSession可以共用二级缓存,跨SqlSession的) 的缓存,它可以提高对数据库查询的效率。

缓存初始化

   如果配置了"cacheEnabled=true(默认是true)",那么MyBatis在为SqlSession对象创建Executor对象时,会创建一个CachingExecutor象,这时SqlSession使用CachingExecutor对象来完成操作请求。

image.png

  在MyBatis的映射XML中配置cache或者 cache-ref 。cache标签用于声明这个namespace使用二级缓存。

image.png

   CachingExecutor持有了TransactionalCacheManager,即上述代码中的tcm。 TransactionalCacheManager中持有了一个Map,保存了Cache和用TransactionalCache包装后的Cache的映射关系。

Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>()

缓存应用

image.png

我们通过上述入口代码开始分析,从CachingExecutorquery方法展开。

  • CachingExecutor#query
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler,
    CacheKey key, BoundSql boundSql) throws SQLException {
  Cache cache = ms.getCache();
  // 如果配置文件中没有配置 <cache>,则 cache 为空
  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);
        // 存入到 entriesToAddOnCommit 这个Map中,而非真实的缓存对象 delegate 中
        tcm.putObject(cache, key, list); // issue #578 and #116
      }
      return list;
    }
  }
  return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

  存储二级缓存对象是放到了TransactionalCache.entriesToAddOnCommit这个map中,而非真实的缓存对象 delegate 中,因为直接存到 delegate 会导致脏数据问题。

  直接存到 delegate 为什么会导致脏数据问题? image.png

  从图上可以看到,如果一个事务没提交就直接更新到缓存中,另外的事务就很有可能读到脏数据。

  SqlSession提交或关闭之后二级缓存才会生效,这样就解决了上面的问题。

  • TransactionalCache#flushPendingEntries
public void commit() {
  if (clearOnCommit) {
    delegate.clear();
  }
  flushPendingEntries();
  reset();
}


private void flushPendingEntries() {
  //  将entriesToAddOnCommit的对象添加到delegate中,二级缓存才真正的生效
  for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
    delegate.putObject(entry.getKey(), entry.getValue());
  }
  for (Object entry : entriesMissedInCache) {
    if (!entriesToAddOnCommit.containsKey(entry)) {
      delegate.putObject(entry, null);
    }
  }
}

缓存执行流程

image.png

总结

  MyBatis 缓存是执行 SQL 优先从缓存中查询,查询不到再查数据库。

  MyBatis 默认开启一级缓存,它有两个级别SESSION或者STATEMENT,默认是SESSION级别,一级缓存的作用域是 SqlSession。SqlSession执行insert/delete/update或者session.close()方法后,会清空一级缓存。

  MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享(Mapper 级别的)。二级缓存开启后,同一个NameSpace下的所有操作语句,都影响着同一个Cache,是一个全局的变量。在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据。