寫在前面
聊一聊MyBatis是如何使用裝飾者模式的,順便回顧下快取的相關知識,可以看看右側目錄一覽內容概述。
裝飾者模式
這裡就不了它的概念了,總結下就是套娃。利用組合的方式將裝飾器組合進來,增強共同的抽象方法(與代理很類似但是又更靈活)
MyBatis快取
回憶下傳統手藝
<!-- 先進先出,60秒重新整理一次,可儲存512個引用,返回物件只讀,不同執行緒中的呼叫者之間修改會導致衝突 -->
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
粗略回顧下MyBatis快取
一級快取
MyBatis的一級快取存在於SqlSession的生命週期中,在同一個SqlSession中查詢時,MyBatis會把執行的方法和引數通過演算法生成快取的鍵值,將鍵值和查詢結果存入一個Map物件中。如果同一個SqlSession中執行的方法和引數完全一致,那麼通過演算法會生成相同鍵值,當Map快取物件中已經存在該鍵值時,則會返回快取中的物件。
預設開啟
二級快取
MyBatis的二級快取非常強大,它不同於一級快取只存在於SqlSession的生命週期中,而是可以理解為存在於SqlSessionFactory的生命週期中。
預設不開啟,需要如下配置後開啟全域性配置,再在對應的Mapper.xml中新增“傳統手藝”-
<settings>
<setting name = "cacheEnabled" value="true"/>
</settings>
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
另一種開啟方式-註解
@CacheNamespace(
eviction = FifoCache.class,
flushInterval = 60000,
size = 512,
readWrite = true
)
public interface RoleMapper {
// 介面方法
}
- eviction(收回策略)
- LRU(最近最少使用的):移除長時間不使用的物件,這是預設值
- FIFO(先進先出):按物件進入快取的順序來移除它們
- SOFT(軟引用):移除基於垃圾回收器狀態和軟引用規則的物件
- WEAK(弱引用):更積極地移除基於垃圾收集器狀態和弱引用規則的物件
- flushInterval(重新整理間隔)
- size(引用數目)
- readOnly(只讀)只讀的快取會給所有呼叫者返回快取的相同例項,因此這些物件不能被修改,這提供了很重要的效能優勢。可讀寫的快取會通過序列化返回快取物件的拷貝,這種方式會慢一些,但是安全,因此預設是false
整合第三方快取
MyBatis還支援通過“type”來整合第三方快取,如下就是整合了Redis快取,這樣就從本地快取跳躍到了分散式快取了。
<mapper namespace="xxx.xxx.xxx.mapper.RoleMapper">
<!-- 整合Redis快取-->
<cache type="org.mybatis.caches.redis.RedisCache" />
</mapper>
二級快取的問題-髒資料
二級快取雖然能提高應用效率,減輕資料庫伺服器的壓力,但是如果使用不當,很容易產生髒資料
MyBatis的二級快取是和名稱空間繫結的,所以通常情況下每一個Mapper對映檔案都擁有自己的二級快取,不同Mapper的二級快取互不影響。在常見的資料庫操作中,多表聯合查詢非常常見,由於關係型資料庫的設計,使得很多時候需要關聯多個表才能獲得想要的資料。在關聯多表查詢時肯定會將查詢放到某個名稱空間下的對映檔案中,這樣一個多表的查詢就會快取在該名稱空間的二級快取中。涉及這些表的增刪改操作通常不在一個對映檔案中,它們的名稱空間不同,因此當有資料變化時,多表查詢的快取未必會被清空,這種情況下就會產生髒資料。
基於MyBatis快取機制結合原始碼解析裝飾器模式
Cache介面:
Cache核心方法:
- putObject
- getObject
- removeObject
DEMO-實戰使用MyBatis的裝飾者模式
public static void main(String[] args) {
final String cacheKey = "cache";
final Cache cache = new LoggingCache(new BlockingCache(new PerpetualCache(cacheKey)));
Object cacheValue = cache.getObject(cacheKey);
if (Objects.isNull(cacheValue)) {
log.debug("快取未命中 >>>>>>>>> key:[{}]", cacheKey);
cache.putObject(cacheKey, "MyCacheValue");
}
cacheValue = cache.getObject(cacheKey);
log.debug("快取命中 >>>>>>>>> key:[{}],value:[{}]", cacheKey, cacheValue);
}
如程式碼所示,是不是看到了“裝飾者模式”的影子了,在建構函式中瘋狂套娃。使用的是MyBatis的API,給基本快取元件裝飾了“日誌列印”、“阻塞“的能力。
結果演示:
可以看到,LogginCache在讀快取的時候還會列印出快取命中率。 好了,接下來進入正題,看看其他快取是怎麼實現的吧。以下原始碼基於MyBatis3.4.5
PerpetualCache
private final Map<Object, Object> cache = new HashMap<>();
@Override
public void putObject(Object key, Object value) {
cache.put(key, value);
}
@Override
public Object getObject(Object key) {
return cache.get(key);
}
@Override
public Object removeObject(Object key) {
return cache.remove(key);
}
這是MyBatis的基礎快取,套娃的基本得有它,它的核心就是個HashMap來作為快取容器,其實現的Cache介面的幾個核心方法也都是委託給了HashMap去做。
FifoCache
一個支援先進先出的快取策略的MyBatisCache
private final Cache delegate;
//維護一個key的雙端佇列
private final Deque<Object> keyList;
private int size;
public FifoCache(Cache delegate) {
//通過建構函式,將Cache組合進來,取名”委託“
this.delegate = delegate;
this.keyList = new LinkedList<>();
this.size = 1024;
}
@Override
public void putObject(Object key, Object value) {
//先走自己的增強
cycleKeyList(key);
//真實的寫快取交給”委託“去做
delegate.putObject(key, value);
}
@Override
public Object getObject(Object key) {
return delegate.getObject(key);
}
@Override
public Object removeObject(Object key) {
return delegate.removeObject(key);
}
private void cycleKeyList(Object key) {
//將新寫的快取key新增到雙端佇列末尾
keyList.addLast(key);
// 如果key的大小大於了1024(建構函式中預設賦值1024)則會移除最早新增的快取
// 1. 移除自身維護的key佇列的隊頭 2.委託給“委託”去真實刪除隊頭快取物件
if (keyList.size() > size) {
Object oldestKey = keyList.removeFirst();
delegate.removeObject(oldestKey);
}
}
以上就是MyBatis先進先出快取的實現了,FifoCache維護了key的雙端佇列,每次寫快取的時候會判斷大小如果大於閾值則會先移除隊頭的key,再委託給組合進來的Cache來刪除對應快取操作,完成“先進先出”的增強(裝飾)
LruCache
一個支援LRU(Least Recently Used ,最近最少使用)快取策略的MyBatisCache
回憶下快取策略
- LRU:Least Recently Used,最近最少使用
- LFU:Least Frequently Used,最近不常被使用
LRU 演算法有一個缺點,比如說很久沒有使用的一個鍵值,如果最近被訪問了一次,那麼即使它是使用次數最少的快取,它也不會被淘汰;而 LFU 演算法解決了偶爾被訪問一次之後,資料就不會被淘汰的問題,它是根據總訪問次數來淘汰資料的,其核心思想是“如果資料過去被訪問多次,那麼將來它被訪問次數也會比較多”。因此 LFU 可以理解為比 LRU 更加合理的淘汰演算法。
回憶下LinkedHashMap的核心機制-LRU
LinkedHashMap相比HashMap多了兩個節點,before,after這樣就能夠維護節點之間的順序了。
我們看看LinkedHashMap的get方法,它內部有LinkedHashMap開啟LRU機制的祕密。
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder) // 為true則會執行afterNodeAccess(將節點移動到隊尾)
afterNodeAccess(e);
return e.value;
}
void afterNodeAccess(Node<K,V> e) { // move node to last (官方註釋 言簡意賅 -> 將節點移動到隊尾)
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
那麼這個accessOrder變數是怎麼維護的呢?看程式碼
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
你會發現,LinkedHashMap有這麼一個建構函式,第三個引數便是accessOrder,所以決定是否開啟LRU是你在執行時傳參決定的!開啟後則會在每次讀取鍵值對之後將讀取的節點移動至隊尾,那麼隊頭就是最近最少使用的了,隊尾就是剛剛使用的了,當需要刪除最近最少使用的節點的時候,直接刪除隊頭的即可。
回憶下LinkedHashMap的核心方法-removeEldestEntry
LinkedHashMap是一個有順序的HashMap,它可以使得你的k,v能夠按照某種順序寫入和讀取,它的核心方法removeEldestEntry功不可沒。
在HashMap新增k,v之後會回撥一個方法“afterNodeInsertion”,這個方法在HashMap中是一個空實現(俗稱鉤子方法),它的子類LinkedHashMap重寫了它,程式碼如下。
void afterNodeInsertion(boolean evict) { // possibly remove eldest 這是官方註釋,言簡意賅(可能會刪除老key)
LinkedHashMap.Entry<K,V> first;
//前面的短路方法不管,我們關注removeEldestEntry方法 -> 如果該方法也返回true,則會走方法體中的removeNode方法(刪除first節點的元素)。
// 當開啟LinkedHashMap的LRU模式,則隊頭的元素是“最近最少使用的元素”,因為每次讀取k,v後都會將元素調整至隊尾,所以隊頭的元素是“最近最少使用的元素“
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
進入正題
private final Cache delegate;
// 維護一個key和value都是快取key的map
private Map<Object, Object> keyMap;
//最近最少使用的Key
private Object eldestKey;
public LruCache(Cache delegate) {
//通過建構函式,將Cache組合進來,取名”委託“
this.delegate = delegate;
//初始化keyMap(重要)
setSize(1024);
}
public void setSize(final int size) {
// 建構函式第三個引數傳遞true(accessOrder),如上所述將開啟LRU模式
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
// 重寫了LinkedHashMap的方法
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
// 大小超過閾值,將隊頭(最近最少使用)的key更新至自身維護的"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) {
keyMap.get(key); // touch
return delegate.getObject(key);
}
@Override
public Object removeObject(Object key) {
return delegate.removeObject(key);
}
private void cycleKeyList(Object key) {
// 因為重寫了LinkedHashMap的removeEldestEntry方法,如上所述,超過閾值後eldestKey指向的就是最近最少使用的key
keyMap.put(key, key);
if (eldestKey != null) {
// 委託移除最近最少使用的快取
delegate.removeObject(eldestKey);
// 置空
eldestKey = null;
}
}
以上就是MyBatis中的LRU快取的機制了,自身維護了一個LinkedHashMap,開啟了LRU機制,重寫了removeEldestEntry方法,當大小觸發閾值的時候維護最近最少使用的元素key,委託給組合進來的Cache物件移除,整個流程下來就使得被裝飾著有了LRU的增強。
SoftCache
一個軟引用的MyBatisCache
弱引用
弱引用比強引用稍弱一些。當JVM記憶體不足時,GC才會回收那些只被軟引用指向的物件,從而避免OutOfMemoryError。當GC將只被軟引用指向的物件全部回收之後,記憶體依然不足時,JVM才會丟擲OutOfMemoryError。(這一特性非常適合做快取,畢竟最終資料來源在DB,還能保護JVM程式)
// 維護最近經常使用的快取資料,該集合會使用強引用指向其中的每個快取Value,防止被GC回收
private final Deque<Object> hardLinksToAvoidGarbageCollection;
//與SortEntry物件關聯,用於記錄已經被回收的快取條目
private final ReferenceQueue<Object> queueOfGarbageCollectedEntries;
private final Cache delegate;
//強引用的個數,預設256。即有256個熱點資料無法直接被GC回收
private int numberOfHardLinks;
public SoftCache(Cache delegate) {
this.delegate = delegate;
this.numberOfHardLinks = 256;
this.hardLinksToAvoidGarbageCollection = new LinkedList<Object>();
this.queueOfGarbageCollectedEntries = new ReferenceQueue<Object>();
}
@Override
public void putObject(Object key, Object value) {
// 同步刪除已經被GC回收的Value
removeGarbageCollectedItems();
delegate.putObject(key, new SoftEntry(key, value, queueOfGarbageCollectedEntries));
}
private static class SoftEntry extends SoftReference<Object> {
private final Object key;
SoftEntry(Object key, Object value, ReferenceQueue<Object> garbageCollectionQueue) {
// 關聯引用佇列。
// 當SoftReference指向的物件被回收的時候,JVM就會將這個SoftReference作為通知,新增到與其關聯的引用佇列
super(value, garbageCollectionQueue);
this.key = key;
}
}
@Override
public Object getObject(Object key) {
Object result = null;
@SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
SoftReference<Object> softReference = (SoftReference<Object>) delegate.getObject(key); // 委託獲取快取
if (softReference != null) {
result = softReference.get();
if (result == null) {
// 重要的一步!判斷Value是否為空,為空則表示弱引用指向的物件已經被GC回收了,就需要同步刪除該快取。
delegate.removeObject(key);
} else {
// See #586 (and #335) modifications need more than a read lock
// 讀取快取後,維護“強引用”的資料。
synchronized (hardLinksToAvoidGarbageCollection) {
hardLinksToAvoidGarbageCollection.addFirst(result); // 將快取新增進強引用佇列(熱點資料)
if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
hardLinksToAvoidGarbageCollection.removeLast(); // 維護佇列個數
}
}
}
}
return result;
}
@Override
public Object removeObject(Object key) {
removeGarbageCollectedItems(); // 刪除被GC回收的Value
return delegate.removeObject(key); // 委託刪除快取
}
private void removeGarbageCollectedItems() {
SoftEntry sv;
// 引用關聯的佇列如果有值,則說明有被GC回收的Value
while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
delegate.removeObject(sv.key);
}
}
WeakCache
一個弱引用的MyBatisCache
與弱引用類似(基本相同),不過多介紹了。
弱引用
弱引用比軟引用的引用強度還要弱。弱引用可以引用一個物件,但無法阻止這個物件被GC回收,也就是說,在JVM進行垃圾回收的時候,若發現某個物件只有一個弱引用指向它,那麼這個物件會被GC立刻回收。(即遇GC比死,存活的時間為兩次GC之間)
// Entry繼承的是WeakReference。
// 其他內容參考弱引用Cache
private static class WeakEntry extends WeakReference<Object> {
private final Object key;
private WeakEntry(Object key, Object value, ReferenceQueue<Object> garbageCollectionQueue) {
super(value, garbageCollectionQueue);
this.key = key;
}
}
LoggingCache
一個支援列印Debug級別的快取命中率的MyBatisCache
// 日誌列印的log物件
private final Log log;
private final Cache delegate;
// 請求數
protected int requests = 0;
// 快取命中數
protected int hits = 0;
public LoggingCache(Cache delegate) {
//通過建構函式,將Cache組合進來,取名”委託“
this.delegate = delegate;
//log通過快取id作為表示
this.log = LogFactory.getLog(getId());
}
@Override
public void putObject(Object key, Object object) {
delegate.putObject(key, object);
}
@Override
public Object getObject(Object key) {
requests++; // 請求數增加
final Object value = delegate.getObject(key);
if (value != null) {
hits++; // 快取命中,命中數增加
}
if (log.isDebugEnabled()) {
log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio()); // 列印快取命中率
}
return value;
}
@Override
public Object removeObject(Object key) {
return delegate.removeObject(key);
}
private double getHitRatio() {
// 計算快取命中率
return (double) hits / (double) requests;
}
LoggingCache使得快取讀取的時候能夠有快取命中率的日誌列印,挺實用的增強。
BlockingCache
一個支援阻塞的MyBatisCache
private long timeout;
private final Cache delegate;
//每個key都有自己的ReentrantLock
private final ConcurrentHashMap<Object, ReentrantLock> locks;
public BlockingCache(Cache delegate) {
this.delegate = delegate;
this.locks = new ConcurrentHashMap<Object, ReentrantLock>();
}
@Override
public void putObject(Object key, Object value) {
try {
delegate.putObject(key, value); // 委託寫入快取
} finally {
releaseLock(key); // 釋放鎖
}
}
@Override
public Object getObject(Object key) {
acquireLock(key); // 嘗試獲取鎖
Object value = delegate.getObject(key);
if (value != null) {
releaseLock(key); // 獲取到快取後 釋放鎖
}
return value;
}
@Override
public Object removeObject(Object key) {
// despite of its name, this method is called only to release locks
releaseLock(key); // 釋放鎖
return null;
}
private void acquireLock(Object key) {
Lock lock = getLockForKey(key); // 獲取對應的Lock,沒有則新增一把Lock
if (timeout > 0) {
try {
boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS); // 嘗試超時加鎖
if (!acquired) {
throw new CacheException("Couldn't get a lock in " + timeout + " for the key " + key + " at the cache " + delegate.getId());
}
} catch (InterruptedException e) {
throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
}
} else {
lock.lock(); // 加鎖
}
}
private ReentrantLock getLockForKey(Object key) {
ReentrantLock lock = new ReentrantLock();
ReentrantLock previous = locks.putIfAbsent(key, lock);
return previous == null ? lock : previous;
}
private void releaseLock(Object key) {
ReentrantLock lock = locks.get(key); // 獲取Key對應的Lock
if (lock.isHeldByCurrentThread()) { // 如果是當前執行緒持有lock,則釋放鎖
lock.unlock();
}
}
SynchronizedCache
一個支援同步的MyBatisCache,從名稱就能知道實現原理是synchronized關鍵字
public SynchronizedCache(Cache delegate) {
this.delegate = delegate;
}
@Override
public synchronized int getSize() {
return delegate.getSize();
}
@Override
public synchronized void putObject(Object key, Object object) {
delegate.putObject(key, object);
}
@Override
public synchronized Object getObject(Object key) {
return delegate.getObject(key);
}
@Override
public synchronized Object removeObject(Object key) {
return delegate.removeObject(key);
}
同步快取就是給核心方法加上了同步鎖,保證了執行緒安全。
跟隨原始碼看看解析-裝飾過程
cacheElement方法解析cache標籤
可以看出最底層是PerpetualCache,預設裝飾的是LruCache。
如下就是將剩下的裝飾器迴圈裝飾的過程了,細節就不追進去了。
以上就是MyBatis對於快取的裝飾者設計模式的實踐相關的原始碼簡單追蹤了。
跟隨原始碼看看快取的使用的地方
先隨便點選Cache介面的一方法,看看在哪裡有使用。很明顯,那個BaseExecutor的類就是正兒八經使用的地方。
query方法中很明顯表示了先從快取中獲取,如果沒有則走DB(還會寫快取)
程式碼也很簡單,就是從DB獲取然後寫入快取
總結
筆者先簡單描述了裝飾者模式,隨後回憶了MyBatis的快取傳統手藝-cache標籤的使用,以及一級二級快取,描述了整合第三方快取(解決JVM快取的單點問題)。
隨後結合原始碼介紹了MyBatis的Cache介面及其相關的實現類,首先通過Demo言簡意賅地表達了裝飾者模式的使用以及MyBatisCache裝飾者模式使用的效果(LoggingCache)
緊接著筆者介紹了
- PerpetualCache這個最關鍵最核心的快取實現類,它的核心是一個HashMap;
- FifoCache先進先出淘汰策略的快取實現類,它的核心是一個維護key的雙端佇列,新增快取前先維護這個雙端佇列,如果size到達閾值則移除隊頭的元素;
- LruCache最近最少使用淘汰策略的快取實現類,它的核心是基於LinkedHashMap實現LRU機制,我們也回憶了LRU以及LinkedHashMap相關的知識點,其關鍵點就是一個繼承了LinkedHashMap的keyMap(KV都是快取Key),重寫了LinkedHashMap的重要方法removeEldestEntry,用於記錄最近最少使用的key,在適當時機刪除該快取;
- SoftCache、WeakCache我們回憶了軟引用、弱引用的相關知識,其核心就是對應的Value元件Entry繼承了SoftReference、WeakReference;
- BlockingCache這個阻塞快取的核心就是大名鼎鼎的ReentrantLock;
- SynchronizedCache這個快取顧名思義就是核心方法追加了synchronized的關鍵字,事實也確實如此。
為什麼要使用快取?走DB的鏈路上層用快取抗一抗再正常不過了。 為什麼用裝飾者模式?這個場景它的核心就是快取策略有很多,它們互相可以疊加,可以在配置的時候靈活配置,那麼就可以通過解析配置後在執行時靈活的“裝飾”起來,達到最後的預期效果,挺妙的。
關於多種Cache的核心實現,以及相關的周邊技術可以反覆琢磨,比如鎖的使用、快取的讀寫、LinkedHashMap、JVM的GC等等,畢竟這是開源框架的實戰程式碼,這些都是值得我們像駱駝一樣反覆咀嚼,反覆反芻的,至少了解了這一塊,後續你真的有類似實戰的時候之前可以先參考參考了!
好了,以上就是MyBatis快取解析-裝飾者設計模式了。歡迎多多交流,希望對你有幫助。原創不易..(沒想到這麼難,本來想總結下,發現一兩次還寫不完,光扣字都扣傻了 哈哈..)