你真的了解Mybtatis的缓存机制吗?

632 阅读12分钟

一级缓存

1.工作流程

在我们的应用与DB交互过程中,可能会出现在在一次的会话(SqlSession)中多次执行相同的SQl语句,MyBatis提供了一级缓存的方案优化这部分场景,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。执行流程如下图:

前面的文章介绍过,每一个SqlSession中都持有一个Executor,而每一个Executor中都有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。

2.生命周期

Mybatis的一级缓存是基于同一个SqlSession的,他们的生命周期也是一致的。

  • SqlSession在创建的时候会创建一个Executor对象,Executor中有一个localCache(PerpetualCache对象)属性,PerpetualCache就是缓存对象。
  • 如果SqlSession调用了clearCache(),会清空PerpetualCache对象中的数据,但是该对象仍可使用。
  • SqlSession中执行了任何一个update操作(update()delete()insert()) ,都会清空PerpetualCache对象的数据,但是该对象可以继续使用。
  • 如果SqlSession调用了close() 方法,会释放掉一级缓存PerpetualCache对象,一级缓存将不可用。

3.源码分析

3.1 设置缓存级别

一级缓存的范围有SESSIONSTATEMENT两种,默认是SESSION,如果不想使用一级缓存,可以把一级缓存的范围指定为STATEMENT,这样每次执行完一个Mapper中的语句后都会将一级缓存清除。 如果需要更改一级缓存的范围,可以在Mybatis的配置文件中,通过localCacheScope指定。

<setting name="localCacheScope" value="STATEMENT"/>

如果不指定,默认是SESSION级别,如下图解析xml代码:

  private void settingsElement(Properties props) {
    ...
    // 默认值使用SESSION
    configuration.setLocalCacheScope(LocalCacheScope.valueOf(props.getProperty("localCacheScope", "SESSION")));
    ...
  }

3.2 写缓存

3.2.1 获取缓存key

SqlSession在执行数据库操作时会委托给Executor执行。有一个和缓存有关Executor叫CachingExecutor。我们不妨先来看下CachingExecutorquery方法:

 @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    //获取sql语句
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    //创建缓存key
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    //调用query方法
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

这里在查询前,先创建了一级缓存的key。我们来看下CachingExecutorcreateCacheKey的方法:

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    return delegate.createCacheKey(ms, parameterObject, rowBounds, boundSql);
  }

delegate是委托对象。所以我们重点来看下BaseExecutorcreateCacheKey的方法:

 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());
    cacheKey.update(rowBounds.getOffset());
    cacheKey.update(rowBounds.getLimit());
    cacheKey.update(boundSql.getSql());
    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;
  }

在上述的代码中,将MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数传入了CacheKey这个类,最终构成CacheKey。在CacheKeyupdate方法中,会进行一个hashcodechecksum的计算,同时把传入的参数添加进updatelist中。如下代码所示:

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

除去hashcode、checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条SQL的下列五个值相同,即可以认为是相同的SQL。

Statement Id + Offset + Limmit + Sql + Params

3.2.2 CachingExecutor是处理二级缓存的

我们继续回到CachingExecutor的query方法:

  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    //获取 MappedStatement 中的 Cache cache 属性。
    Cache cache = ms.getCache();
    if (cache != null) {
      flushCacheIfRequired(ms);
      //是否   
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        //尝试使用key获取缓存
        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
        }
        return list;
      }
    }
    // 交给委托类
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

代码很简单,无非就是一个判断缓存的过程。但是可能看到这里的同学有疑问了,我们刚才说一级缓存的对象不是localCache吗,这里怎么是tcm(TransactionalCacheManager)? 你的怀疑没错,这里确实是缓存,传说中的二级缓存。也就是说,二级缓存的的判断在一级缓存之前。我们先记住这个结论,一会在探究二级缓存那些事。

那么问题来了,一级缓存在哪呢? 不妨往下看CachingExecutor委托类BaseExecutor的query方法:

 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) {
      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 {
      //从db查询
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }
    } finally {
      queryStack--;
    }
    if (queryStack == 0) {
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      //如果是STATEMENT级别,删除缓存。
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
  }

ok,就是在这里。如果查不到的话,就从数据库查,在queryFromDatabase中,会对 localCache进行写入。在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。

注意:MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为STATEMENT

3.3 清除缓存

在源码分析的最后,我们确认一下,如果是insert/delete/update方法,缓存就会刷新的原因。 SqlSession的insert方法和delete方法,都会统一走update的流程,代码如下所示:

@Override
public int insert(String statement, Object parameter) {
    return update(statement, parameter);
  }
   @Override
  public int delete(String statement) {
    return update(statement, null);
}

update方法也是委托给了Executor执行,每次执行update方法都会清空缓存如下所示:

@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    //清除缓存
    clearLocalCache();
    return doUpdate(ms, parameter);
}

4.思考

  • MyBatis的一级缓存设计的比较简单,就简单地使用了HashMap来维护,并没有对HashMap的容量和大小进行限制,有内存溢出的风险。
  • 一级缓存是一个粗粒度的缓存,没有更新缓存和缓存过期的概念。
  • 多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement

二级缓存

1.工作流程

一级缓存最大的作用范围是同一个SqlSession内部,如果多个SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询,具体的工作流程如下所示。

二级缓存是基于namespace的,被多个SqlSession共享,是一个全局变量。

2.生命周期

mybatis的二级缓存是基于application为生命周期的。emmmm很持久。。

3.源码分析

3.1 开启缓存

3.1.1 cacheEnabled

还记得CachingExecutor吗?他在初始化的时候又一个条件就是:

if (cacheEnabled) {
      executor = new CachingExecutor(executor);
}

如果 cacheEnabled表示二级缓存机制标记,默认为true。缓存的实现类为 CachingExecutor,这里使用了经典的装饰模式,处理了缓存的相关逻辑后,委托给的具体的 Executor 执行。 cacheEnabled可以通过mybatis-config.xml 文件中指定。

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

3.1.2 cache和cache-ref标签

<cache>标签用于声明当前namespace使用二级缓存,并且可以自定义配置。

  • type:cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
  • eviction: 定义回收的策略,常见的有FIFO,LRU。
  • flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
  • size: 最多缓存对象的个数。
  • readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
  • blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存

cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。

<cache-ref namespace="mapper.XXX"/>

3.2 cache属性的创建

CachingExecutorquery方法,一上来就有一步获取cache属性的操作:

Cache cache = ms.getCache();

ms就是MappedStatement对象,它是在MapperBuilderAssistant的addMappedStatement方法创建的。

继续来追踪这个currentCache属性

useCacheRef和UseNewCache是不是很熟悉,好像和cachecache-ref标签有点关系。

果不其然,一个是注解的解析,一个是xml解析。我们来看下xml解析。

 private void cacheElement(XNode context) {
    if (context != null) {
      String type = context.getStringAttribute("type", "PERPETUAL");
      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
      String eviction = context.getStringAttribute("eviction", "LRU");
      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
      Long flushInterval = context.getLongAttribute("flushInterval");
      Integer size = context.getIntAttribute("size");
      boolean readWrite = !context.getBooleanAttribute("readOnly", false);
      boolean blocking = context.getBooleanAttribute("blocking", false);
      Properties props = context.getChildrenAsProperties();
      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
  }

再来看下useCacheRef:

 public Cache useCacheRef(String namespace) {
    if (namespace == null) {
      throw new BuilderException("cache-ref element requires a namespace attribute.");
    }
    try {
      unresolvedCacheRef = true;
      //获取某命名空间的缓存
      Cache cache = configuration.getCache(namespace);
      if (cache == null) {
        throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.");
      }
      //修改当前缓存
      currentCache = cache;
      unresolvedCacheRef = false;
      return cache;
    } catch (IllegalArgumentException e) {
      throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.", e);
    }
  }

对于同一个Mapper来讲,只能使用一个Cache,当同时使用了cachecache-ref时,cache定义的优先级更高,可以参考如下代码:

  private void configurationElement(XNode context) {
    try {
      String namespace = context.getStringAttribute("namespace");
      if (namespace == null || namespace.equals("")) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      builderAssistant.setCurrentNamespace(namespace);
      cacheRefElement(context.evalNode("cache-ref"));
      cacheElement(context.evalNode("cache"));
      ...
    } catch (Exception e) {
      throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
    }
  }

3.3 创建缓存

我们再次回看 CacheingExecutor 的查询方法:

 @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    //获取sql语句
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    //创建缓存key
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    //调用query方法
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

前面已经介绍过createCacheKey了,这里就不多提了,继续看query方法。

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();
    if (cache != null) {
       //刷新缓存
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        //取缓存
        //如果没有查到,会把key加入Miss集合,这个主要是为了统计命中率
        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
        }
        return list;
      }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

3.4 TransactionalCache 操作缓存

flushCacheIfRequired(ms) 如果不是查询语句的话,会清空缓存。

接下来我们说下tcm——TransactionalCacheManager,实际上它是一个Map:

private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

这个Map保存了Cache和用TransactionalCache包装后的Cache的映射关系。

TransactionalCache实现了Cache接口,CachingExecutor会默认使用他包装初始生成的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;
    //未命中的缓存集合,防止击穿缓存,并且如果查询到的数据为null,说明要通过数据库查询,有可能存在数据不一致,都记录到这个地方
    private final Set<Object> entriesMissedInCache;

flushCacheIfRequired在清除缓存时,调用了TransactionalCacheclear方法,清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存,代码如下所示:

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

写缓存调用tcm的put方法也不是直接操作缓存,只是在把这次的数据和key放入待提交的Map中。

public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
}

这样看来,putObject并未直接对二级缓存造成影响。一切还是要等到commit方法执行。

public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
  }

它的调用链路是:

TransactionalCacheManager
    -> CachingExecutor
        -> DefaultSqlSession
            -> SqlSessionTemplate

看到这里的clearOnCommit就想起刚才TrancationalCacheclear方法设置的标志位,真正的清理Cache是放到这里来进行的。具体清理的职责委托给了包装的Cache类。之后进入flushPendingEntries方法,将待提交的Map进行循环处理,委托给包装的Cache类,进行putObject的操作。代码如下所示:

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

3.6 清除缓存

CachingExecutorupdate里有刷新缓存的操作。而在DefaultSqlSession执行insert|update|delete的话,会统一进入CachingExecutorupdate方法,清空缓存。

@Override
  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
  }

4.总结

  • 二级缓存在一级缓存执行之前执行
  • 使用二级缓存可以在mapper.xml中定义<cache><cacheRef>标签,两个同时设置,会以设置为准。
  • 二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
  • 二级缓存在安全使用上较为苛刻。如分布式环境,多变查询等条件下,极大可能出现脏数据。建议直接使用RedisMemcached等分布式缓存可能成本更低,安全性也更高