美文网首页springMyBatis源码解析
(五)Mybatis-缓存解析

(五)Mybatis-缓存解析

作者: 骑着乌龟去看海 | 来源:发表于2018-03-02 09:39 被阅读22次

    1、概述

    Mybatis的缓存大体上分为一级缓存和二级缓存,我们先来说下一级缓存。

    2、一级缓存

      当我们使用Mybatis对数据库进行一次查询操作的时候,会通过SqlSession来表示一次数据库会话。在每次会话中,可能会对数据库执行相同的SQL查询操作,而我们也知道,对数据库频繁操作是很耗费性能的,因为数据库中的数据是持久化再磁盘上的。Web工程最大的瓶颈就在于对磁盘文件的I/O操作,因为学过计算机的都了解,I/O操作比内存操作速度差了恐怕几个量级。

    而为了避免相同sql的多次数据库查询操作,Mybatis提供了一个简单的缓存机制。将每次sql查询的结果缓存起来,下次相同sql执行的时候直接查询缓存。缓存中存在,从缓存中获取后直接返回,缓存中不存在,查询数据库将查询结果放入缓存并返回。我们把这种一次会话级别的缓存称为一级缓存。

    2.1 实现

      一级缓存在Mybatis中是通过SqlSession中的Executor来维护的,上文我们已经了解过Executor了,这次不再详述了。在BaseExecutor中,维护了一个PerpetualCache的localCache,来实现一级缓存的功能。

    2.1.1 首先,我们先来看下PerpetualCache的实现。

    PerpetualCache的实现很简单,实现了Mybatis的Cache接口。Mybatis的Cache接口是用于缓存的接口,一般与缓存相关的类都应该实现这个接口。PerpetualCache内部维护了一个HashMap来实现缓存的功能:

    public class PerpetualCache implements Cache {
      private String id;
      private Map<Object, Object> cache = new HashMap<Object, Object>();
    }
    

    id是一个名为LocalCache的字符串,而Map用来存储数据,key也就是接下来会说到的CacheKey,value则是查询到的数据。

    2.1.2 然后我们来看下缓存的实现流程,我们从BaseExecutor的query方法看起。
    public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
        BoundSql boundSql = ms.getBoundSql(parameter);
        // 获取缓存的key
        CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
        return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
    

    这里,比较重要的一点是缓存key的创建,如何确定相同sql的key值是相同的?这里会涉及到用于存储缓存key的CacheKey类。我们先来简单看下CacheKey,再来看下createCacheKey方法。

    CacheKey

    我们来看下CacheKey内部的实现:

    public class CacheKey implements Cloneable, Serializable {
      ...
      public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
    
      private static final int DEFAULT_MULTIPLYER = 37;
      private static final int DEFAULT_HASHCODE = 17;
    
      // 这个是用于hashcode计算时的扩展因子,默认37
      private int multiplier;
      // 计算hashcode
      private int hashcode;
      // 生成key的各项参数的默认hashcode的总和
      private long checksum;
      // 计数
      private int count;
      // 生成key的各项参数
      private List<Object> updateList;
    }
    

    CacheKey内部有一些属性,用于生成cacheKey及获取时的校验。由于HashMap的get方法是先判断hashCode再equals进行判断,所以我们可以简单看下CacheKey中对hashcode的处理及equals方法。

    private void doUpdate(Object object) {
        // 对象默认的hashcode
        int baseHashCode = object == null ? 1 : object.hashCode();
        // 计数
        count++;
        // 所有对象的hashcode相加
        checksum += baseHashCode;
        // 对象的hashcode扩大count倍
        baseHashCode *= count;
        // 根据扩展因子扩展,然后加上扩大后的对象的hashcode
        hashcode = multiplier * hashcode + baseHashCode;
        // 添加对象到list中
        updateList.add(object);
    }
    

    由于生成key的时候最终方法会调用到doUpdate,我们只需看下doUpdate方法,了解它的hashcode是如何生成的即可。我们再来看下equals方法:

    public boolean equals(Object object) {
        if (this == object) {
          return true;
        }
        if (!(object instanceof CacheKey)) {
          return false;
        }
    
        final CacheKey cacheKey = (CacheKey) object;
        if (hashcode != cacheKey.hashcode) {
          return false;
        }
        if (checksum != cacheKey.checksum) {
          return false;
        }
        if (count != cacheKey.count) {
          return false;
        }
    
        for (int i = 0; i < updateList.size(); i++) {
          Object thisObject = updateList.get(i);
          Object thatObject = cacheKey.updateList.get(i);
          if (thisObject == null) {
            if (thatObject != null) {
              return false;
            }
          } else {
            if (!thisObject.equals(thatObject)) {
              return false;
            }
          }
        }
        return true;
    }
    

    equals方法会对CachKey的各个属性进行比较判断,并且会循环判断updateList中的每个元素,通过这种方式来保证key的唯一性。


    createCacheKey相关

    简单看了CacheKey后,我们再来看下createCacheKey方法,了解一下CacheKey的创建规则。

    public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
        if (closed) {
          throw new ExecutorException("Executor was closed.");
        }
        CacheKey cacheKey = new CacheKey();
        // 1. MappedStatement的id
        cacheKey.update(ms.getId());
        // 2. 查询的分页参数 offset和limit
        cacheKey.update(Integer.valueOf(rowBounds.getOffset()));
        cacheKey.update(Integer.valueOf(rowBounds.getLimit()));
        // 3. sql语句
        cacheKey.update(boundSql.getSql());
        // 4. 传递给JDBC的参数
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
        // mimic DefaultParameterHandler logic
        // 解析参数
        for (int i = 0; i < parameterMappings.size(); i++) {
          ParameterMapping parameterMapping = parameterMappings.get(i);
          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);
          }
        }
        // 5. 如果mybatis-config配置的environment不为空,取environment的id
        if (configuration.getEnvironment() != null) {
          // issue #176
          cacheKey.update(configuration.getEnvironment().getId());
        }
        return cacheKey;
    }
    

    createCacheKey方法表明了缓存key的生成规则,拿我们来看一下生成cacheKey的一些条件。

    1. MappedStatement的id。所谓id,即是Mybatis的映射文件中,每个select节点的namespace及名称,有了它,我们才能确定执行的是哪一条sql,我们拿上文的实例来看一下id:id="com.mapper.IStudentMapper.getAll"
    2. offset及limit。这里就与Mybatis的分页有关系了,Mybatis的分页功能是通过RowBounds来实现的,而RowBounds则是通过offset和limit属性来实现分页,而这种分页功能是基于查询结果的再过滤,而不是进行数据库的物理分页;
    3. sql语句。Mybatis的sql语句是通过BoundSql来实现的,这个就比较好理解了,sql语句不一样,那key肯定不会相同;
    4. 参数。也就是说,调用JDBC的时候,sql语句要一样,传递的参数也要完全一样,这样才是相同的sql。
    5. environment的id。这里大致说一点:这里配置的id是每个环境的id,可能开发,测试环境等。

    针对environment的id,我们看下官网的解释就明白了:

    MyBatis 可以配置成适应多种环境,这种机制有助于将 SQL 映射应用于多种数据库之中, 现实情况下有多种理由需要这么做。例如,开发、测试和生产环境需要有不同的配置;或者共享相同 Schema 的多个生产数据库, 想使用相同的 SQL 映射。许多类似的用例。
    节选自:配置环境(environments)


    query和queryFromDatabase方法

    获取到缓存的key之后,接下来的操作就比较简单了。我们接着来看query方法:

    public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
        ...
        // 如果mapper节点中配置了flushCache=true,就清空缓存
        // queryStack 参数应该是用于延迟加载用的
        if (queryStack == 0 && ms.isFlushCacheRequired()) {
          clearLocalCache();
        }
        List<E> list;
        try {
          queryStack++;
          // 从缓存localCache中获取
          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--;
        }
        ...
        return list;
    }
    

    这里也说明了一点,如果我们不想从缓存里查询,只想查询数据库,那么只需要配置对应节点的flushCache=true即可了。

    <select id="getAll" resultType="Student2" statementType="CALLABLE" flushCache="true">
        SELECT * FROM Student
    </select>
    

    我们接着来看下queryFromDatabase方法,这个方法就是缓存数据库查询的结果并返回:

    private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
        List<E> list;
        localCache.putObject(key, EXECUTION_PLACEHOLDER);
        try {
          list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
        } finally {
          localCache.removeObject(key);
        }
        localCache.putObject(key, list);
        // 如果statementType类型是callable,则缓存存储过程的参数
        if (ms.getStatementType() == StatementType.CALLABLE) {
          localOutputParameterCache.putObject(key, parameter);
        }
        return list;
    }
    

    这里有一个问题,我以前就遇到过:

    就是说 我们查询完成之后,localCache直接缓存我们查询的结果,并没有拷贝或者怎么处理,然后这个结果又被直接返回了。但是由于引用的关系,这里就会出现一种情况,就是外部修改了这个结果,缓存中的值也会跟着发生变化。这样的话,可能会出现我们意想不到的结果,所以这里可以注意一下。

    2.1.3 我们再简单看下 insert,update,delete方法

    我们随便看下这几个方法的实现,可以看到它们底层都是通过调用update方法来实现的,我们来看下BaseExecutor中update方法的实现:

    // update方法实现
    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);
    }
    

    在update方法中我们可以看到,每次进行insert,update,delete之后,就会进行清空缓存操作。

    2.2 如何清除一级缓存或者说不使用一级缓存

    其实,我们从query方法的源码中就可以找到解决方式,我们再来看下query源码:

    public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
        ...
        if (queryStack == 0 && ms.isFlushCacheRequired()) {
          clearLocalCache();
        }
        ...
          if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            // issue #482
            clearLocalCache();
          }
        }
        return list;
    }
    

    从这里,我们可以看到两种解决方式:

    1. 配置映射文件中节点的 flushCache属性,设置为true
    2. 配置mybatis-config.xml中的localCacheScopeSTATEMENT

    针对第二种方式可以简单说下:
    Mybatis一级缓存的范围有SESSION 和STATEMENT两种,默认是SESSION。我们配置为STATEMENT,这样每次执行完一个对应的Mapper方法后,就会将缓存清空:

    <setting name="localCacheScope" value="STATEMENT"/>
    
    2.3 适用场景
    1. 单从一级缓存来看,它只是对HashMap的操作,并且没有容量的大小限制,所以存在HashMap占用内存太大,导致内存溢出的可能;但一般情况下,每个SqlSession的生命周期很短,并且只要执行相应的update方法,缓存就会被清空,当然我们也可以手动清空缓存,所以正常情况下一般不会出现缓存过大,内存溢出的情况;
    1. 所以我们在使用一级缓存的时候还是要注意下:对于时效性很高的数据,我们要控制好SqlSession的生存时间,SqlSession的生存时间越长,它其中缓存的数据有可能就越旧,从而造成和真实数据库的误差。对于这种情况,我们可以适时的手动清空缓存;对于特定的查询,我们也可以配置flushCache属性,对该条SQL语句不适用一级缓存;
    2.4 一级缓存总结

    到这里,一级缓存的学习就差不多结束了,我们来总结下,然后开始学习二级缓存。

    1. Mybatis的一级缓存是SqlSession级别的,而缓存的维护则是通过Executor来实现的,当一次会话结束(比如调用了close方法)后,相应的一级缓存也会被清除;
    2. 对于一级缓存中的数据,由于引用的关系,如果外部修改了这个结果,那缓存中的值也会跟着发生变化,注意下这种情况;
    3. 如果不想使用一级缓存,可以配置映射文件中节点的flushCache属性为true或者配置全局文件的localCacheScopeSTATEMENT

    3、二级缓存

    我们现在来开始一下二级缓存。二级缓存是Application级别的缓存,默认是开启的,我们可以通过配置cacheEnabled参数来关闭二级缓存:

    <setting name="cacheEnabled" value="false"/>
    

    Mybatis中的二级缓存适用的是Executor接口的另一个实现类:CachingExecutor。前文已经学习过如何获取CachingExecutor,现在再来简单看一下:

    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
        if (cacheEnabled) {
          executor = new CachingExecutor(executor);
        }
        executor = (Executor) interceptorChain.pluginAll(executor);
        return executor;
      }
    

    在Configuration的newExecutor方法中,我们通过cacheEnabled参数来判断是否开启了二级缓存,如果开启的话,Mybatis就将通过CachingExecutor来完成操作,而CachingExecutor通过适用装饰者模式,在内部包装了一个Executor的实例来进行实际的操作:

    // 包装的实际执行器
    private Executor delegate;
    // 事务缓存数据
    private TransactionalCacheManager tcm = new TransactionalCacheManager();
    

    而对于实际用于缓存数据的 TransactionalCacheManager 类,其实底层也是通过HashMap来实现的。其中map的key是每个节点的Cache对象,value是TransactionalCache对象,感兴趣的童鞋可以看下该类:

    public class TransactionalCacheManager {
      private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();
    }
    
    public class TransactionalCache implements Cache {
      private static final Log log = LogFactory.getLog(TransactionalCache.class);
        
      // 包装的缓存对象
      private Cache delegate;
      private boolean clearOnCommit;
      private Map<Object, Object> entriesToAddOnCommit;
      private Set<Object> entriesMissedInCache;
    }
    
    3.1 二级缓存配置及条件

    对整个Application而言,Mybatis二级缓存并不是只有一份,对每个Mapper文件都会有一个<cache>节点,只要配置了这个节点的话,那这个mapper文件就会对应一个Cache对象。

    当然,我们也可以对多个Mapper文件公用一个Cache,需要配置一下<cache-ref>节点,指定它的namespace属性;

    <cache></cache>
    <cache-ref namespace="" ></cache-ref>
    

    当然,如果我们同时配置了cache和cache-ref节点的话,那么Mybatis中cache节点的优先级是高于cache-ref的,所以Mybatis会选择cache节点。

    1. Mybatis的二级缓存的粒度很细,它可以指定某一条查询语句是否可以使用二级缓存。
    2. 虽然在Mapper中配置了<cache>,并且为此Mapper分配了Cache对象,这并不表示这个Mapper中的任一条sql语句查到的结果都会放置到Cache对象之中,只有指定了`useCache="true"的<select>节点才会走二级缓存。
    <cache></cache>
    <select id="getAll" resultType="Student2" useCache="true">
        SELECT * FROM Student
    </select>
    

    也就是说,如果要使某个<select>节点支持二级缓存,要满足以下三个条件:

    1. Mybatis开启了二级缓存:cacheEnabled=true
    2. 该select所在的mapper,配置了cache或cache-ref节点;
    3. 该select节点配置了useCache=true属性;
    3.1.2 源码分析

    接下来,我们来通过源码来查看一下二级缓存:

    public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
          throws SQLException {
        // 获取该mapper对象的cache节点
        Cache cache = ms.getCache();
        if (cache != null) {
          // 清空二级缓存
          flushCacheIfRequired(ms);
          if (ms.isUseCache() && resultHandler == null) {
            ensureNoOutParams(ms, parameterObject, boundSql);
            @SuppressWarnings("unchecked")
            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); // issue #578 and #116
            }
            return list;
          }
        }
        return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    }
    

    我们来看一下二级缓存的流程:

    1. 首先,我们通过MappedStatement的getCache()方法获取mapper文件的cache节点;
    2. 如果该cache节点不存在,调用BaseExecutor的query方法执行一级缓存相关的操作;如果存在,先根据节点的flushCache属性来确定是否清除该节点的二级缓存;
    private void flushCacheIfRequired(MappedStatement ms) {
        Cache cache = ms.getCache();
        if (cache != null && ms.isFlushCacheRequired()) {      
          tcm.clear(cache);
        }
    }
    
    1. 通过ensureNoOutParams方法来确保二级缓存不会存储存储过程相关的查询结果;
    2. 从事务缓存对象tcm中获取数据,这里同样用到了装饰者模式,最终会从TranactionalCache中包装的缓存对象Cache的实例对象中获取数据。
    3. 如果二级缓存中没有查询到数据,调用BaseExecutor的query方法查询数据,查询到后向二级缓存中缓存一份;
    3.2 cache相关
    1. 由于Mybatis二级缓存的生命周期也就是整个application的生命周期,所以application不结束,二级缓存就会一直在内存中。当然,这里也会出现一级缓存的内存溢出的可能,不过Mybatis在cache节点中增加了许多配置,比如readOnly(只读),eviction(缓存的回收算法),flushInterval(缓存的清理时间间隔),blocking(读取时是否阻塞)等,详细参数可以查看官网:
      Mybatis-XML映射文件
    2. 我们也可以通过实现Cache接口,然后配置cache节点的type属性为我们自定义的cache实现。当然,我们也可以使用第三方缓存来实现;
    1. 所以说,Mybatis的二级缓存有三个选择:
    • Mybatis默认的缓存实现;
    • 我们自定义的缓存实现;
    • 第三方缓存的结合,如Redis等。
    3.3 二级缓存的清除

    同样,二级缓存,在进行update,insert,delete的时候时可以自动清空的:

    public int update(MappedStatement ms, Object parameterObject) throws SQLException {
        // 根据flushCache属性来判断是否清空
        flushCacheIfRequired(ms);
        return delegate.update(ms, parameterObject);
    }
    

    当然我们也可以指定flushCache=false,在更新的时候不清除二级缓存。

    3.4 二级缓存的一个问题

      在关联查询的时候,Mybatis会有一个小问题。比如说关联查询两张表A与B,他们位于不同的mapper中,有不同的命名空间,我们先在A中进行一次关联查询,然后对B进行了一次update操作,然后再对原先的A的关联查询进行了一次查询,那查询还是原来的结果,这样就有可能导致数据的不同步。这种情况下,我们可以将A,B使用同一份cache来解决这个问题,但这并不是最好的解决方案。

    而最理想的解决方案就是:

    对于某些表执行了更新(update、delete、insert)操作后,如何去清空跟这些表有关联的查询语句所造成的缓存;这样,就是以很细的粒度管理MyBatis内部的缓存,使得缓存的使用率和准确率都能大大地提升。

    所以,使用Mybatis的二级缓存时,最好保证所有的增删改查都在同一个命名空间下;

    3.5 二级缓存总结

    二级缓存的介绍大致完了,我们来简单总结下二级缓存。

    1. 二级缓存是Application级别的缓存,默认情况下每个mapper文件会对应一份缓存;
    2. 二级缓存的粒度很细,可以具体到某条select语句,只需要相应的配置即可;
    3. 我们可以使用默认的二级缓存,也可以自定义缓存,当然也可以使用第三方的缓存;
    4. 注意下关联查询的问题,最好在同一个命名空间下进行二级缓存的操作;
    3.6 二级缓存和一级缓存的顺序

    根据源代码我们也可以很清除的看到,如果同时配置了一级缓存和二级缓存,那Mybatis会先执行二级缓存,再执行一级缓存,最后查询数据库,顺序大致是:

    二级缓存 -> 一级缓存 -> 数据库查询

    4 总结

    到这里,Mybatis的缓存基本上就学习完了。

    1. Mybatis缓存这里好多地方用到了装饰者模式,我们可以参考学习下;
    2. Mybatis的缓存还是很灵活的,大部分的配置都可以由我们来选择;

    其实缓存这块还有许多东西我们没有分析,比如cache节点的解析,Cache的多个实例如BlockingCache,FifoCache,以及用于自定义及第三软件的LoggingCache等,还有自定义cache的实现,结合第三方软件的实现等。这些等以后有时间了再来学习吧。

    本文参考自:
    终结篇:MyBatis原理深入解析(三)
    Mybatis介绍之缓存
    Mybatis - XML映射配置文件
    【MyBatis源码解析】MyBatis一二级缓存

    相关文章

      网友评论

        本文标题:(五)Mybatis-缓存解析

        本文链接:https://www.haomeiwen.com/subject/csboxftx.html