深入了解MyBatis - 二級緩存
前言
在MyBatis的緩存體系中,存在一級與二級緩存。我們在上個章節中講解了MyBatis一級緩存的實作,同時我們也知道了MyBatis一級緩存是會話級别的緩存,隻能在同個會話線程中發揮作用,無法滿足應用級别緩存的需求,其作用與意義都不算大。而MyBatis的二級緩存是一個應用級别的緩存,在整體設計上彌補了一級緩存的不足。二級緩存在其設計上,具有良好的擴充性,同時非常靈活,支援第三方外部存儲。如果想要設計一個優秀的應用緩存,那麼MyBatis的緩存體系設計将會是你非常好的老師。研究MyBatis的二級緩存實作,定會讓你受益匪淺。
1、如果設計一個應用級緩存
在進入正題之前,我們先一起來思考一個問題,如何設計好一個應用級别的緩存,或者說,一個應用級的緩存,最基本上需要實作哪些功能?
我相信,絕大多數的Java開發者的職業生涯中都會有面臨這個問題的一天。不管在之前有沒有思考過這個問題,讓我們現在來一起讨論分析下,一個應用級别緩存功能的實作,最起碼需要考慮哪些東西以及實作哪些功能。
第一,作為緩存,我們在長期以來對其最直覺的了解就是,要快,至少比直接查詢資料庫要快。在這個要點上,我們就已經面臨了兩個問題,資料存儲在什麼地方?并且存儲的地方的通路速度要比資料庫更快。對于這個問題,我們可以有三個選擇,一、磁盤,二、記憶體,三是第三方軟體。如果想用第三方的軟體,那麼我們在緩存的存儲設計上,就需要好好考慮緩存的存儲設計的可擴充性,當然這個問題是無法避免的。
第二,不管是存儲在磁盤,記憶體,還是交由第三方軟體存儲,都對緩存的資料容量存在限制。如果縱容緩存資料的增大,那其實緩存就失去了其意義。那麼在緩存的資料容量超過了我們限定的大小時,我們要如何去選擇資料的溢出淘汰機制。在計算機領域存在非常出名的兩種算法,FIFO(先進先出)和LRU(最近最少使用),幾乎在任何的緩存溢出淘汰機制實作中,都可以看到他們的身影。此外,由此問題還能引出資料的過期清理。
第三,作為應用級别的緩存,不可避免的線程安全問題也是重中之重。在多線程下,我們如何保證緩存資料的ACID,這可能會使設計一個應用緩存中最難的一部分。
第四,當我們把資料存儲在系統外部或其他第三方軟體時,但凡涉及到網絡傳輸或持久化時,還必須将資料序列化。
當我們淺顯的分析應用緩存時,以上的四點是必須具備的基本功能。那麼MyBatis的二級緩存也會具備上面的四個基本功能,不妨思考下,作為一個成熟的架構,MyBatis的作者會如何設級上述功能的實作,接下來,讓我們帶着這個問題,走進MyBatis二級緩存的源碼。
2、Cache體系
1、Cache體系的設計
在前兩章的都出現的BaseExecutor源碼中,其中有一個PerpetualCache對象,用來存儲以及緩存的資料。我們在上一章中說,PerpetualCache是Cache中最終用來存儲資料的實作,其内部隻維護了一個HashMap來存儲緩存資料,對于一級緩存來說,一個HashMap存儲資料可以說是遊刃有餘,顯然這種做法在并不适用于應用級别的緩存。此外,PerpetualCache繼承了一個Cache接口,是MyBatis中定義的用來實作緩存的相關操作的頂層接口。作為緩存操作的提供者,實際上Cache中的方法隻有幾個,我們來看看都有哪些。
public interface Cache {
// 擷取ID,對應Mapper的namespace
String getId();
void putObject(Object key, Object value);
Object getObject(Object key);
Object removeObject(Object key);
void clear();
int getSize();
ReadWriteLock riteLock();
}

在這張繼承圖中,可以看到在MyBatis中關于緩存有具備了哪些功能。
- LonggingCache:用于記錄緩存的命中率
- PerpetualCache:用于資料存儲
- SynchronizedCache:同步緩存,防止多線程問題,實作方法就是在方法上加上synchronized關鍵字。現在已經沒用了
- ScheduledCache:定時重新整理緩存,預設每小時重新整理一次緩存
- SoftCache:軟引用緩存,使用連結清單來應用緩存資料,防止垃圾回收,主要是将緩存資料對象轉成SoftReference
- WeakCache:弱應用緩存,于SoftCache代碼如出一轍,隻是将SoftReference換成了WeakReference
- SerializedCache:序列化和反序列化,做法是将對象序列化成二進制,将縮了對象的大小,省記憶體空間
- BlockingCache:用來阻塞緩存資料的存取
- LruCache:内部使用了一個LinkedHashMap來實作LRU,至于具體怎麼做的,我們稍後再分析
- FifoCache:同LruCache一樣,用于控制緩存資料的容量,内部使用連結清單實作
- TransactionalCache:事務緩存,于二級緩存的事務有關,後面再詳述
可以看到Cache接口一共有11個實作,各自都有其作用,顧名思義,各自的用途也都很明顯。實作也都很簡單,其中比較具有分析價值的是LruCache,下面讓我們來看看MyBatis作者是怎麼實作的。
public class LruCache implements Cache {
private final Cache delegate;
//額外用了一個map才做LRU,但是委托的Cache裡面其實也是一個map,這樣等于用2倍的記憶體實作LRU功能
private Map<Object, Object> keyMap;
private Object eldestKey;
public LruCache(Cache delegate) {
this.delegate = delegate;
setSize(1024);
}
@Override
public String getId() {
return delegate.getId();
}
@Override
public int getSize() {
return delegate.getSize();
}
public void setSize(final int size) {
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
//核心就是覆寫 LinkedHashMap.removeEldestEntry方法,
//傳回true或false告訴 LinkedHashMap要不要删除此最老鍵值
//LinkedHashMap内部其實就是每次通路或者插入一個元素都會把元素放到連結清單末尾,
//這樣不經常通路的鍵值肯定就在連結清單開頭啦
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
//這裡沒轍了,把eldestKey存入執行個體變量
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
//增加新紀錄後,判斷是否要将最老元素移除
cycleKeyList(key);
}
@Override
public Object getObject(Object key) {
//get的時候調用一下LinkedHashMap.get,讓經常通路的值移動到連結清單末尾
keyMap.get(key); //touch
return delegate.getObject(key);
}
@Override
public Object removeObject(Object key) {
return delegate.removeObject(key);
}
@Override
public void clear() {
delegate.clear();
keyMap.clear();
}
@Override
public ReadWriteLock getReadWriteLock() {
return null;
}
private void cycleKeyList(Object key) {
keyMap.put(key, key);
//keyMap是linkedhashmap,最老的記錄已經被移除了,然後這裡我們還需要移除被委托的那個cache的記錄
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
}
可以看到,在LruCache中,另外維護了一個有序的LinkedHashMap,用于記錄緩存key的最新通路順序,即最新通路的資料總是處于連結清單的尾部。在類初始化時,建立LinkedHashMap,并将其size預設設定為1024,當LinkedHashMap的長度超過1024時,map對象會移除該key。作者通過重寫了removeEldestEntry方法來在移除最老的鍵值對是擷取最少被通路的緩存key。之是以要擷取該key,是因為LruCache并不是最終實作資料緩存的類,在其之後,資料最終可能存儲在PerpetualCache或者其他第三方實作,當在LruCache中移除該緩存key時,也需要通過委托的cache去删除對應的緩存資料。
此外,FifoCache的實作與LruCache大體一緻,隻是将LinkedHashMap換成了LinkedList,利用清單來實作緩存資料的FIFO。
2、cache之間的關系
雖然在代碼結構上,各種緩存相關功能的實作都很簡單和清晰,但是相信會有不少同學會有疑問,各個功能之間是如何協作的呢?如果細心觀察的話會發現,在上面LruCache的源碼中,存在着一個delegate對象,該對象也是一個Cache對象。不少人看到這裡其實已經發現了,緩存體系其實是采用了責任鍊加上裝飾者模式來實作的。在每個Cache的實作類中,除了PerpetualCache,其他的實作類都持有了一個叫做delegate的Cache對象,在各自的方法實作對應的邏輯之後,交由delegate去做後續的邏輯,最終由PerpetualCache去實作資料的緩存。下面列舉幾個二級緩存關系圖
在責任鍊設計模式中,會為請求或功能的實作建立一個接受者對象的鍊,通常一個接受者都會包含另外一個接收者的引用,把相同的請求傳遞給下一個處理對象。通常在對象建立時就需要确定接受處理者的關系鍊,MyBatis中的Cache鍊也不另外。在标簽中,存在着這樣的幾個配置項。
這幾個配置項在mapper檔案的代碼編寫階段就确定,MyBatis在建構mapper檔案的MappedStatement對象時就将對應的Cache對象建立好。其對應的代碼在XMLMapperBuilder的cacheElement方法中。在确定了參數之後,由CacheBuilder建立cache對象。
public Cache build() {
setDefaultImplementations();
//先new一個base的cache(PerpetualCache)
Cache cache = newBaseCacheInstance(implementation, id);
//設額外屬性
setCacheProperties(cache);
if (PerpetualCache.class.equals(cache.getClass())) {
for (Class<? extends Cache> decorator : decorators) {
//裝飾者模式一個個包裝cache
cache = newCacheDecoratorInstance(decorator, cache);
//又要來一遍設額外屬性
setCacheProperties(cache);
}
//最後附加上标準的裝飾者
cache = setStandardDecorators(cache);
} else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
//如果是custom緩存,且不是日志,要加日志
cache = new LoggingCache(cache);
}
return cache;
}
private void setDefaultImplementations() {
//又是一重保險,如果為null則設預設值,和XMLMapperBuilder.cacheElement以及MapperBuilderAssistant.useNewCache邏輯重複了
if (implementation == null) {
implementation = PerpetualCache.class;
if (decorators.isEmpty()) {
decorators.add(LruCache.class);
}
}
}
//最後附加上标準的裝飾者
private Cache setStandardDecorators(Cache cache) {
try {
MetaObject metaCache = SystemMetaObject.forObject(cache);
if (size != null && metaCache.hasSetter("size")) {
metaCache.setValue("size", size);
}
if (clearInterval != null) {
//重新整理緩存間隔,怎麼重新整理呢,用ScheduledCache來刷,還是裝飾者模式
cache = new ScheduledCache(cache);
((ScheduledCache) cache).setClearInterval(clearInterval);
}
if (readWrite) {
//如果readOnly=false,可讀寫的緩存 會傳回緩存對象的拷貝(通過序列化) 。這會慢一些,但是安全,是以預設是 false。
cache = new SerializedCache(cache);
}
//日志緩存
cache = new LoggingCache(cache);
//同步緩存, 3.2.6以後這個類已經沒用了,考慮到Hazelcast, EhCache已經有鎖機制了,是以這個鎖就畫蛇添足了。
cache = new SynchronizedCache(cache);
if (blocking) {
cache = new BlockingCache(cache);
}
return cache;
} catch (Exception e) {
throw new CacheException("Error building standard cache decorators. Cause: " + e, e);
}
}
3、二級緩存的命中條件
在前面我們講到過,CachingExecutor執行器用于實作二級緩存。CachingExecutor在BaseExecutor通過裝飾者模式在原有的邏輯包裝上二級緩存相關的邏輯。
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
Cache cache = ms.getCache();
// 預設情況下是沒有開啟緩存的(二級緩存).要開啟二級緩存,你需要在你的 SQL 映射檔案中添加一行: <cache/>
// 簡單的說,就是先查CacheKey,查不到再委托給實際的執行器去查
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);
}
return list;
}
}
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
二級緩存的命中條件與一級緩存一樣可以分為運作時相關的參數和配置相關的參數。二級緩存的運作時命中條件與一級緩存隻有一個差別,在有在會話送出後才能命中緩存,在CachingExecutor中的TransactionalCacheManager用來實作會話之間的事務緩存管理。而二級緩存的配置參數除了cacheEnable、useCache、flushCache之外,還需要聲明緩存空間或緩存引用空間。xml方式和注解方式都有對應的配置如 、@CacheNamespace、和@CacheNamespaceRef等。
4、二級緩存是如何保證事務的
1、為什麼要送出之後才能命中緩存
二級緩存時應用級的緩存,必須滿足跨線程使用,當多個線程同時讀寫同一資料時,不可避免的會出現髒讀現象。假設現在存在兩個線程同時通路同一資料,線程一修改了該資料,同時線程二通路該資料,讀到了線程一剛剛修改的資料。線上程二讀取完資料之後,線程一出于某些原因復原了修改的資料,這時便導緻了髒讀。
如果将流程修改程下圖所示,線上程中修改的資料存儲在臨時存儲區中,本線程再讀資料時,可以通路到自己修改後的資料。在事務送出之後,再将本線程修改的資料送出到二級緩存和資料庫,進而避免髒讀現象。
2、事務緩存管理
在CachingExecutor中維護了一個TransactionCacheManager對象,也就是事務緩存管理器,其内部管理着一個Map。這個Map就是我們上面說的臨時緩存區,也叫事務暫存區。事務暫存區的鍵為一個Cache對象,值為TransactionCache對象。
在二級緩存中存儲了多個表的緩存資料,假設,我們将每個表的緩存空間成為緩存區,則在事務暫存區中,每一個鍵值對就對應一個緩存區。而事務暫存區中的緩存,隻有當事務送出時,才将暫存區中的所有事務送出。