MyBatis一级缓存源码分析

939 阅读7分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战


1. 前言

为了提升持久层数据查询的性能,MyBatis提供了一套缓存机制,根据缓存的作用范围可分为:一级缓存和二级缓存。一级缓存默认是开启的,作用域为SqlSession,也称作【回话缓存/本地缓存】。二级缓存默认是关闭的,需要手动开启,作用域为namespace。本篇文章暂且不讨论二级缓存,仅从源码的角度分析一下MyBatis一级缓存的实现原理。 ​

我们已经知道,SqlSession是MyBatis对外提供的,操作数据库的唯一接口。当我们从SqlSessionFactory打开一个新的回话时,一个新的SqlSession实例将被创建。SqlSession内置了一个Executor,它是MyBatis提供的操作数据库的执行器,当我们执行数据库查询时,最终会调用Executor.query()方法,它在查询数据库前会先判断是否命中一级缓存,如果命中就直接返回,否则才真的发起查询操作。 ​

2. 源码分析

我们直接看Executor.query()方法,它首先会根据请求参数ParamMap解析出要执行的SQL语句BoundSql,然后创建缓存键CacheKey,然后调用重载方法。

@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
  // 根据参数解析出要执行的SQL
  BoundSql boundSql = ms.getBoundSql(parameter);
  // 创建缓存键
  CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
  // 执行查询
  return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

因此我们重点看query重载方法,它首先会向ErrorContext报告自己正在做查询,然后判断是否需要清空缓存,如果你在SQL节点配置了flushCache="true"则不会使用一级缓存。之后就是尝试从一级缓存中获取结果,如果命中缓存则直接返回,否则调用queryFromDatabase查询数据库,在queryFromDatabase方法中,会再将查询结果存入一级缓存。

@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
  List<E> list;
  try {
    // 试图从一级缓存中获取数据
    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);
    }
  }
  return list;
}

因此,我们重点看localCache对象。 ​

2.1 PerpetualCache

localCache是PerpetualCache类的实例,译为【永久缓存】,因为它不会主动删除缓存,但是会在事务提交或执行更新方法时清空缓存。PerpetualCache是缓存接口Cache的子类,分析子类前,我们应该先看它的接口。 ​

如下是Cache接口,它的职责很简单,就是维护结果集缓存,对缓存提供了【增删查】的API。

public interface Cache {

  // 获取缓存唯一标识符
  String getId();

  // 添加缓存项
  void putObject(Object key, Object value);

  // 根据缓存键获取缓存
  Object getObject(Object key);

  // 根据缓存键删除缓存
  Object removeObject(Object key);

  // 清空缓存数据
  void clear();
}

PerpetualCache的实现非常简单, 内部使用一个HashMap容器来维护缓存,Key存放的是CacheKey,Value存放的是结果集,代码就不贴了,如下是它的属性。

public class PerpetualCache implements Cache {

  // 缓存唯一表示
  private final String id;

  // 使用HashMap作为数据缓存的容器
  private final Map<Object, Object> cache = new HashMap<>();
}

2.2 CacheKey

CacheKey是MyBatis提供的缓存键,为什么要为缓存键单独写一个类呢?因为MyBatis判断是否命中缓存,条件非常多,并不是一个简单的字符串就可以搞定的,因此才有了CacheKey。 ​

如何判断查询能否命中缓存?有哪些条件需要判断呢?总结如下:

  1. StatementID要相同,必须执行的是同一个接口的同一个方法。
  2. RowBounds要相同,查询的数据范围必须一致。
  3. 执行的SQL语句要相同。
  4. 预编译的SQL填充的参数要相同。
  5. 查询的数据源要相同,即EnvironmentID相同。

以上五个条件,必须同时满足,才能命中缓存。而且,像【填充的参数】这类数据是不固定的,因此CacheKey使用一个List来存放这些条件,如下是它的属性:

  1. DEFAULT_MULTIPLIER:默认参与哈希值计算的倍数因子。
  2. DEFAULT_HASHCODE:默认哈希值。
  3. multiplier:与哈希值计算的倍数因子,默认37。
  4. hashcode:哈希码,提高equals的效率。
  5. checksum:校验和,哈希值的和。
  6. count:updateList元素的个数。
  7. updateList:条件列表,必须满足所有条件,才能命中缓存。

能否命中缓存,就看CacheKey是否相等了。为了提升equals方法的性能,避免每次挨个比较updateList,CacheKey使用hashcode来保存哈希值,哈希值是根据每个条件对象参与计算得出的,MyBatis提供了一套算法,尽可能的让哈希值分散。 ​

调用update方法可以添加条件对象,源码如下:

public void update(Object object) {
  // 计算单个对象的哈希值
  int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
  // 条件对象个数递增
  count++;
  // 累加校验和
  checksum += baseHashCode;
  // 基础哈希值乘以条件数量,使哈希值尽可能分散
  baseHashCode *= count;
  // 最终哈希值,再乘以质数17,还是为了分散
  hashcode = multiplier * hashcode + baseHashCode;
  // 添加条件对象到List
  updateList.add(object);
}

equals方法用于判断两个CacheKey是否相等,为了提升性能,会先进行一系列的简单校验,最后才是按个匹配每个条件对象。

@Override
public boolean equals(Object object) {
  // 前置一系列校验省略...
  // 以上步骤,都是为了提升equals的性能。最终还是比较updateList的每一项
  for (int i = 0; i < updateList.size(); i++) {
    // 依次比较updateList各项条件
  }
  return true;
}

如果CacheKey相等,则代表命中缓存。 ​

2.3 创建缓存键

前面已经说过,调用query方法时,首先会创建CacheKey,根据CacheKey判断能否命中缓存,最后,我们看一下CacheKey的创建过程。 ​

createCacheKey方法位于BaseExecutor,CacheKey的创建过程并不复杂,就是实例化一个CacheKey对象,然后将需要匹配的条件调用update方法保存下来。 ​

上文已经说过判断能否命中缓存的五大条件了,因此它需要MappedStatement获取StatementID、需要parameterObject获取请求参数、需要RowBounds获取分页信息、需要BoundSql获取要执行的SQL。

/**
 *
 * @param ms 执行的SQL节点封装对象
 * @param parameterObject 参数,一般是ParamMap
 * @param rowBounds 分页信息
 * @param boundSql 绑定的SQL
 * @return
 */
@Override
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
  // 实例化缓存键
  CacheKey cacheKey = new CacheKey();
  // StatementId要一致,必须调用的是同一个Mapper的同一个方法
  cacheKey.update(ms.getId());
  // 分页信息,查询的数据范围要一致
  cacheKey.update(rowBounds.getOffset());
  cacheKey.update(rowBounds.getLimit());
  // 执行的SQL要一致,动态SQL的原因,SQL都不一致,肯定不能命中缓存
  cacheKey.update(boundSql.getSql());
  /**
   * 除了上述基本的四项,还要匹配所有的参数。
   * 针对同一个查询方法,传入的参数不同,肯定也不能命中缓存。
   * 下面是从参数ParamMap中获取对应参数的过程。
   */
   cacheKey.update(参数);
  // 校验运行环境,查询的数据源不同,也不能命中缓存。
    if (configuration.getEnvironment() != null) {
    cacheKey.update(configuration.getEnvironment().getId());
  }
  return cacheKey;
}

CacheKey创建完毕后,就是从PerpetualCache中判断是否已经存在缓存数据了,如果命中缓存就直接取缓存结果,避免查询数据库,提升查询性能。 ​

继续看BaseExecutor的其他代码你会发现,PerpetualCache虽然自己不会主动清理缓存,但是只要执行了update语句、或者事务提交/回滚都会清空缓存。

3. 总结

一级缓存是基于SqlSession的,当一个会话被打开时,它会同时创建一个Executor,Executor内部持有一个PerpetualCache,PerpetualCache底层使用一个HashMap容器来维护缓存结果集。HashMap中Key存储的是CacheKey,它是MyBatis提供的缓存键,因为判断是否命中缓存涉及的条件非常多,因此CacheKey使用一个List来保存条件对象,只有当所有的条件都匹配时,才能命中缓存。 ​

MyBatis的一级缓存其实还是比较鸡肋的,在多会话的场景下存在脏数据的问题,SessionA读了一遍数据,SessionB修改了该数据,SessionA再去读仍然是旧数据。不过,如果你使用Spring整合MyBatis就不用担心这个问题了。