阅读 580

从源码层面谈谈mybatis的缓存设计

从源码聊聊mybatis一次查询都经历了些什么一文中我们梳理了mybatis执行查询SQL的具体流程,在Executor中简单提到了缓存。本文将从源码一步一步详细解析mybatis缓存的架构,以及自定义缓存等相关内容。由于一级缓存是写死在代码里面的,所以本文重点讨论的是二级缓存,下文中提到的缓存如果没有特别指定的话都是指二级缓存。

自定义缓存

实现自定义缓存非常简单,只需要实现org.apache.ibatis.cache.Cache接口,然后为需要的Mapper配置实现就可以了。
下面的代码是一个简单的缓存实现

@Slf4j
public class MyCache implements Cache, InitializingObject {
    private String id;
    private String key;
    private Map<Object, Object> table = new ConcurrentHashMap<>();
    
    public MyCache(String id) {
        this.id = id;
    }

    @Override
    public void initialize() throws Exception {
        log.info("id = {}", id);
        log.info("key = {}", key);
    }
    // ......
}
复制代码

使用注解方式为Mapper配置缓存,使用XML配置也是类似的

@Mapper
@CacheNamespace(
        // 指定实现类
        implementation = MyCache.class,
        // 指定淘汰策略(也实现了Cache接口),mybatis通过装饰者模式实现淘汰策略
        // 只有当implementation是PerpetualCache时才会生效
        eviction = LruCache.class,
        // 配置缓存属性,mybatis会将对应的属性注入到缓存对象中
        properties = {
                @Property(name = "key", value = "hello mybatis")
        }
)
public interface AddressMapper {
    // ......
}
复制代码

缓存对象的创建

缓存是何时创建的呢?我们不妨想一下,缓存是配置在Mapper上的,那么应该会在解析Mapper的时候顺便把缓存配置也解析了吧。我们不妨先看看Mapper配置解析的代码,Configuration类添加Mapper时会调用org.apache.ibatis.binding.MapperRegistryaddMapper方法,如下所示,很直观的,这里使用了一个叫做MapperAnnotationBuilder的类来解析Mapper注解。

  public <T> void addMapper(Class<T> type) {
    if (type.isInterface()) {
        knownMappers.put(type, new MapperProxyFactory<T>(type));
        MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
        parser.parse();
    }
  }
复制代码

那么我们关注一下这个类的parse方法,非常棒,我们一下子就找到了解析缓存配置的地方。

  public void parse() {
    String resource = type.toString();
    if (!configuration.isResourceLoaded(resource)) {
      loadXmlResource();
      configuration.addLoadedResource(resource);
      assistant.setCurrentNamespace(type.getName());
      // 解析缓存
      parseCache();
      // 解析引用的缓存
      parseCacheRef();
      Method[] methods = type.getMethods();
      for (Method method : methods) {
          if (!method.isBridge()) {
            // 解析生成MappedStatement
            parseStatement(method);
          }
      }
    }
    parsePendingMethods();
  }
复制代码

parseCache方法也非常直观,简单粗暴,取出@CacheNamespace注解中的配置,然后传递给MapperBuilderAssistant#useNewCache方法创建缓存对象,MapperBuilderAssistant是构建Mapper的一个辅助类。

  private void parseCache() {
    CacheNamespace cacheDomain = type.getAnnotation(CacheNamespace.class);
    if (cacheDomain != null) {
      Integer size = cacheDomain.size() == 0 ? null : cacheDomain.size();
      Long flushInterval = cacheDomain.flushInterval() == 0 ? null : cacheDomain.flushInterval();
      // 把属性配置转成Properties对象
      Properties props = convertToProperties(cacheDomain.properties());
      assistant.useNewCache(cacheDomain.implementation(), cacheDomain.eviction(), flushInterval, 
                            size, cacheDomain.readWrite(), cacheDomain.blocking(), props);
    }
  }
复制代码

先把缓存对象添加到配置对象的注册表中,这样的话其他的Mapper就可以通过配置@CacheNamespaceRef来引用这个缓存对象了。然后设置缓存对象到辅助类的成员变量,在后面创建MappedStatement时候拿出使用。

  public Cache useNewCache(/* ... */) {
    Cache cache = new CacheBuilder(currentNamespace)
        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
        .clearInterval(flushInterval)
        .size(size)
        .readWrite(readWrite)
        .blocking(blocking)
        .properties(props)
        .build();
    // 添加到配置对象的缓存注册表中
    configuration.addCache(cache);
    // 设置为当前Mapper的缓存,后面构建MappedStatement的时候会用到
    currentCache = cache;
    return cache;
  }
复制代码

然后再看看CacheBuilder#build方法都干了些啥吧,具体细节我注释在下面的代码里面。

  public Cache build() {
    // 首先,确保实现类和淘汰策略为空的时候,设置默认的实现PerpetualCache和LruCache
    setDefaultImplementations();
    // 这里要求实现的缓存类必须提供一个带id参数的构造器,不然就会报错
    Cache cache = newBaseCacheInstance(implementation, id);
    // 设置通过@Property配置的属性到缓存对象中,然后如果实现了InitializingObject接口还会调用initialize方法
    setCacheProperties(cache);
    // 从下面这段逻辑可以看出来,我们配置的缓存淘汰策略只对默认缓存有效果
    // 自定义缓存需要自己实现淘汰策略
    if (PerpetualCache.class.equals(cache.getClass())) {
      for (Class<? extends Cache> decorator : decorators) {
        cache = newCacheDecoratorInstance(decorator, cache);
        setCacheProperties(cache);
      }
      cache = setStandardDecorators(cache);
    } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
      cache = new LoggingCache(cache);
    }
    return cache;
  }
复制代码

这个创建好的缓存是如何配置到MappedStatement中去的呢?回到MapperAnnotationBuilder#parse方法找到parseStatement(method),最终会调用到MapperBuilderAssistant#addMappedStatement()方法,下面代码就会把刚才创建的缓存对象设置到每个MappedStatement中去,由此可见mybatis二级缓存的作用域是整个Mapper的(如果被其他Mapper引用,还会扩张)

  public MappedStatement addMappedStatement(/* ... */) {
    id = applyCurrentNamespace(id, false);
    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
    MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType)
        /* ... */
        .flushCacheRequired(valueOrDefault(flushCache, !isSelect))
        .useCache(valueOrDefault(useCache, isSelect))
        .cache(currentCache);
    /* ... */
    MappedStatement statement = statementBuilder.build();
    configuration.addMappedStatement(statement);
    return statement;
  }
复制代码

到这里终于是把我们自定义的缓存设置到了配置中了,接下来就是缓存的使用了。

缓存的使用

从源码聊聊mybatis一次查询都经历了些什么这篇文章中简单提到过缓存的使用是在CachingExecutor中。再把代码贴过来看一看:

public <E> List<E> query(/* ... */) throws SQLException {
  // 这里就取到前面设置到ms(MappedStatement)中的缓存对象了
  Cache cache = ms.getCache();
  if (cache != null) {
    // 通过上面的配置就能知道,默认情况下除了select都需要清空
    flushCacheIfRequired(ms);
    if (ms.isUseCache() && resultHandler == null) {
      // 又懵逼了?这个tcm是啥
      List<E> list = (List<E>) tcm.getObject(cache, key);
      // 缓存未命中,查库
      if (list == null) {
        list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
        tcm.putObject(cache, key, list);
      }
      return list;
    }
  }
  return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
复制代码

一切都顺理成章了,不过半路杀出个程咬金,这个tcm(TransactionalCacheManager)是什么东西呢?看看下面这张图,mybatis的每次会话(SqlSession)都会创建一个tcm,这个tcm里面其实维护着一个HashMap,map的key就是Mapper的cache对象,value是一个使用TransactionalCache装饰的cache对象。 {% asset_img cache.svg mybatis缓存 %} 从名字就可以猜一猜,这个TransactionalCache应该是和事务有关系的,从下面的代码可以看出,putObject操作并没有直接添加到缓存中,而是先put到一个本地Map,然后再批量提交。getObject缓存未命中时会把key添加到一个本地的Set中,在未来批量提交的时候会把这个Set中的key也put到缓存中,value设置为null,来防止缓存穿透。

public class TransactionalCache implements Cache {
  public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
  }
  
  public Object getObject(Object key) {
    Object object = delegate.getObject(key);
    if (object == null) {
      // 未命中key添加到Set中
      entriesMissedInCache.add(key);
    }
    /* ... */
  }

  public void clear() {
    clearOnCommit = true;
    entriesToAddOnCommit.clear();
  }

  public void commit() {
    // clearOnCommit在TransactionalCache#clear方法被调用后设置为true
    // 此时才会在提交的时候清空delegate
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
  }

  private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    // 为未命中的key设置null
    for (Object entry : entriesMissedInCache) {
      if (!entriesToAddOnCommit.containsKey(entry)) {
        delegate.putObject(entry, null);
      }
    }
  }
}
复制代码

至于什么时候commit会被调用呢,我们再回看一下TransactionalCacheManager的commit,会提交当前SqlSession所有Mapper的缓存,而TransactionalCacheManager的commit是在CachingExecutor的commit中调用的,而Executor的commit又依赖与SqlSession的commit操作,也就是说,如果我们不手动调用SqlSession的commit的话,就只能等到SqlSession关闭的时候才会提交这个查询缓存。

  public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
    }
  }
复制代码

从源码我们不难发现CachingExecutor在每次调用update方法的时候,都会先清空TransactionalCache的本地的HashMap,然后在提交的时候再清空Mapper的缓存。因此,在更新操作比较频繁的场景下,二级缓存反而不会起到很好的作用。所以是否开启二级缓存,还要取决于业务场景。可能大部分的场景下,关闭二级缓存都是一个比较不错的方案。

关注下面的标签,发现更多相似文章
评论