聊聊 Mybatis 缓存

image.png

聊聊 Mybatis 缓存

mybatis 作为国内使用率最高(可能)的 ORM 框架,其实也是有提供缓存功能的~

前言

在正文开始前,我们先来看这样一个问题:

大家都知道 Oracle 的一大特点就是提供了序列功能,目前很多业务中的 id,序号等都采用 DB 序列生成,类似这样:

    select seq_xxx.nextval from dual;
复制代码

一般来说,大家都会把 sql 交给 mybatis 去管理,获取序列也不例外。

那么问题就来了:

如果我在同一个事务中,使用 mybatis 去执行两次上面的 sql 语句,得到的值竟然是一样的!实际上,如果你细心的话,还可以发现只有第一次查询序列时去真正执行了 sql,而第二次,便是使用了 mybatis 缓存。

缓存 key

接下来就是缓存的第一大问题,设计缓存 key。

mybatis 为了让缓存真正得只对相同的 sql 生效,设计了一个包含了很多元素的缓存 key。

缓存属性 描述
mapper.id mapper 方法的全限定名
rowbounds-limit mybatis 自带的假分页-limit 默认为 0
rowbounds-offset mybatis 自带的假分页-offset 默认为 Integer.MAX_VALUE
sql 对应的 sql(未填充参数的形式)
param 传入的参数
envConfiguration 当前 myabtis 环境配置的 bean Id

把上面这些属性都拼接到一起,会得到一个类似下面这样的缓存属性:

git.frank.tuning.mapper.UserMapper.findById:0:2147483647:select * from user where id = ?:1:SqlSessionFactoryBean
复制代码

这样看,是不是这个 key 太长了呢?

所以,mybatis 定了一个 CacheKey 类,并利用 hashCode 方法非常巧妙的大大降低了缓存 key 的长度。

  public void update(Object object) {
    int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);

    count++;
    checksum += baseHashCode;
    baseHashCode *= count;

    hashcode = multiplier * hashcode + baseHashCode;

    updateList.add(object);
  }
复制代码

而对应的 equals 方法,则是分别对原始拼接的对象进行比较:

@Override
public boolean equals(Object object) {
  ...
    for (int i = 0; i < updateList.size(); i++) {
    Object thisObject = updateList.get(i);
    Object thatObject = cacheKey.updateList.get(i);
    if (!ArrayUtil.equals(thisObject, thatObject)) {
          return false;
    }
  }
    ...
}
复制代码

一级缓存

简单来说,一级缓存就是对同一个 session/statement 中相同 sql 的缓存。

可以在 org.apache.ibatis.executor.BaseExecutor 中找到真正执行的缓存代码。

 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);
 }
复制代码

缓存实现

由于一级缓存范围很小,最大程度也只是对同一个 sqlSession 生效。

mybatis 中一级缓存直接使用 HashMap 封装了缓存实现。

public class PerpetualCache implements Cache {

  private Map<Object, Object> cache = new HashMap<>();
  
  @Override
  public void putObject(Object key, Object value) {...}
  @Override
  public Object getObject(Object key) {...}
  @Override
  public Object removeObject(Object key) {...}
  @Override
  public int getSize() {...}
}
复制代码

加入缓存/删除缓存

一级缓存的加入与删除比较简单,也是对大家对缓存使用的通用做法。

首先,查询操作优先使用缓存查询:

list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
复制代码

如果缓存不存在的话,再进入数据库查询:

list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
复制代码

queryFromDatabase 方法内,封装了缓存执行中,双删除,并加入缓存的动作。

localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
      localCache.removeObject(key);
}
localCache.putObject(key, list);
复制代码

在有更新,删除的 sql 执行时,mysql 采用了直接清空全部缓存的策略(简单粗暴,但是有效)。

@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
      ...
      clearLocalCache();
      return doUpdate(ms, parameter);
}
复制代码

二级缓存

二级缓存的缓存 key 与一级缓存使用的是同一个。

二级缓存的缓存范围更广,将 session 级别提升到了 mapper namespace 级别。使得同一个 mapper namespace 下的缓存所有 session 可以共享。

缓存实现

缓存管理:
org.apache.ibatis.cache.TransactionalCacheManager

两阶段提交:
org.apache.ibatis.cache.decorators.TransactionalCache

首先,由于各个缓存的 namespase 是独立的。所以需要由TransactionalCacheManager来统一维护各个缓存。

public class TransactionalCacheManager {

  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
  
  public void clear(Cache cache) {...}
  public Object getObject(Cache cache, CacheKey key) {...}
  public void putObject(Cache cache, CacheKey key, Object value) {...}

}
复制代码

另外,由于 mybatis 二级缓存的范围大大提升,为了避免其他事务通过缓存读到事务还未提交的数据,所以二级缓存需要在事务提交后才能生效。

所以,对应的 TransactionalCacheManager 中也会有 commitrollback 方法。

针对缓存的提交/回滚,mybatis 也是采用了简单粗暴的策略:

首先在 TransactionalCache 中,有三个这样的属性:

  private final Cache delegate;
  private final Map<Object, Object> entriesToAddOnCommit;
  private final Set<Object> entriesMissedInCache;
复制代码

其中,delegate 为真正的缓存实现,entriesToAddOnCommit为在事务提交后,需要加入至缓存的对象,而entriesMissedInCache 为未命中的缓存 key。

加入缓存

mybatis 在查询时会优先查二级缓存,如果二级缓存为空,再走一级缓存查询,最后再加入缓存。

        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); // issue #578 and #116
        }
复制代码

值得注意的是,二级缓存这里的加入缓存并不是真的加入缓存 ~

来看二级缓存的实现:org.apache.ibatis.cache.decorators.TransactionalCache

我们刚才调用的 putObject 方法,实际上并没有真正的把缓存对象加入进去,而是放在了一个待提交列表中。

  @Override
  public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
  }
复制代码

另外,在查询缓存方法:getObject 中,还同时记录了缓存未命中的 key(entriesMissedInCache)。

  @Override
  public Object getObject(Object key) {

    Object object = delegate.getObject(key);
    if (object == null) {
      entriesMissedInCache.add(key);
    }

    if (clearOnCommit) {
      return null;
    } else {
      return object;
    }
  }
复制代码

这样,在缓存进行提交时,就可以只保留发生过未命中 key 的记录,而将没有进行过查询的缓存都进行丢弃。

  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 flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {
      tcm.clear(cache);
    }
  }
复制代码

如果你需要在某些读操作上也执行刷新缓存(例如获取序列的 select 语句),需要在对应方法上加 flushCache = TRUE 配置:

    @Select("select * from user where name = #{name} limit 2")
    @Options(flushCache = Options.FlushCachePolicy.TRUE)
    List<User> findByName(String name);
复制代码

该配置对一级缓存也同样会生效。

    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
复制代码

总结

mybatis 对 SQL 执行也提供了一个简单的缓存行为。

其中,一级缓存直接使用了 HashMap 来实现,并且不考虑数据的关联关系:当发生写操作后,直接把整个缓存清空。

由于一级缓存在设计上只是为了防止同 session 内重复执行相同 sql ,缓存范围小,全部清空也只是对本 session 内的一级缓存,问题不大。

对二级缓存而言,mybatis 不是默认开启的,缓存的设计上也较一级缓存稍微复杂一点,还提供了 LRU 的淘汰策略。而缓存范围,二级缓存覆盖了同 namespace 下的所有 sql 执行,实现了 sqlSession 间共享。

image.png

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享