MyBatis一级缓存源码分析

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


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方法中,会再将查询结果存入一级缓存。markdown

@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对象。 ​app

2.1 PerpetualCache

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

以下是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存放的是结果集,代码就不贴了,以下是它的属性。post

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就不用担忧这个问题了。

相关文章
相关标签/搜索