MyBatis源码解析——缓存

这一节我将讲解有关MyBatis缓存的相关源码,也不会太难。

众所周知,MyBatis有一级缓存和二级缓存。缓存这个东西是有利有弊,利的话,很明显在于速度更快了,弊一般是数据不一致的问题,这个不仅仅是在MyBatis里会出现了,任何使用缓存的地方都有可能会这样。因此了解MyBatis的缓存是很有必要的,下面我们一起来看下它相关的源码吧。

一级缓存

先来简单介绍一下一级缓存。

在一次数据库连接session中,可能会查询多次相同的SQL,因此MyBatis对这部分优化了,它使用一级缓存机制,来避免多次对数据库的查询,一次查询过后将数据保存到缓存中,同一个session的相同语句就会命中缓存了,注意 :一级缓存只在数据库会话内部生效

一级缓存源码分析

在XML时候,一般是这么配置的:

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

现在我的Mybatis版本是3.5.7,可以在发现Configuration这个类下的localCacheScope默认是SESSION,也就是说默认一级缓存是开启的。

我在上上篇讲SQL解析的时候就说过,查询数据时候会调用到executor.query方法,这里面的逻辑我也已经讲解过,就简单贴一点点代码了,

// ---- BaseExecutor ------
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 {
      list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
  } finally {
    queryStack--;
  }
  //  忽略其他代码
}

可以看见在这个方法里面会先去localCache获取结果,获取不到才会走数据库查询,因为executor的类型,一般是SimpleExecutor,它继承自抽象类BaseExecutor,让我们看下BaseExecutor的构造方法。

protected BaseExecutor(Configuration configuration, Transaction transaction) {
  this.transaction = transaction;
  this.deferredLoads = new ConcurrentLinkedQueue<>();
  // 这里将localCache设置为PerpetualCache
  this.localCache = new PerpetualCache("LocalCache");
  this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
  this.closed = false;
  this.configuration = configuration;
  this.wrapper = this;
}

PerpetualCache 里面比较简单,就两个参数。

public class PerpetualCache implements Cache {

  private final String id;

  private final Map<Object, Object> cache = new HashMap<>();
  
  // 省略其他方法
}

还可以点击看下Cache的其他实现类,会发现使用了装饰器模式来装饰其他的类,PerpetualCache应该是其中最简单的类。

还可以看见在缓存中取value的时候localCache.getObject(key)是这样的,而这个key,通过BaseExecutor的createCacheKey来获取,其组成是 MappedStatement的ID + RowBounds的Offset + RowBounds 的Limmit + BoundSql 的SQL + 参数 + (如果存在environment的话)environment的ID。 因此这些都一样的话,才能确定是同一个。

再来看看更新方法

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

一级缓存的逻辑很好理解,缺点在于在多个session或者分布式的情况下,可能会出现脏数据,毕竟从缓存中取出的数据不是实时的,比较推荐使用STATEMENT类型,官网是这么说的:

MyBatis uses local cache to prevent circular references and speed up repeated nested queries. By default (SESSION) all queries executed during a session are cached. If localCacheScope=STATEMENT local session will be used just for statement execution, no data will be shared between two different calls to the same SqlSession.

也就是说如果是STATEMENT类型的,那么就不会数据分享了,因为每次都会清空数据,当然,这一点我们也可以在代码里看到,还是在BaseExecutor的query里:

// 这之前的已经贴了,在上面!
// 如果查询栈是0
if (queryStack == 0) {
  for (DeferredLoad deferredLoad : deferredLoads) {
    deferredLoad.load();
  }
  // issue #601
  deferredLoads.clear();
  // 如果是STATEMENT类型,那么清空缓存!
  if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
    clearLocalCache();
  }
}

二级缓存

上面说到一级缓存特点在于 同一个session,而二级缓存则是多个SQLSession共享缓存,当然,前提条件是namespace是同一个。开启二级缓存后,会使用CachingExecutor来装饰Executor的实现类,这块我也还是在上上篇讲过,这里贴一下部分:

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
  executorType = executorType == null ? defaultExecutorType : executorType;
  executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
  Executor executor;
  if (ExecutorType.BATCH == executorType) {
    executor = new BatchExecutor(this, transaction);
  } else if (ExecutorType.REUSE == executorType) {
    executor = new ReuseExecutor(this, transaction);
  } else {
    executor = new SimpleExecutor(this, transaction);
  }
  // 如果它为true,就使用CachingExecutor来装饰Executor
  if (cacheEnabled) {
    executor = new CachingExecutor(executor);
  }
  executor = (Executor) interceptorChain.pluginAll(executor);
  return executor;
}

二级缓存不适用于连表查询的情况,因为二级缓存是针对namespace的,而多表查询往往涉及到不同的namespace,其他的namespace对表进行修改的话,delegate。诚然,我们可以使用Cache-ref来解决,但这样缓存的粒度变粗了,多个namespace都会对缓存产生影响,一下这个更新,一下那个更新,这个缓存一直失效,那这个缓存就没有存在必要了。

二级缓存源码分析

我们进入到CachingExecutor来看看它的查询。

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
    throws SQLException {
  // 获取MappedStatement里的cache
  Cache cache = ms.getCache();
  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) {
        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);
}

俺们知道Cache的实现类大都是装饰器,这里记录下比较重要的几个,我直接copy网上的了:

  • SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
  • LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
  • SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
  • LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
  • PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。

一般PerpetualCache都是作为基础的缓存的,这在上面也是说过的。

现在来看tcm.getObject(cache, key),而这个tcm是CachingExecutor的TransactionalCacheManager类,它下面维护了一个map变量:

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

它使用Cache作为key,使用TransactionalCache包装后的Cache作为value,还是可以顾名思义一下的,这个cache是事务cache。

它的作用是如果事务提交,缓存才会生效,如果事务回滚或者不提交事务,那么不对缓存做任何操作。

可以看见重点还是在TransactionalCache这个类里,这个类有两个很重要的变量,下面看下它的变量。

// 被装饰的类
private final Cache delegate;
// 在commit的时候是否清除数据,默认为false
private boolean clearOnCommit;
// 所有查出的集合会存进此集合中。
private final Map<Object, Object> entriesToAddOnCommit;
// 如果未命中,将会被存在此集合中。
private final Set<Object> entriesMissedInCache;

entriesMissedInCache这个变量可以看下它put的地方,点进tcm.getObject(cache, key) 这行。

public Object getObject(Cache cache, CacheKey key) {
  // 根据cache获取对应的TransactionalCache,再调用它的getObject
  return getTransactionalCache(cache).getObject(key);
}

@Override
public Object getObject(Object key) {
  // 从被装饰类中获取结果
  Object object = delegate.getObject(key);
  // 为空的话则将key放进entriesMissedInCache
  if (object == null) {
    entriesMissedInCache.add(key);
  }
  // issue #146
  if (clearOnCommit) {
    return null;
  } else {
    return object;
  }
}

而entriesToAddOnCommit这个变量则是所有已经查出来结果的集合了。

为啥不推荐使用二级缓存,因为除了前面说的并发问题外,还有一个很致命的问题,那就是事务问题。

如果跟随tcm.putObject(cache, key, list);这行点进去,会发现查询结果真正放的地方是TransactionalCache,而被装饰的delegate则是没有放的,而我们去跟随delegate的put方法,会发现调用它的是TransactionalCache的flushPendingEntries,而这个方法是被commit方法所调用,commit就是事务提交。

public void putObject(Cache cache, CacheKey key, Object value) {
  // 根据cache获取对应的TransactionalCache,再调用它的put
  getTransactionalCache(cache).putObject(key, value);
}
@Override
public void putObject(Object key, Object object) {
  // 只在这个变量里放了,而没有在delegate下放。
  entriesToAddOnCommit.put(key, object);
}

对比前面get那块,会发现获取缓存结果的地方是在delegate获取,但在put的时候却是在entriesToAddOnCommit中。而在事务提交之后,entriesToAddOnCommit就会放进delegate的缓存中了。

为什么特地引进一个TransactionalCache,还将查询数据放在它下面的变量。而不是直接放在被装饰的PerpetualCache(因为默认是它,上下文的delegate都代指它)中呢。因为这样会引发脏数据问题,假如现在有两个事务,事务A和事务B。事务A将一行数据更新了并查询出来,然后放在delegate中了,而事务B刚好需要获取相同的行记录那么就会获取到事务A尚未提交的数据!这就是数据库里的未提交读了,你保证级别这么低,那谁还敢用。所以MyBatis这么操作,这样的话,其他的事务能读取到的都会是已提交的事务的数据了。

但这样的结构还是会有事务问题的,因为这样只能解决未提交读的问题,而不能解决不可重复读的问题

还是举个例子,事务A查询一行记录,事务B也查同一行数据,然后将结果放进各自的TransactionalCache中,然后,事务A又将这行数据变更了,然后事务A和B又去查,这个时候事务A获取的是更新后的数据,事务B则是获取自己的TransactionalCache下的值,也就是原值,好,这没问题,没有出现脏读的问题,但在此时,事务A提交了,前面我们说过了commit是会调用flushPendingEntries,再把entriesToAddOnCommit的值给刷进delegate中去,那么问题来了,假设事务B再次读取这行数据,它是调用tcm.getObject(cache, key),那么就能获取delegate的里的值,也就是说,这个时候,事务B读到的是新值,与在同一个事务中之前读的值不一致了,这就是不可重复读的问题了。

总结

好像也没啥好总结的,细节啥的都已经在文章中提到了。总之MyBatis的缓存坑很深,尤其是在现在很多是分布式情况下,你都是本地化的缓存,用处一点没有。还是勉强总结几点。

  1. 一级缓存基于SqlSession,二级缓存超越了SqlSession,而是基于namespace。
  2. 这俩都线程不安全,尤其是二级缓存,都会引发脏数据的问题,二级缓存的事务级别只到读提交就虚了。
  3. 尽量少用,一级缓存使用推荐设定为STATEMENT。

以上!

你可能感兴趣的:(MyBatis,Java,java,mybatis,缓存)