• 【MyBatis源码解析】MyBatis一二级缓存


    MyBatis缓存

    我们知道,频繁的数据库操作是非常耗费性能的(主要是因为对于DB而言,数据是持久化在磁盘中的,因此查询操作需要通过IO,IO操作速度相比内存操作速度慢了好几个量级),尤其是对于一些相同的查询语句,完全可以把查询结果存储起来,下次查询同样的内容的时候直接从内存中获取数据即可,这样在某些场景下可以大大提升查询效率。

    MyBatis的缓存分为两种:

    1. 一级缓存,一级缓存是SqlSession级别的缓存,对于相同的查询,会从缓存中返回结果而不是查询数据库
    2. 二级缓存,二级缓存是Mapper级别的缓存,定义在Mapper文件的<cache>标签中并需要开启此缓存,多个Mapper文件可以共用一个缓存,依赖<cache-ref>标签配置

    下面来详细看一下MyBatis的一二级缓存。

    MyBatis一级缓存工作流程

    接着看一下MyBatis一级缓存工作流程。前面说了,MyBatis的一级缓存是SqlSession级别的缓存,当openSession()的方法运行完毕或者主动调用了SqlSession的close方法,SqlSession就被回收了,一级缓存与此同时也一起被回收掉了。前面的文章有说过,在MyBatis中,无论selectOne还是selectList方法,最终都被转换为了selectList方法来执行,那么看一下SqlSession的selectList方法的实现:

     1 public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
     2     try {
     3       MappedStatement ms = configuration.getMappedStatement(statement);
     4       return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
     5     } catch (Exception e) {
     6       throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
     7     } finally {
     8       ErrorContext.instance().reset();
     9     }
    10 }

    继续跟踪第4行的代码,到BaseExeccutor的query方法:

    1 public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    2     BoundSql boundSql = ms.getBoundSql(parameter);
    3     CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    4     return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
    5 }

    第3行构建缓存条件CacheKey,这里涉及到怎么样条件算是和上一次查询是同一个条件的一个问题,因为同一个条件就可以返回上一次的结果回去,这部分代码留在下一部分分析。

    接着看第4行的query方法的实现,代码位于CachingExecutor中:

     1 public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
     2       throws SQLException {
     3     Cache cache = ms.getCache();
     4     if (cache != null) {
     5       flushCacheIfRequired(ms);
     6       if (ms.isUseCache() && resultHandler == null) {
     7         ensureNoOutParams(ms, parameterObject, boundSql);
     8         @SuppressWarnings("unchecked")
     9         List<E> list = (List<E>) tcm.getObject(cache, key);
    10         if (list == null) {
    11           list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    12           tcm.putObject(cache, key, list); // issue #578 and #116
    13         }
    14         return list;
    15       }
    16     }
    17     return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    18 }

    第3行~第16行的代码先不管,继续跟第17行的query方法,代码位于BaseExecutor中:

     1 public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
     2     ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
     3     if (closed) {
     4       throw new ExecutorException("Executor was closed.");
     5     }
     6     if (queryStack == 0 && ms.isFlushCacheRequired()) {
     7       clearLocalCache();
     8     }
     9     List<E> list;
    10     try {
    11       queryStack++;
    12       list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
    13       if (list != null) {
    14         handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    15       } else {
    16         list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
    17       }
    18     } finally {
    19       queryStack--;
    20     }
    21     ...
    22 }

    看12行,query的时候会尝试从localCache中去获取查询结果,如果获取到的查询结果为null,那么执行16行的代码从DB中捞数据,捞完之后会把CacheKey作为key,把查询结果作为value放到localCache中。

    MyBatis一级缓存存储流程看完了,接着我们从这段代码中可以得到三个结论:

    1. MyBatis的一级缓存是SqlSession级别的,但是它并不定义在SqlSessio接口的实现类DefaultSqlSession中,而是定义在DefaultSqlSession的成员变量Executor中,Executor是在openSession的时候被实例化出来的,它的默认实现为SimpleExecutor
    2. MyBatis中的一级缓存,与有没有配置无关,只要SqlSession存在,MyBastis一级缓存就存在,localCache的类型是PerpetualCache,它其实很简单,一个id属性+一个HashMap属性而已,id是一个名为"localCache"的字符串,HashMap用于存储数据,Key为CacheKey,Value为查询结果
    3. MyBatis的一级缓存查询的时候默认都是会先尝试从一级缓存中获取数据的,但是我们看第6行的代码做了一个判断,ms.isFlushCacheRequired(),即想每次查询都走DB也行,将<select>标签中的flushCache属性设置为true即可,这意味着每次查询的时候都会清理一遍PerpetualCache,PerpetualCache中没数据,自然只能走DB

    从MyBatis一级缓存来看,它以单纯的HashMap做缓存,没有容量控制,而一次SqlSession中通常来说并不会有大量的查询操作,因此只适用于一次SqlSession,如果用到二级缓存的Mapper级别的场景,有可能缓存数据不断碰到而导致内存溢出。

    还有一点,差点忘了写了,<insert>、<delete>、<update>最终都会转换为update方法,看一下BaseExecutor的update方法:

    1 public int update(MappedStatement ms, Object parameter) throws SQLException {
    2     ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    3     if (closed) {
    4       throw new ExecutorException("Executor was closed.");
    5     }
    6     clearLocalCache();
    7     return doUpdate(ms, parameter);
    8 }

    第6行clearLocalCache()方法,这意味着所有的增、删、改都会清空本地缓存,这和是否配置了flushCache=true是无关的。

    这很好理解,因为增、删、改这三种操作都可能会导致查询出来的结果并不是原来的结果,如果增、删、改不清理缓存,那么可能导致读取出来的数据是脏数据。

    一级缓存的CacheKey

    接着我们看下一个问题:怎么样的查询条件算和上一次查询是一样的查询,从而返回同样的结果回去?这个问题,得从CacheKey说起。

    我们先看一下CacheKey的数据结构:

     1 public class CacheKey implements Cloneable, Serializable {
     2 
     3   private static final long serialVersionUID = 1146682552656046210L;
     4 
     5   public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
     6 
     7   private static final int DEFAULT_MULTIPLYER = 37;
     8   private static final int DEFAULT_HASHCODE = 17;
     9 
    10   private int multiplier;
    11   private int hashcode;
    12   private long checksum;
    13   private int count;
    14   private List<Object> updateList;
    15   ...
    16 }

    其中最重要的是第14行的updateList这个两个属性,为什么这么说,因为HashMap的Key是CacheKey,而HashMap的get方法是先判断hashCode,在hashCode冲突的情况下再进行equals判断,因此最终无论如何都会进行一次equals的判断,看下equals方法的实现:

     1 public boolean equals(Object object) {
     2     if (this == object) {
     3       return true;
     4     }
     5     if (!(object instanceof CacheKey)) {
     6       return false;
     7     }
     8 
     9     final CacheKey cacheKey = (CacheKey) object;
    10 
    11     if (hashcode != cacheKey.hashcode) {
    12       return false;
    13     }
    14     if (checksum != cacheKey.checksum) {
    15       return false;
    16     }
    17     if (count != cacheKey.count) {
    18       return false;
    19     }
    20 
    21     for (int i = 0; i < updateList.size(); i++) {
    22       Object thisObject = updateList.get(i);
    23       Object thatObject = cacheKey.updateList.get(i);
    24       if (thisObject == null) {
    25         if (thatObject != null) {
    26           return false;
    27         }
    28       } else {
    29         if (!thisObject.equals(thatObject)) {
    30           return false;
    31         }
    32       }
    33     }
    34     return true;
    35 }

    看到整个方法的流程都是围绕着updateList中的每个属性进行逐一比较,因此再进一步的,我们要看一下updateList中到底存储了什么。

    关于updateList里面存储的数据我们可以看下哪里使用了updateList的add方法,然后一步一步反推回去即可。updateList中数据的添加是在doUpdate方法中:

     1 private void doUpdate(Object object) {
     2     int baseHashCode = object == null ? 1 : object.hashCode();
     3 
     4     count++;
     5     checksum += baseHashCode;
     6     baseHashCode *= count;
     7 
     8     hashcode = multiplier * hashcode + baseHashCode;
     9 
    10     updateList.add(object);
    11 }

    它的调用方为update方法:

     1 public void update(Object object) {
     2     if (object != null && object.getClass().isArray()) {
     3       int length = Array.getLength(object);
     4       for (int i = 0; i < length; i++) {
     5         Object element = Array.get(object, i);
     6         doUpdate(element);
     7       }
     8     } else {
     9       doUpdate(object);
    10     }
    11 }

    这里主要是对输入参数是数组类型进行了一次判断,是数组就遍历逐一做doUpdate,否则就直接做doUpdate。再看update方法的调用方,其实update方法的调用方有挺多处,但是这里我们要看的是Executor中的,看一下BaseExecutor中的createCacheKey方法实现:

     1 public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
     2     if (closed) {
     3       throw new ExecutorException("Executor was closed.");
     4     }
     5     CacheKey cacheKey = new CacheKey();
     6     cacheKey.update(ms.getId());
     7     cacheKey.update(rowBounds.getOffset());
     8     cacheKey.update(rowBounds.getLimit());
     9     cacheKey.update(boundSql.getSql());
    10     List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    11     TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    12     // mimic DefaultParameterHandler logic
    13     for (ParameterMapping parameterMapping : parameterMappings) {
    14       if (parameterMapping.getMode() != ParameterMode.OUT) {
    15         Object value;
    16         String propertyName = parameterMapping.getProperty();
    17         if (boundSql.hasAdditionalParameter(propertyName)) {
    18           value = boundSql.getAdditionalParameter(propertyName);
    19         } else if (parameterObject == null) {
    20           value = null;
    21         } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
    22           value = parameterObject;
    23         } else {
    24           MetaObject metaObject = configuration.newMetaObject(parameterObject);
    25           value = metaObject.getValue(propertyName);
    26         }
    27         cacheKey.update(value);
    28       }
    29     }
    30     if (configuration.getEnvironment() != null) {
    31       // issue #176
    32       cacheKey.update(configuration.getEnvironment().getId());
    33     }
    34     return cacheKey;
    35 }

    到了这里应当一目了然了,MyBastis从四组共五个条件判断两次查询是相同的:

    1. <select>标签所在的Mapper的Namespace+<select>标签的id属性
    2. RowBounds的offset和limit属性,RowBounds是MyBatis用于处理分页的一个类,offset默认为0,limit默认为Integer.MAX_VALUE
    3. <select>标签中定义的sql语句
    4. 输入参数的具体参数值,一个int值就update一个int,一个String值就update一个String,一个List就轮询里面的每个元素进行update

    即只要两次查询满足以上三个条件且没有定义flushCache="true",那么第二次查询会直接从MyBatis一级缓存PerpetualCache中返回数据,而不会走DB。

    MyBatis二级缓存

    上面说完了MyBatis,接着看一下MyBatis二级缓存,还是从二级缓存工作流程开始。还是从DefaultSqlSession的selectList方法进去:

     1 public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
     2     try {
     3       MappedStatement ms = configuration.getMappedStatement(statement);
     4       return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
     5     } catch (Exception e) {
     6       throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
     7     } finally {
     8       ErrorContext.instance().reset();
     9     }
    10 }

    执行query方法,方法位于CachingExecutor中:

    1 public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    2     BoundSql boundSql = ms.getBoundSql(parameterObject);
    3     CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    4     return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    5 }

    继续跟第4行的query方法,同样位于CachingExecutor中:

     1 public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
     2       throws SQLException {
     3     Cache cache = ms.getCache();
     4     if (cache != null) {
     5       flushCacheIfRequired(ms);
     6       if (ms.isUseCache() && resultHandler == null) {
     7         ensureNoOutParams(ms, parameterObject, boundSql);
     8         @SuppressWarnings("unchecked")
     9         List<E> list = (List<E>) tcm.getObject(cache, key);
    10         if (list == null) {
    11           list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    12           tcm.putObject(cache, key, list); // issue #578 and #116
    13         }
    14         return list;
    15       }
    16     }
    17     return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    18 }

    从这里看到,执行第17行的BaseExecutor的query方法之前,会先拿Mybatis二级缓存,而BaseExecutor的query方法会优先读取MyBatis一级缓存,由此可以得出一个重要结论:假如定义了MyBatis二级缓存,那么MyBatis二级缓存读取优先级高于MyBatis一级缓存

    而第3行~第16行的逻辑:

    • 第5行的方法很好理解,根据flushCache=true或者flushCache=false判断是否要清理二级缓存
    • 第7行的方法是保证MyBatis二级缓存不会存储存储过程的结果
    • 第9行的方法先尝试从tcm中获取查询结果,这个tcm解释一下,这又是一个装饰器模式(数数MyBatis用到了多少装饰器模式了),创建一个事物缓存TranactionalCache,持有Cache接口,Cache接口的实现类就是根据我们在Mapper文件中配置的<cache>创建的Cache实例
    • 第10行~第12行,如果没有从MyBatis二级缓存中拿到数据,那么就会查一次数据库,然后放到MyBatis二级缓存中去

    至于如何判定上次查询和这次查询是一次查询?由于这里的CacheKey和MyBatis一级缓存使用的是同一个CacheKey,因此它的判定条件和前文写过的MyBatis一级缓存三个维度的判定条件是一致的。

    最后再来谈一点,"Cache cache = ms.getCache()"这句代码十分重要,这意味着Cache是从MappedStatement中获取到的,而MappedStatement又和每一个<insert>、<delete>、<update>、<select>绑定并在MyBatis启动的时候存入Configuration中:

    protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>("Mapped Statements collection");

    因此MyBatis二级缓存的生命周期即整个应用的生命周期,应用不结束,定义的二级缓存都会存在在内存中。

    从这个角度考虑,为了避免MyBatis二级缓存中数据量过大导致内存溢出,MyBatis在配置文件中给我们增加了很多配置例如size(缓存大小)、flushInterval(缓存清理时间间隔)、eviction(数据淘汰算法)来保证缓存中存储的数据不至于太过庞大。

    MyBatis二级缓存实例化过程

    接着看一下MyBatis二级缓存<cache>实例化的过程,代码位于XmlMapperBuilder的cacheElement方法中:

     1 private void cacheElement(XNode context) throws Exception {
     2     if (context != null) {
     3       String type = context.getStringAttribute("type", "PERPETUAL");
     4       Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
     5       String eviction = context.getStringAttribute("eviction", "LRU");
     6       Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
     7       Long flushInterval = context.getLongAttribute("flushInterval");
     8       Integer size = context.getIntAttribute("size");
     9       boolean readWrite = !context.getBooleanAttribute("readOnly", false);
    10       boolean blocking = context.getBooleanAttribute("blocking", false);
    11       Properties props = context.getChildrenAsProperties();
    12       builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    13     }
    14 }

    这里分别取<cache>中配置的各个属性,关注一下两个默认值:

    1. type表示缓存实现,默认是PERPETUAL,根据typeAliasRegistry中注册的,PERPETUAL实际对应PerpetualCache,这和MyBatis一级缓存是一致的
    2. eviction表示淘汰算法,默认是LRU算法

    第3行~第11行拿到了所有属性,那么调用12行的useNewCache方法创建缓存:

     1 public Cache useNewCache(Class<? extends Cache> typeClass,
     2       Class<? extends Cache> evictionClass,
     3       Long flushInterval,
     4       Integer size,
     5       boolean readWrite,
     6       boolean blocking,
     7       Properties props) {
     8     Cache cache = new CacheBuilder(currentNamespace)
     9         .implementation(valueOrDefault(typeClass, PerpetualCache.class))
    10         .addDecorator(valueOrDefault(evictionClass, LruCache.class))
    11         .clearInterval(flushInterval)
    12         .size(size)
    13         .readWrite(readWrite)
    14         .blocking(blocking)
    15         .properties(props)
    16         .build();
    17     configuration.addCache(cache);
    18     currentCache = cache;
    19     return cache;
    20 }

    这里又使用了建造者模式,跟一下第16行的build()方法,在此之前该传入的参数都已经传入了CacheBuilder:

     1 public Cache build() {
     2     setDefaultImplementations();
     3     Cache cache = newBaseCacheInstance(implementation, id);
     4     setCacheProperties(cache);
     5     // issue #352, do not apply decorators to custom caches
     6     if (PerpetualCache.class.equals(cache.getClass())) {
     7       for (Class<? extends Cache> decorator : decorators) {
     8         cache = newCacheDecoratorInstance(decorator, cache);
     9         setCacheProperties(cache);
    10       }
    11       cache = setStandardDecorators(cache);
    12     } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
    13       cache = new LoggingCache(cache);
    14     }
    15     return cache;
    16 }

    第3行的代码,构建基础的缓存,implementation指的是type配置的值,这里是默认的PerpetualCache。

    第6行的代码,如果是PerpetualCache,那么继续装饰(又是装饰器模式,可以数数这几篇MyBatis源码解析的文章里面出现了多少次装饰器模式了),这里的装饰是根据eviction进行装饰,到这一步,给PerpetualCache加上了LRU的功能。

    第11行的代码,继续装饰,这次MyBatis将它命名为标准装饰,setStandardDecorators方法实现为:

     1 private Cache setStandardDecorators(Cache cache) {
     2     try {
     3       MetaObject metaCache = SystemMetaObject.forObject(cache);
     4       if (size != null && metaCache.hasSetter("size")) {
     5         metaCache.setValue("size", size);
     6       }
     7       if (clearInterval != null) {
     8         cache = new ScheduledCache(cache);
     9         ((ScheduledCache) cache).setClearInterval(clearInterval);
    10       }
    11       if (readWrite) {
    12         cache = new SerializedCache(cache);
    13       }
    14       cache = new LoggingCache(cache);
    15       cache = new SynchronizedCache(cache);
    16       if (blocking) {
    17         cache = new BlockingCache(cache);
    18       }
    19       return cache;
    20     } catch (Exception e) {
    21       throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);
    22     }
    23 }

    这次是根据其它的配置参数来:

    • 如果配置了flushInterval,那么继续装饰为ScheduledCache,这意味着在调用Cache的getSize、putObject、getObject、removeObject四个方法的时候都会进行一次时间判断,如果到了指定的清理缓存时间间隔,那么就会将当前缓存清空
    • 如果readWrite=true,那么继续装饰为SerializedCache,这意味着缓存中所有存储的内存都必须实现Serializable接口
    • 跟配置无关,将之前装饰好的Cache继续装饰为LoggingCache与SynchronizedCache,前者在getObject的时候会打印缓存命中率,后者将Cache接口中所有的方法都加了Synchronized关键字进行了同步处理
    • 如果blocking=true,那么继续装饰为BlockingCache,这意味着针对同一个CacheKey,拿数据与放数据、删数据是互斥的,即拿数据的时候必须没有在放数据、删数据

    Cache全部装饰完毕,返回,至此MyBatis二级缓存生成完毕。

    最后说一下,MyBatis支持三种类型的二级缓存:

    • MyBatis默认的缓存,type为空,Cache为PerpetualCache
    • 自定义缓存
    • 第三方缓存

    从build()方法来看,后两种场景的Cache,MyBatis只会将其装饰为LoggingCache,理由很简单,这些缓存的定期清除功能、淘汰过期数据功能开发者自己或者第三方缓存都已经实现好了,根本不需要依赖MyBatis本身的装饰。

    MyBatis二级缓存带来的问题

    补充一个内容,MyBatis二级缓存使用的在某些场景下会出问题,来看一下为什么这么说。

    假设我有一条select语句(开启了二级缓存):

    select a.col1, a.col2, a.col3, b.col1, b.col2, b.col3 from tableA a, tableB b where a.id = b.id;

    对于tableA与tableB的操作定义在两个Mapper中,分别叫做MapperA与MapperB,即它们属于两个命名空间,如果此时启用缓存:

    1. MapperA中执行上述sql语句查询这6个字段
    2. tableB更新了col1与col2两个字段
    3. MapperA再次执行上述sql语句查询这6个字段(前提是没有执行过任何insert、delete、update操作)

    此时问题就来了,即使第(2)步tableB更新了col1与col2两个字段,第(3)步MapperA走二级缓存查询到的这6个字段依然是原来的这6个字段的值,因为我们从CacheKey的3组条件来看:

    1. <select>标签所在的Mapper的Namespace+<select>标签的id属性
    2. RowBounds的offset和limit属性,RowBounds是MyBatis用于处理分页的一个类,offset默认为0,limit默认为Integer.MAX_VALUE
    3. <select>标签中定义的sql语句

    对于MapperA来说,其中的任何一个条件都没有变化,自然会将原结果返回。

    这个问题对于MyBatis的二级缓存来说是一个无解的问题,因此使用MyBatis二级缓存有一个前提:必须保证所有的增删改查都在同一个命名空间下才行

  • 相关阅读:
    用纯CSS改变下拉列表Select框的默认样式
    前端JS来控制选中的项
    Display:table;妙用,使得左右元素高度相同
    服务器与浏览器缓存协商控制机制的总结
    浏览器缓存机制
    高性能网站建设指南总结
    主题:关于CSS细节集合(一)
    [译] 关于CSS中的float和position
    常用前端开发工具合集
    [JavaScript忍者系列] — CSS选择符引擎入门
  • 原文地址:https://www.cnblogs.com/xrq730/p/6991655.html
Copyright © 2020-2023  润新知