MyBatis 缓存原理解析

为什么 MyBatis 要缓存

缓存在互联网系统中是非常重要的, 其主要作用是将数据保存到内存中, 当用户查询数据时, 优先从缓存容器中获取数据,而不是频繁地从数据库中查询数据,从而提高查询性能。而在 ORM 框架中引入缓存的目的就是为了减少读取数据库的次数,从而提升查询的效率。

在 MyBatis 中存在两种缓存,一个在事务内部使用的一级缓存,另一个可以全局使用的二级缓存。

一级缓存

一级缓存也叫本地缓存,在MyBatis中,一级缓存是在会话(SqlSession)层面实现的,这就说明一级缓存作用范围只能在同一个 SqlSession 中,跨 SqlSession 是无效的。

需要注意的是:MyBatis 中一级缓存是默认开启的,不需要任何的配置。

既然一级缓存的作用域只对同一个 SqlSession 有效,那么一级缓存应该存储在哪里比较合适是呢?

当然是 SqlSession 内是最合适的,下面我们看看 SqlSession 的唯一实现类 DefaultSqlSession,如下图所示:
在这里插入图片描述
我们知道,SqlSession 只提供对外接口,实际执行 sql 的就是 Executor。
下面是查询的执行流程:

在这里插入图片描述每个 SqlSession 实例中都有一个 Executor 对象,Executor 对象执行查询操作。

Executor有多个实现,无论哪种实现,查询的时候都会执行父类 BaseExecutor 的 query 方法。

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

从这里我们可以看到,在查询之前就会去 localCache 中根据 CacheKey 对象来获取缓存,获取不到才会调用下面的 query 方法中的 queryFromDatabase 方法。

接下来看一下缓存的 key 是怎么创建的。
缓存的 key 使用 CacheKey 表示。MyBatis 使用 createCacheKey 方法创建 CacheKey 对象,如下所示:

  @Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    CacheKey cacheKey = new CacheKey();
    cacheKey.update(ms.getId());//Mapper接口的全限定类名+方法名
    cacheKey.update(rowBounds.getOffset());//查询数据的偏移量
    cacheKey.update(rowBounds.getLimit());//查询条数
    cacheKey.update(boundSql.getSql());//原始SQL语句
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    // mimic DefaultParameterHandler logic
    for (ParameterMapping parameterMapping : parameterMappings) {
      if (parameterMapping.getMode() != ParameterMode.OUT) {
        Object value;
        String propertyName = parameterMapping.getProperty();
        if (boundSql.hasAdditionalParameter(propertyName)) {
          value = boundSql.getAdditionalParameter(propertyName);
        } else if (parameterObject == null) {
          value = null;
        } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
          value = parameterObject;
        } else {
          MetaObject metaObject = configuration.newMetaObject(parameterObject);
          value = metaObject.getValue(propertyName);
        }
        cacheKey.update(value);
      }
    }
    if (configuration.getEnvironment() != null) {
      // issue #176
      cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
  }

创建缓存 key 会经过一系列的 update 方法,update 方法由一个 createKey 对象来执行,这个 update 方法最终由 updateList 的 list 来把五个值存进去,对照上面的代码和下面的图示,我们可以理解这五个值是什么了,如下图:

在这里插入图片描述
CacheKey 集合了 SQL 语句、SQL 参数、调用的 Mapper 方法名、SQL 分页参数、Environment 对象信息。当比较两个 CacheKey 是否相等时,上述这些数据都要参与比较,而且 CacheKey 对象哈希值也是基于这些数据生成的。因此 CacheKey 重写了 hashCode(),equals()。

下面看一下 cacheKey.update 方法就可以明白 hashCode(),equals() 是如何做的了。

  //每次向CacheKey添加数据时,都要调用该方法
  public void update(Object object) {
  	//计算入参对象的hash值
    int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); 
   
    count++;
    checksum += baseHashCode;
    baseHashCode *= count;
    //计算CacheKey对象哈希值
    //hashCode()方法返回的就是hashcode值
    //multiplier = 37
    hashcode = multiplier * hashcode + baseHashCode;
	//updateList是List对象,用于保存原始数据,比较两个CacheKey是否相等,要比较updateList中的数据
    updateList.add(object);
  }

创建完 CacheKey 之后,我们继续进入 query 方法:

 @SuppressWarnings("unchecked")
  @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) {//检测当前Executor是否已经被关闭
      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
        //如果设置localCacheScope=STATEMENT,则清空缓存
        clearLocalCache();
      }
    }
    return list;
  }

在 query 方法中,如果查不到的话,就去数据库查询,在 queryFromDatabase 中会对 localCache 进行写入,localCache 对象的 put 方法最终会交给 Map 进行存放,如下所示:

  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;
  }
 
  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }

二级缓存

一级缓存因为只能在同一个 SqlSession 中共享,所以会有一个问题,在分布式或者多线程的环境下,不同会话之间对于相同的数据可能会产生不同的结果,因为跨会话修改了数据是不能互相感知的,所以就有可能存在脏数据的问题,正因为一级缓存存在这种不足,所以我们需要一种作用域更大的缓存,这就是二级缓存。

二级缓存默认是关闭的,开启二级缓存需要三步操作,如下:

1、在 mybatis-config 中有一个全局配置属性,这个不配置也行,因为默认就是 true。

<setting name="cacheEnabled" value="true"/>

2、在 Mapper 映射文件内需要配置缓存标签:

<cache/><cache-ref namespace="com.lonelyWolf.mybatis.mapper.UserAddressMapper"/>

3、在select查询语句标签上配置useCache属性,如下:

<select id="selectUserAndJob" resultMap="JobResultMap2" useCache="true">
        select * from lw_user
    </select>

以上配置第1点是默认开启的,也就是说我们只要配置第 2 点就可以打开二级缓存了,而第 3 点是当我们需要针对某一条语句来配置二级缓存时候则可以使用。

不过开启二级缓存的时候有两点需要注意:

1、需要 commit 事务之后才会生效
2、如果使用的是默认缓存,那么结果集对象需要实现序列化接口(Serializable)

如果不实现序列化接口则会报错误。

二级缓存是通过 CachingExecutor 对象来实现的,接下来我们来看看这个对象都有些什么。

在这里插入图片描述
我们看到 CachingExecutor 中只有 2 个属性,Executor 就不用说了,因为 CachingExecutor 本身就是 Executor 的包装器,所以属性 TransactionalCacheManager 可以确定就是用来管理二级缓存的,我们再进去看看 TransactionalCacheManager 对象是如何管理缓存的:

在这里插入图片描述
TransactionalCacheManager 中维护了一个 HashMap 来存储缓存。HashMap 中的 value 是一个TransactionalCache 对象,继承了 Cache,我们在进去看看。

public class TransactionalCache implements Cache {

  private static final Log log = LogFactory.getLog(TransactionalCache.class);

  private final Cache delegate;
  private boolean clearOnCommit;
  private final Map<Object, Object> entriesToAddOnCommit;
  private final Set<Object> entriesMissedInCache;

我们可以看到其中也维护了一个 Map, 这个 Map 是暂存区真正用来暂存数据的地方, 而 delegate 属性代表的便是真正的缓存区, 有了与缓存区之间的关联, 在提交事务的时候, 就可以方便的把暂存区的数据刷新到缓存区了。

介绍完事务管理器, 暂存区, 缓存区之间的结构关系, 我们来通过源码看下二级缓存进行查询的过程,如下:

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();//获取二级缓存
    //全局配置文件默认开启,假如 Mapper.xml 文件没有开启二级缓存,这里就会是 null 值
    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) {
        //如果二级缓存没有获取到,就回去执行原来的 Executor 中的 query 方法,也就是会再去读取一级缓存
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          //二级缓存存储时会先保存在一个临时的属性中,直到事务提交才会保存到二级缓存
          //存储二级缓存
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

需要注意的是在事务提交之前,并不会真正存储到二级缓存,而是先存储到一个临时属性,等事务提交之后才会真正存储到二级缓存。这么做的目的就是防止脏读。因为假如你在一个事务中修改了数据,然后去查询,这时候直接缓存了,那么假如事务回滚了呢?所以这里会先临时存储一下。所以我们看一下 commit 方法:

在这里插入图片描述

在提交的方法中, 我们会把暂存区中的所有内容刷新到缓存区中。

在我们调用 sqlSession.commit() 方法的时候, 也会调用当前会话持有的缓存执行器的 commit() 方法, 缓存执行器会执行事务缓存管理器的 commit() 方法。看一下事务缓存管理器的提交的源码, 在事务缓存管理器的 commit() 方法中, 会调用事务缓存管理器所有暂存区 (TransactionalCache) 的 commit() 方法。

在 TransactionalCache 的 commit() 方法中, 如果有未提交的更新操作( clearOnCommit 为 true), 则要清空缓存区, 因为更新后, 缓存区的数据便是不准确的了。随后调用 flushPendingEntries 和 reset 两个方法, flushPendingEntries 方法负责把所有暂存区的内容刷新到缓存中。而 reset 方法则负责把本地暂存区清空, 同时把 clearOnCommit 置为 false。

public class TransactionalCache implements Cache:
private final Cache delegate;//指向缓存区(链条式的 Cache 实现类)
private boolean clearOnCommit;//执行更新后 clearOnCommit 将变为 true
private final Map<Object, Object> entriesToAddOnCommit;//本地暂存
public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
}
private void flushPendingEntries() {
    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);
      }
    }
}
private void reset() {
    clearOnCommit = false;
    entriesToAddOnCommit.clear();
    entriesMissedInCache.clear();
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值