MyBatis 的秘密(六)缓存

缓存

众所周知的,MyBatis 内置了二级缓存,对于一级缓存是默认打开的,而二级缓存需要手动开启。

接下来,我们探索一下MyBatis的缓存。

首选在官方文档中,我们可以找到MyBatis的相关配置:

  • 全局配置: cacheEnabled : 全局的开启或关闭配置文件中所有映射器已经配置的缓存。 默认true
  • 全局配置:localCacheScope: 设置一级缓存作用域,可以设置为SESSIONSTATEMENT, 默认为SESSION,当设置为STATEMENT之后,一级缓存仅仅会用在STATEMENT范围
  • 映射配置: useCache : 是否将返回结果在二级缓存中缓存起来,默认selecttrue
  • 映射配置: flushCache : 语句调用后,是否将本地缓存和二级缓存都清空,默认非selecttrue
  • 映射配置:<cache> : 开启二级缓存,如果是MyBatis的内置二级缓存,还可以配置:缓存刷新时间,缓存大小,缓存刷新间隔,缓存替换策略等
  • 映射配置:<cache-ref>: 联合域名空间,使用所指定的域名空间的缓存。

以上便是MyBatis中,所有有关缓存的配置。

一级缓存

首先看一级缓存,一级缓存的代码主要在BaseExecutor中:

MyBatis的一级缓存是通过HashMap实现的。

@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) {
        throw new ExecutorException("Executor was closed.");
    }
    //是否需要刷新缓存
    //queryStack的作用应该是防止在多线程的情况下,其他线程同时在查询缓存,而这里执行
    //清空操作
    //不过这里考虑到了多线程,为什么缓存还用HashMap,是因为觉得并发不够,并且一般很少多线程么
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    List<E> list;
    try {
        queryStack++;
        //前面说过,当有自定义的`ResultHandler`时,不会使用缓存
        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();
        //如果配置了一级缓存为`STATEMENT` 则清空缓存
        if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            // issue #482
            clearLocalCache();
        }
    }
    return list;
}

从以上代码可以分析出来,大概就是首先从缓存中查找,如果找不到再从数据库中查找。

同时,如果缓存范围是STATEMENT,那么每次执行都会清空本地缓存,那么STATEMENT的缓存在哪里呢?

需要知道的Executor是属于SqlSession的,而STATMENT是属于方法的,也就是整个SqlSession用的是同一个Executor,而对于方法是每执行一个方法,就会新建一个STATEMENT,因此我们可以认为,对于作用域为STATEMENT的一级缓存,相当于关闭了一级缓存


二级缓存

首先看看CacheEnable

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);
    }
    //如果配置cacheEnabled 为`true`
    if (cacheEnabled) {
        //则使用`CachingExecutor`包装生成的`executor`
        executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}

可以看到,这便是前面我们说的第四个包装类Executor

CachingExecutor 仅仅的作用便是在代理Executor执行前或执行后进行缓存的处理:

接下来看看CachingExecutor的具体实现:

CachingExecutor中包含两个成员:

//包装的类
private final Executor delegate;
//事务缓存管理器
private final TransactionalCacheManager tcm = new TransactionalCacheManager();

因为二级缓存是可以跨Session的,因此就涉及到事务的提交和回滚。

  @Override
  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);
       //如果配置了useCache以及没有自定义resultHandler 
      if (ms.isUseCache() && resultHandler == null) {
        //判断是否有存储过程  
        ensureNoOutParams(ms, boundSql);
        //查看缓存中是否存在  
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);
        //不存在则交给`Executor`查找  
        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);
  }

可以看到,基本的逻辑就是查看缓存中是否存在,不存在则查询数据库

可以看到基本和一级缓存一样,但是唯一不同的是缓存的策略,因为二级缓存涉及到事务的回滚,因此需要单独处理回滚和提交。

MyBatis处理事务性缓存的方案非常简单: 首先,使用一个临时缓存保存产生的数据,当commit()的时候,就将数据真正的写入缓存中,当需要rollback()则直接clear()

TransactionalCache#commit()

public void commit() {
    //如果中途发生了异常,则清空
    if (clearOnCommit) {
        delegate.clear();
    }
    //将产生的数据真正的写入缓存
    //将记录的未命中的数据从结果中读取,并加入缓存中
    flushPendingEntries();
    //重置状态
    reset();
}

TransactionalCache#rollback()

  public void rollback() {
    //清空所有保存的未命中的key  
    unlockMissedEntries();
    //重置状态
    reset();
  }

有一点疑惑的,它回退并不是简单的把产生数据缓存清空,而且还调用了unlockMissedEntries();将执行过程中发现未命中的数据也删除:

TransactionalCache#unlockMissedEntries()

private void unlockMissedEntries() { 
    for (Object entry : entriesMissedInCache) {
        delegate.removeObject(entry);
    }
}

答案在于Cache接口的实现者,BlockingCache中,增加了数据库的锁,而当回滚的时候,则需要释放锁,这里的操作便是释放锁。


看到这里,我们可以联系数据库中的事务隔离问题,数据库事务存在脏读,不可重复读,幻读等问题,而数据库解决这些问题的办法在于通过加锁。对于MyBatis的二级缓存,如果在事务的情况下,会不会导致这些事务隔离失效呢?

需要知道的便是MyBatis的缓存使用只有两种方式,取数据(select),清空缓存(update),而数据库的事务一般都是在读取的过程中数据被修改而导致的。而修改了数据就会导致MyBatis清空缓存,从而达到通过数据库解决事务隔离的问题。

但是,有些情况还是会有事务隔离问题:

复现如下:

  • 首先,关闭一级缓存,打开二级缓存。

  • 然后调用select并提交,使得二级缓存中存在值。

  • 从新打开一个新的连接,开启事务,然后select,此时并不提交,
  • 再打开一个新的连接,修改此select对应的数据,并提交
  • 继续刚刚没有提交的select,再次进行select
  • 可以看到两次select获取的数据不一样,但是数据库的隔离级别确实不可重复读

究其原因,是因为在两次select中,第一次select命中了缓存,所以没有查询数据库,而当其他事务修改了此数据并提交后,此时清空了缓存,因此第二次select会去查询数据库,此时才会开启数据库的事务,但是两次查询的数据已经不一样了。

上面的问题很难发现,因为我们一般不会关闭一级缓存。

当一级缓存开启的时候,两次select都会命中一级缓存,因而不会有不可重复读的问题。

这可能也是一级缓存默认开启的原因吧。


看完了缓存的使用方式,接下来看看MyBatis缓存的真正实现:

MyBatis的缓存接口为Cache

public interface Cache {

  String getId();

  void putObject(Object key, Object value);

  Object getObject(Object key);

  Object removeObject(Object key);

  void clear();

  int getSize();

  default ReadWriteLock getReadWriteLock() {
    return null;
  }
}

可以看到,就是简单的增删查改。

虽然Cache的接口简单,但是其实现有很多,因为MyBatis内置了很多不同的Cache配置。

可以看看Cache的实现类有如下:

  • BlockingCache : 阻塞缓存,当未在缓存中成功查询到数据的时候,会对该数据加锁,然后仅让其中一个连接查询,其他连接等待,查询完毕后直接使用该缓存,防止缓存雪崩

    不知道为什么MyBatis官网没有他的说明,但是确实可以配置的,配置方式为<cache blocking=true>

  • FifoCache : 先进先出策略缓存

  • LoggingCache : 增加日志信息的缓存

  • LruCache :移除最近最少使用的缓存

    通过LinkedHashMap 包装实现

  • ScheduledCache: 定时刷新的缓存

  • SerializedCache : 将Value 序列化起来存储的缓存

  • SoftCache : Value使用软引用的缓存

  • SynchronizedCache : 将缓存的所有操作都添加锁(SynchronizedCache

    为什么不用ConcurrentHashMap ? 为了更好的解耦么?

  • TransactionalCache : 具有事务性的缓存

  • WeakCache : 使用弱引用的缓存


上面所有的缓存实现,每个所具有的功能都不一样,在配置中可以选着配置功能,在MyBatis中都将其通过装饰者模式包装起来。

真正的具有缓存功能的是:PerpetualCache ,其内部通过HashMap实现。

MyBatis中,创建包装类的代码如下所示:

private Cache setStandardDecorators(Cache cache) {
    try {
        MetaObject metaCache = SystemMetaObject.forObject(cache);
        //如果设置了缓存大小,则设置缓存的大小
        //默认1024
        //默认传入的cache是`LruCache`
        if (size != null && metaCache.hasSetter("size")) {
            metaCache.setValue("size", size);
        }
        //如果设置了刷新间隔,则包装定时刷新缓存
        if (clearInterval != null) {
            cache = new ScheduledCache(cache);
            ((ScheduledCache) cache).setClearInterval(clearInterval);
        }
        //如果缓存的对象有可能被改写,那么为了安全,会将对象进行序列化
        //readOnly =false
        if (readWrite) {
            cache = new SerializedCache(cache);
        }
        //添加日志记录
        //设置日志级别为`Debug` 即可看到日志信息
        cache = new LoggingCache(cache);
        //添加锁
        cache = new SynchronizedCache(cache);

        //是否需要阻塞
        if (blocking) {
            cache = new BlockingCache(cache);
        }
        return cache;
    } catch (Exception e) {
        throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);
    }
}

从这里我们可以看到MyBatis的二级缓存默认使用了Serializable序列化Value,因此对于MyBaitsDomain,我们需要实现Serializable接口,否则会报错。

MyBaits中,还可以自定义实现二级缓存:

<cache type="com.domain.something.MyCustomCache"/>

不过由于一些原因,MyBatis限制了一些包装类只能用在内置类中:

 // issue #352, do not apply decorators to custom caches
    //如果是内置类,再进行包装
    if (PerpetualCache.class.equals(cache.getClass())) {
      for (Class<? extends Cache> decorator : decorators) {
        cache = newCacheDecoratorInstance(decorator, cache);
        setCacheProperties(cache);
      }
      cache = setStandardDecorators(cache);
    } 
    //否则,只包装一层`Logging` 
    else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
      cache = new LoggingCache(cache);
    }

看完MyBaitsCache实现,还有一个问题就是Cache对应的KeyMyBatis是如何判断是同一条SQL呢?

一般来说,最好的判断方法便是直接看SQL语句是不是一样,但事实并不是这么简单。在MyBatis中,将CacheKey使用Cachekey包装起来:

CacheKey主要包含5个字段:

  private final int multiplier;
  private int hashcode;
  private long checksum;
  private int count;
  private List<Object> updateList;

MyBatis执行过程中,当遇到影响SQL的结果的时候,就会同时更新这5个字段:

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

比如在更新的时候:

  @Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    CacheKey cacheKey = new CacheKey();
    //id
    //同一个namespace使用同一个cache  
    cacheKey.update(ms.getId());
    //offset
    cacheKey.update(rowBounds.getOffset());
    //limit
    cacheKey.update(rowBounds.getLimit());
    //sql
    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
      // DataBase 环境添加影响  
      cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
  }

这便是MyBatis的缓存的大概实现。

总结

简单总结下:

  • MyBatis缓存分为一级缓存和二级缓存,其最后的实现都是HashMap
  • MyBaits的一级缓存默认范围为Session,可以修改为STATEMENT,相当于关闭了缓存(每执行一次方法都会新建一个Statement
  • MyBatis利用3层缓存解决了事务的隔离的问题
  • MyBatis的缓存可以配置多种功能,其实现是通过装饰者模式实现
  • MyBatis的二级缓存默认需要使Domain实现Serializable接口