MyBatis Cache
빈번한 데이터베이스 작업은 성능을 많이 소모한다는 것을 알고 있습니다(주로 DB의 경우 데이터가 디스크에 유지되므로 쿼리 작업은 IO를 거쳐야 하기 때문에 IO 작업 속도는 메모리에 비해 작업 속도가 몇 배나 느립니다. 특히 일부 동일한 쿼리 문의 경우 쿼리 결과를 저장할 수 있으며 다음에 동일한 내용을 쿼리할 때 데이터를 메모리에서 직접 얻을 수 있습니다. 특정 시간 쿼리 효율성은 일부 시나리오에서 크게 향상될 수 있습니다.
MyBatis의 캐시는 두 가지 유형으로 나뉩니다.
첫 번째 수준 캐시는 SqlSession 수준 캐시입니다.
두 번째 수준 캐시는 Mapper 수준에 있는 캐시입니다.
MyBatis의 첫 번째 및 두 번째 레벨 캐시를 자세히 살펴보겠습니다.
MyBatis 1차 캐시 워크플로
그런 다음 MyBatis 1차 캐시 워크플로를 살펴보세요. 앞서 언급했듯이 MyBatis의 첫 번째 수준 캐시는 SqlSession() 메서드 실행이 완료되거나 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의 쿼리 메서드로 이동합니다.
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행의 쿼리 메서드 구현을 살펴보세요. 코드는 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 #11613 }14 return list;15 }16 }17 return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);18 }
3~16행의 코드를 무시하고 17행의 쿼리 메서드를 계속 진행합니다. 코드는 In 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一级缓存存储流程看完了,接着我们从这段代码中可以得到三个结论:
MyBatis的一级缓存是SqlSession级别的,但是它并不定义在SqlSessio接口的实现类DefaultSqlSession中,而是定义在DefaultSqlSession的成员变量Executor中,Executor是在openSession的时候被实例化出来的,它的默认实现为SimpleExecutor
MyBatis中的一级缓存,与有没有配置无关,只要SqlSession存在,MyBastis一级缓存就存在,localCache的类型是PerpetualCache,它其实很简单,一个id属性+一个HashMap属性而已,id是一个名为"localCache"的字符串,HashMap用于存储数据,Key为CacheKey,Value为查询结果
MyBatis的一级缓存查询的时候默认都是会先尝试从一级缓存中获取数据的,但是我们看第6行的代码做了一个判断,ms.isFlushCacheRequired(),即想每次查询都走DB也行,将,这意味着每次查询的时候都会清理一遍PerpetualCache,PerpetualCache中没数据,自然只能走DB
从MyBatis一级缓存来看,它以单纯的HashMap做缓存,没有容量控制,而一次SqlSession中通常来说并不会有大量的查询操作,因此只适用于一次SqlSession,如果用到二级缓存的Mapper级别的场景,有可能缓存数据不断碰到而导致内存溢出。
还有一点,差点忘了写了,
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 ...2 CacheKey cacheKey = new CacheKey();3 cacheKey.update(ms.getId());4 cacheKey.update(rowBounds.getOffset());5 cacheKey.update(rowBounds.getLimit());6 cacheKey.update(boundSql.getSql());7 ...
到了这里应当一目了然了,MyBastis从三组共四个条件判断两次查询是相同的:
RowBounds的offset和limit属性,RowBounds是MyBatis用于处理分页的一个类,offset默认为0,limit默认为Integer.MAX_VALUE
即只要两次查询满足以上三个条件且没有定义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 #11613 }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文件中配置的
第10行~第12行,如果没有从MyBatis二级缓存中拿到数据,那么就会查一次数据库,然后放到MyBatis二级缓存中去
至于如何判定上次查询和这次查询是一次查询?由于这里的CacheKey和MyBatis一级缓存使用的是同一个CacheKey,因此它的判定条件和前文写过的MyBatis一级缓存三个维度的判定条件是一致的。
最后再来谈一点,"Cache cache = ms.getCache()"这句代码十分重要,这意味着Cache是从MappedStatement中获取到的,而MappedStatement又和每一个
protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>("Mapped Statements collection");
因此MyBatis二级缓存的生命周期即整个应用的生命周期,应用不结束,定义的二级缓存都会存在在内存中。
从这个角度考虑,为了避免MyBatis二级缓存中数据量过大导致内存溢出,MyBatis在配置文件中给我们增加了很多配置例如size(缓存大小)、flushInterval(缓存清理时间间隔)、eviction(数据淘汰算法)来保证缓存中存储的数据不至于太过庞大。
MyBatis二级缓存实例化过程
接着看一下MyBatis二级缓存
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 }
这里分别取
type表示缓存实现,默认是PERPETUAL,根据typeAliasRegistry中注册的,PERPETUAL实际对应PerpetualCache,这和MyBatis一级缓存是一致的
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에 대한 작업은 각각 MapperA와 MapperB라는 두 개의 Mapper에 정의됩니다. 즉, 이때 캐싱이 활성화된 경우:
위의 SQL을 실행합니다. MapperA의 문은 이 6개 필드를 쿼리합니다
tableB는 col1 및 col2 필드를 업데이트했습니다
MapperA는 위의 SQL 문을 다시 실행하여 이 6개 필드를 쿼리합니다(삽입이나 삽입이 없는 경우). 실행됨) 삭제, 업데이트 작업)
이때 문제가 발생합니다. tableB가 (2)단계에서 col1 및 col2 필드를 업데이트하더라도 MapperA는 (3)단계에서 2단계 캐시를 사용합니다. 쿼리된 6개 필드는 CacheKey의 세 가지 조건 세트를 살펴보기 때문에 여전히 원래 6개 필드의 값입니다.
RowBounds의 오프셋 및 제한 속성에 있습니다. RowBounds는 페이징을 처리하기 위해 MyBatis에서 사용하는 클래스이며 기본값은 Integer.MAX_VALUE
< select>
라벨에 정의된 sql 문은 MapperA의 경우 어떠한 조건도 변경되지 않았으며 자연스럽게 원래 결과가 반환됩니다.
이 문제는 마이바티스의 2차 캐시에서는 해결할 수 없는 문제이므로 마이바티스의 2차 캐시를 사용하기 위해서는 전제조건이 있습니다:
모든 추가, 삭제, 수정 및 쿼리가 동일한 네임스페이스에 있는지 확인해야 합니다..
위 내용은 [마이바티스 소스코드 분석] 마이바티스 1차, 2차 캐시의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!