從原始碼層面談談mybatis的快取設計
在從原始碼聊聊mybatis一次查詢都經歷了些什麼 一文中我們梳理了mybatis執行查詢SQL的具體流程,在Executor中簡單提到了快取。本文將從原始碼一步一步詳細解析mybatis快取的架構,以及自定義快取等相關內容。由於一級快取是寫死在程式碼裡面的,所以本文重點討論的是二級快取,下文中提到的快取如果沒有特別指定的話都是指二級快取。
自定義快取
實現自定義快取非常簡單,只需要實現org.apache.ibatis.cache.Cache
介面,然後為需要的Mapper配置實現就可以了。
下面的程式碼是一個簡單的快取實現
@Slf4j public class MyCache implements Cache, InitializingObject { private String id; private String key; private Map<Object, Object> table = new ConcurrentHashMap<>(); public MyCache(String id) { this.id = id; } @Override public void initialize() throws Exception { log.info("id = {}", id); log.info("key = {}", key); } // ...... } 複製程式碼
使用註解方式為Mapper配置快取,使用XML配置也是類似的
@Mapper @CacheNamespace( // 指定實現類 implementation = MyCache.class, // 指定淘汰策略(也實現了Cache介面),mybatis通過裝飾者模式實現淘汰策略 // 只有當implementation是PerpetualCache時才會生效 eviction = LruCache.class, // 配置快取屬性,mybatis會將對應的屬性注入到快取物件中 properties = { @Property(name = "key", value = "hello mybatis") } ) public interface AddressMapper { // ...... } 複製程式碼
快取物件的建立
快取是何時建立的呢?我們不妨想一下,快取是配置在Mapper上的,那麼應該會在解析Mapper的時候順便把快取配置也解析了吧。我們不妨先看看Mapper配置解析的程式碼,Configuration類新增Mapper時會呼叫org.apache.ibatis.binding.MapperRegistry
的addMapper
方法,如下所示,很直觀的,這裡使用了一個叫做MapperAnnotationBuilder
的類來解析Mapper
註解。
public <T> void addMapper(Class<T> type) { if (type.isInterface()) { knownMappers.put(type, new MapperProxyFactory<T>(type)); MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type); parser.parse(); } } 複製程式碼
那麼我們關注一下這個類的parse
方法,非常棒,我們一下子就找到了解析快取配置的地方。
public void parse() { String resource = type.toString(); if (!configuration.isResourceLoaded(resource)) { loadXmlResource(); configuration.addLoadedResource(resource); assistant.setCurrentNamespace(type.getName()); // 解析快取 parseCache(); // 解析引用的快取 parseCacheRef(); Method[] methods = type.getMethods(); for (Method method : methods) { if (!method.isBridge()) { // 解析生成MappedStatement parseStatement(method); } } } parsePendingMethods(); } 複製程式碼
parseCache
方法也非常直觀,簡單粗暴,取出@CacheNamespace
註解中的配置,然後傳遞給MapperBuilderAssistant#useNewCache
方法建立快取物件,MapperBuilderAssistant
是構建Mapper的一個輔助類。
private void parseCache() { CacheNamespace cacheDomain = type.getAnnotation(CacheNamespace.class); if (cacheDomain != null) { Integer size = cacheDomain.size() == 0 ? null : cacheDomain.size(); Long flushInterval = cacheDomain.flushInterval() == 0 ? null : cacheDomain.flushInterval(); // 把屬性配置轉成Properties物件 Properties props = convertToProperties(cacheDomain.properties()); assistant.useNewCache(cacheDomain.implementation(), cacheDomain.eviction(), flushInterval, size, cacheDomain.readWrite(), cacheDomain.blocking(), props); } } 複製程式碼
先把快取物件新增到配置物件的登錄檔中,這樣的話其他的Mapper就可以通過配置@CacheNamespaceRef
來引用這個快取物件了。然後設定快取物件到輔助類的成員變數,在後面建立MappedStatement時候拿出使用。
public Cache useNewCache(/* ... */) { Cache cache = new CacheBuilder(currentNamespace) .implementation(valueOrDefault(typeClass, PerpetualCache.class)) .addDecorator(valueOrDefault(evictionClass, LruCache.class)) .clearInterval(flushInterval) .size(size) .readWrite(readWrite) .blocking(blocking) .properties(props) .build(); // 新增到配置物件的快取登錄檔中 configuration.addCache(cache); // 設定為當前Mapper的快取,後面構建MappedStatement的時候會用到 currentCache = cache; return cache; } 複製程式碼
然後再看看CacheBuilder#build
方法都幹了些啥吧,具體細節我註釋在下面的程式碼裡面。
public Cache build() { // 首先,確保實現類和淘汰策略為空的時候,設定預設的實現PerpetualCache和LruCache setDefaultImplementations(); // 這裡要求實現的快取類必須提供一個帶id引數的構造器,不然就會報錯 Cache cache = newBaseCacheInstance(implementation, id); // 設定通過@Property配置的屬性到快取物件中,然後如果實現了InitializingObject介面還會呼叫initialize方法 setCacheProperties(cache); // 從下面這段邏輯可以看出來,我們配置的快取淘汰策略只對預設快取有效果 // 自定義快取需要自己實現淘汰策略 if (PerpetualCache.class.equals(cache.getClass())) { for (Class<? extends Cache> decorator : decorators) { cache = newCacheDecoratorInstance(decorator, cache); setCacheProperties(cache); } cache = setStandardDecorators(cache); } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) { cache = new LoggingCache(cache); } return cache; } 複製程式碼
這個建立好的快取是如何配置到MappedStatement中去的呢?回到MapperAnnotationBuilder#parse
方法找到parseStatement(method)
,最終會呼叫到MapperBuilderAssistant#addMappedStatement()
方法,下面程式碼就會把剛才建立的快取物件設定到每個MappedStatement中去,由此可見mybatis二級快取的作用域是整個Mapper的(如果被其他Mapper引用,還會擴張)
。
public MappedStatement addMappedStatement(/* ... */) { id = applyCurrentNamespace(id, false); boolean isSelect = sqlCommandType == SqlCommandType.SELECT; MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType) /* ... */ .flushCacheRequired(valueOrDefault(flushCache, !isSelect)) .useCache(valueOrDefault(useCache, isSelect)) .cache(currentCache); /* ... */ MappedStatement statement = statementBuilder.build(); configuration.addMappedStatement(statement); return statement; } 複製程式碼
到這裡終於是把我們自定義的快取設定到了配置中了,接下來就是快取的使用了。
快取的使用
在從原始碼聊聊mybatis一次查詢都經歷了些什麼
這篇文章中簡單提到過快取的使用是在CachingExecutor
中。再把程式碼貼過來看一看:
public <E> List<E> query(/* ... */) throws SQLException { // 這裡就取到前面設定到ms(MappedStatement)中的快取物件了 Cache cache = ms.getCache(); if (cache != null) { // 通過上面的配置就能知道,預設情況下除了select都需要清空 flushCacheIfRequired(ms); if (ms.isUseCache() && resultHandler == null) { // 又懵逼了?這個tcm是啥 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); } return list; } } return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); } 複製程式碼
一切都順理成章了,不過半路殺出個程咬金,這個tcm(TransactionalCacheManager)是什麼東西呢?看看下面這張圖,mybatis的每次會話(SqlSession)都會建立一個tcm,這個tcm裡面其實維護著一個HashMap,map的key就是Mapper的cache物件,value是一個使用TransactionalCache
裝飾的cache物件。
{% asset_img cache.svg mybatis快取 %}
從名字就可以猜一猜,這個TransactionalCache應該是和事務有關係的,從下面的程式碼可以看出,putObject操作並沒有直接新增到快取中,而是先put到一個本地Map,然後再批量提交。getObject快取未命中時會把key新增到一個本地的Set中,在未來批量提交的時候會把這個Set中的key也put到快取中,value設定為null,來防止快取擊穿。
public class TransactionalCache implements Cache { public void putObject(Object key, Object object) { entriesToAddOnCommit.put(key, object); } public Object getObject(Object key) { Object object = delegate.getObject(key); if (object == null) { // 未命中key新增到Set中 entriesMissedInCache.add(key); } /* ... */ } public void clear() { clearOnCommit = true; entriesToAddOnCommit.clear(); } public void commit() { // clearOnCommit在TransactionalCache#clear方法被呼叫後設置為true // 此時才會在提交的時候清空delegate if (clearOnCommit) { delegate.clear(); } flushPendingEntries(); reset(); } private void flushPendingEntries() { for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) { delegate.putObject(entry.getKey(), entry.getValue()); } // 為未命中的key設定null for (Object entry : entriesMissedInCache) { if (!entriesToAddOnCommit.containsKey(entry)) { delegate.putObject(entry, null); } } } } 複製程式碼
至於什麼時候commit會被呼叫呢,我們再回看一下TransactionalCacheManager的commit,會提交當前SqlSession所有Mapper的快取,而TransactionalCacheManager的commit是在CachingExecutor的commit中呼叫的,而Executor的commit又依賴與SqlSession的commit操作,也就是說,如果我們不手動呼叫SqlSession的commit的話,就只能等到SqlSession關閉的時候才會提交這個查詢快取。
public void commit() { for (TransactionalCache txCache : transactionalCaches.values()) { txCache.commit(); } } 複製程式碼
從原始碼我們不難發現CachingExecutor在每次呼叫update方法的時候,都會先清空TransactionalCache的本地的HashMap,然後在提交的時候再清空Mapper的快取。因此,在更新操作比較頻繁的場景下,二級快取反而不會起到很好的作用。所以是否開啟二級快取,還要取決於業務場景。可能大部分的場景下,關閉二級快取都是一個比較不錯的方案。