1. 程式人生 > 實用技巧 >建議收藏,Mybatis快取體系結構(上)

建議收藏,Mybatis快取體系結構(上)

在 Web 應用中,快取是必不可少的元件。通常我們都會用 Redis 或 memcached 等快取中介軟體,攔截大量奔向資料庫的請求,減輕資料庫壓力。作為一個重要的元件,MyBatis 自然也在內部提供了相應的支援。通過在框架層面增加快取功能,可減輕資料庫的壓力,同時又可以提升查詢速度,可謂一舉兩得。MyBatis 快取結構由一級快取和二級快取構成,這兩級快取均是使用 Cache 介面的實現類。因此,我將首先會向大家介紹 Cache 幾種實現類的原始碼,然後再分析一級和二級快取的實現。
本文主要內容:

Mybatis快取體系結構
Mybatis跟快取相關的類都在cache包目錄下,在前面的文章中我們也提過,今天才來詳細說說。其中有一個頂層介面Cache,並且只有一個預設的實現類PerpetualCache。
下面是Cached的類圖:

既然PerpetualCache是預設實現類,那麼我們就從他下手。
PerpetualCache
PerpetualCache這個物件會建立,所以這個叫做基礎快取。但是快取又可以有很多額外的功能,比如說:回收策略、日誌記錄、定時重新整理等等,如果需要的話,就可以在基礎快取上加上這些功能,如果不喜歡就不加。這裡是不是想到了一種設計模式-----裝飾器設計模式。PerpetualCache 相當於裝飾模式中的 ConcreteComponent。
裝飾器模式是指在不改變原有物件的基礎之上,將功能附加到物件上,提供了比繼承更有彈性的替換方案,即擴充套件原有物件的功能。
除了快取之外,Mybatis也定義很多的裝飾器,同樣實現了Cache介面,通過這些裝飾器可以額外實現很多功能。
這些快取是怎麼分類的呢?
所有的快取可以大體歸為三類:基本類快取、淘汰演算法快取、裝飾器快取。
下面把每個快取進行詳細說明和對比:

快取實現類原始碼
PerpetualCache原始碼
PerpetualCache 是一個具有基本功能的快取類,內部使用了 HashMap 實現快取功能。它的原始碼如下:
public class PerpetualCache implements Cache {

private final String id;
//使用Map作為快取
private Map<Object, Object> cache = new HashMap<>();

public PerpetualCache(String id) {
this.id = id;
}

@Override
public String getId() {
return id;
}

@Override
public int getSize() {
return cache.size();
}
// 儲存鍵值對到 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);
}
//清空快取
@Override
public void clear() {
cache.clear();
}
//部分程式碼省略
}
複製程式碼
上面是 PerpetualCache 的全部程式碼,也就是所謂的基本快取,很簡單。接下來,我們通過裝飾類對該類進行裝飾,使其功能變的豐富起來。
LruCache
LruCache,顧名思義,是一種具有 LRU(Least recently used,最近最少使用)演算法的快取實現類。
除此之外,MyBatis 還提供了具有 FIFO 策略的快取 FifoCache。不過並未提供 LFU (Least Frequently Used ,最近最少使用演算法)快取,也是一種常見的快取演算法 ,如果大家有興趣,可以自行拓展。
接下來,我們來看一下 LruCache 的實現。
public class LruCache implements Cache {

private final Cache delegate;
private Map<Object, Object> keyMap;
private Object eldestKey;

public LruCache(Cache delegate) {
    this.delegate = delegate;
    setSize(1024);
}

public int getSize() {
    return delegate.getSize();
}

public void setSize(final int size) {
    /*
     * 初始化 keyMap,注意,keyMap 的型別繼承自 LinkedHashMap,
     * 並覆蓋了 removeEldestEntry 方法
     */
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
        private static final long serialVersionUID = 4267176411845948333L;

        // 覆蓋 LinkedHashMap 的 removeEldestEntry 方法
        @Override
        protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
            boolean tooBig = size() > size;
            if (tooBig) {
                // 獲取將要被移除快取項的鍵值
                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) {
    // 重新整理 key 在 keyMap 中的位置
    keyMap.get(key);
    // 從被裝飾類中獲取相應快取項
    return delegate.getObject(key);
}

@Override
public Object removeObject(Object key) {
    // 從被裝飾類中移除相應的快取項
    return delegate.removeObject(key);
}
//清空快取
@Override
public void clear() {
    delegate.clear();
    keyMap.clear();
}

private void cycleKeyList(Object key) {
    // 儲存 key 到 keyMap 中
    keyMap.put(key, key);
    if (eldestKey != null) {
        // 從被裝飾類中移除相應的快取項
        delegate.removeObject(eldestKey);
        eldestKey = null;
    }
}
// 省略部分程式碼

}
複製程式碼
從上面程式碼中可以看出,LruCache 的 keyMap 屬性是實現 LRU 策略的關鍵,該屬性型別繼承自 LinkedHashMap,並覆蓋了 removeEldestEntry 方法。LinkedHashMap 可保持鍵值對的插入順序,當插入一個新的鍵值對時,
LinkedHashMap 內部的 tail 節點會指向最新插入的節點。head 節點則指向第一個被插入的鍵值對,也就是最久未被訪問的那個鍵值對。預設情況下,LinkedHashMap 僅維護鍵值對的插入順序。若要基於 LinkedHashMap 實現 LRU 快取,還需通過構造方法將 LinkedHashMap 的 accessOrder 屬性設為 true,此時 LinkedHashMap 會維護鍵值對的訪問順序。
比如,上面程式碼中 getObject 方法中執行了這樣一句程式碼 keyMap.get(key),目的是重新整理 key 對應的鍵值對在 LinkedHashMap 的位置。LinkedHashMap 會將 key 對應的鍵值對移動到連結串列的尾部,尾部節點表示最久剛被訪問過或者插入的節點。除了需將 accessOrder 設為 true,還需覆蓋 removeEldestEntry 方法。LinkedHashMap 在插入新的鍵值對時會呼叫該方法,以決定是否在插入新的鍵值對後,移除老的鍵值對。
在上面的程式碼中,當被裝飾類的容量超出了 keyMap 的所規定的容量(由構造方法傳入)後,keyMap 會移除最長時間未被訪問的鍵,並儲存到 eldestKey 中,然後由 cycleKeyList 方法將 eldestKey 傳給被裝飾類的 removeObject 方法,移除相應的快取專案。
BlockingCache
BlockingCache 實現了阻塞特性,該特性是基於 Java 重入鎖實現的。同一時刻下,BlockingCache 僅允許一個執行緒訪問指定 key 的快取項,其他執行緒將會被阻塞住。
下面我們來看一下 BlockingCache 的原始碼。
public class BlockingCache implements Cache {

private long timeout;
private final Cache delegate;
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) {
    // 釋放鎖
    releaseLock(key);
    return null;
}

private ReentrantLock getLockForKey(Object key) {
    ReentrantLock lock = new ReentrantLock();
    // 儲存 <key, Lock> 鍵值對到 locks 中
    ReentrantLock previous = locks.putIfAbsent(key, lock);
    return previous == null ? lock : previous;
}

private void acquireLock(Object key) {
    Lock lock = getLockForKey(key);
    if (timeout > 0) {
        try {
            // 嘗試加鎖
            boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
            if (!acquired) {
                throw new CacheException("...");
            }
        } catch (InterruptedException e) {
            throw new CacheException("...");
        }
    } else {
        // 加鎖
        lock.lock();
    }
}

private void releaseLock(Object key) {
    // 獲取與當前 key 對應的鎖
    ReentrantLock lock = locks.get(key);
    if (lock.isHeldByCurrentThread()) {
        // 釋放鎖
        lock.unlock();
    }
}

// 省略部分程式碼

}
複製程式碼
如上,查詢快取時,getObject 方法會先獲取與 key 對應的鎖,並加鎖。若快取命中,getObject 方法會釋放鎖,否則將一直鎖定。getObject 方法若返回 null,表示快取未命中。此時 MyBatis 會進行資料庫查詢,並呼叫 putObject 方法儲存查詢結果。同時,putObject 方法會將指定 key 對應的鎖進行解鎖,這樣被阻塞的執行緒即可恢復執行。
上面的描述有點囉嗦,倒是 BlockingCache 類的註釋說到比較簡單明瞭。這裡引用一下:

It sets a lock over a cache key when the element is not found in cache.
This way, other threads will wait until this element is filled instead of hitting the database.

這段話的意思是,當指定 key 對應元素不存在於快取中時,BlockingCache 會根據 lock 進行加鎖。此時,其他執行緒將會進入等待狀態,直到與 key 對應的元素被填充到快取中。而不是讓所有執行緒都去訪問資料庫。
在上面程式碼中,removeObject 方法的邏輯很奇怪,僅呼叫了 releaseLock 方法釋放鎖,卻沒有呼叫被裝飾類的 removeObject 方法移除指定快取項。這樣做是為什麼呢?大家可以先思考,答案將在分析二級快取的相關邏輯時分析。
CacheKey
在 MyBatis 中,引入快取的目的是為提高查詢效率,降低資料庫壓力。既然 MyBatis 引入了快取,那麼大家思考過快取中的 key 和 value 的值分別是什麼嗎?大家可能很容易能回答出 value 的內容,不就是 SQL 的查詢結果嗎。
那 key 是什麼呢?是字串,還是其他什麼物件?如果是字串的話,那麼大家首先能想到的是用 SQL 語句作為 key。但這是不對的.
比如:
SELECT * FROM author where id > ?
複製程式碼
d > 1 和 id > 10 查出來的結果可能是不同的,所以我們不能簡單的使用 SQL 語句作為 key。從這裡可以看出來,執行時引數將會影響查詢結果,因此我們的 key 應該涵蓋執行時引數。除此之外呢,如果進行分頁查詢也會導致查詢結果不同,因此 key 也應該涵蓋分頁引數。綜上,我們不能使用簡單的 SQL 語句作為 key。應該考慮使用一種複合物件,能涵蓋可影響查詢結果的因子。在 MyBatis 中,這種複合物件就是 CacheKey。
下面來看一下它的定義。
public class CacheKey implements Cloneable, Serializable {
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;

// 乘子,預設為37
private final int multiplier;
// CacheKey 的 hashCode,綜合了各種影響因子
private int hashcode;
// 校驗和
private long checksum;
// 影響因子個數
private int count;
// 影響因子集合
private List<Object> updateList;

public CacheKey() {
    this.hashcode = DEFAULT_HASHCODE;
    this.multiplier = DEFAULT_MULTIPLYER;
    this.count = 0;
    this.updateList = new ArrayList<Object>();
}
// 省略其他方法

}
複製程式碼
如上,除了 multiplier 是恆定不變的 ,其他變數將在更新操作中被修改。
下面看一下更新操作的程式碼。
/** 每當執行更新操作時,表示有新的影響因子參與計算 */
public void update(Object object) {
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
// 自增 count
count++;
// 計算校驗和
checksum += baseHashCode;
// 更新 baseHashCode
baseHashCode *= count;

// 計算 hashCode
hashcode = multiplier * hashcode + baseHashCode;

// 儲存影響因子
updateList.add(object);

}
複製程式碼
當不斷有新的影響因子參與計算時,hashcode 和 checksum 將會變得愈發複雜和隨機。這樣可降低衝突率,使 CacheKey 可在快取中更均勻的分佈。CacheKey 最終要作為鍵存入 HashMap,因此它需要覆蓋 equals 和 hashCode 方法。
下面我們來看一下這兩個方法的實現。
public boolean equals(Object object) {
// 檢測是否為同一個物件
if (this == object) {
return true;
}
// 檢測 object 是否為 CacheKey
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;

// 檢測 hashCode 是否相等
if (hashcode != cacheKey.hashcode) {
    return false;
}
// 檢測校驗和是否相同
if (checksum != cacheKey.checksum) {
    return false;
}
// 檢測 coutn 是否相同
if (count != cacheKey.count) {
    return false;
}

// 如果上面的檢測都通過了,下面分別對每個影響因子進行比較
for (int i = 0; i < updateList.size(); i++) {
    Object thisObject = updateList.get(i);
    Object thatObject = cacheKey.updateList.get(i);
    if (!ArrayUtil.equals(thisObject, thatObject)) {
        return false;
    }
}
return true;

}

public int hashCode() {
// 返回 hashcode 變數
return hashcode;
}
複製程式碼
equals 方法的檢測邏輯比較嚴格,對 CacheKey 中多個成員變數進行了檢測,已保證兩者相等。hashCode 方法比較簡單,返回 hashcode 變數即可。
關於 CacheKey 就先分析到這,CacheKey 在一二級快取中會被用到,接下來還會看到它的身影。
好吧終於把原始碼快取實現類的原始碼說完了。

作者:田維常
連結:https://juejin.cn/post/6907923325805133837
來源:掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。