【MyBatis原始碼解析】MyBatis一二級快取

五月的倉頡發表於2017-06-12

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二級快取有一個前提:必須保證所有的增刪改查都在同一個名稱空間下才行

相關文章