Java技術分享:如何設計一個本地快取?

千鋒武漢發表於2021-04-21

前言

最近在看Mybatis的原始碼,剛好看到快取這一塊,Mybatis提供了一級快取和二級快取;一級快取相對來說比較簡單,功能比較齊全的是二級快取,基本上滿足了一個快取該有的功能;當然如果拿來和專門的快取框架如ehcache來對比可能稍有差距;本文小千帶大家來整理一下實現一個本地快取都應該需要考慮哪些東西。

考慮點

考慮點主要在資料用何種方式儲存,能儲存多少資料,多餘的資料如何處理等幾個點,下面我們來詳細的介紹每個考慮點,以及該如何去實現;

1.資料結構

首要考慮的就是資料該如何儲存,用什麼資料結構儲存,最簡單的就直接用Map來儲存資料;或者複雜的如redis一樣提供了多種資料型別雜湊,列表,集合,有序集合等,底層使用了雙端連結串列,壓縮列表,集合,跳躍表等資料結構;

2.物件上限

因為是本地快取,記憶體有上限,所以一般都會指定快取物件的數量比如1024,當達到某個上限後需要有某種策略去刪除多餘的資料;

3.清除策略

上面說到當達到物件上限之後需要有清除策略,常見的比如有LRU(最近最少使用)、FIFO(先進先出)、LFU(最近最不常用)、SOFT(軟引用)、WEAK(弱引用)等策略;

4.過期時間

除了使用清除策略,一般本地快取也會有一個過期時間設定,比如redis可以給每個key設定一個過期時間,這樣當達到過期時間之後直接刪除,採用清除策略+過期時間雙重保證;

5.執行緒安全

像redis是直接使用單執行緒處理,所以就不存線上程安全問題;而我們現在提供的本地快取往往是可以多個執行緒同時訪問的,所以執行緒安全是不容忽視的問題;並且執行緒安全問題是不應該拋給使用者去保證;

6.簡明的介面

提供一個傻瓜式的對外介面是很有必要的,對使用者來說使用此快取不是一種負擔而是一種享受;提供常用的get,put,remove,clear,getSize方法即可;

7.是否持久化

這個其實不是必須的,是否需要將快取資料持久化看需求;本地快取如ehcache是支援持久化的,而guava是沒有持久化功能的;分散式快取如redis是有持久化功能的,memcached是沒有持久化功能的;

8.阻塞機制

在看Mybatis原始碼的時候,二級快取提供了一個blocking標識,表示當在快取中找不到元素時,它設定對快取鍵的鎖定;這樣其他執行緒將等待此元素被填充,而不是命中資料庫;其實我們使用快取的目的就是因為被快取的資料生成比較費時,比如呼叫對外的介面,查詢資料庫,計算量很大的結果等等;這時候如果多個執行緒同時呼叫get方法獲取的結果都為null,每個執行緒都去執行一遍費時的計算,其實也是對資源的浪費;比較好的辦法是隻有一個執行緒去執行,其他執行緒等待,計算一次就夠了;但是此功能基本上都交給使用者來處理,很少有本地快取有這種功能;

如何實現

以上大致介紹了實現一個本地快取我們都有哪些需要考慮的地方,當然可能還有其他沒有考慮到的點;下面繼續看看關於每個點都應該如何去實現,重點介紹一下思路;

1.資料結構

本地快取最常見的是直接使用Map來儲存,比如guava使用ConcurrentHashMap,ehcache也是用了ConcurrentHashMap,Mybatis二級快取使用HashMap來儲存:

Map<Object, Object> cache = new ConcurrentHashMap<Object, Object>()

Mybatis使用HashMap本身是非執行緒安全的,所以可以看到起內部使用了一個SynchronizedCache用來包裝,保證執行緒的安全性;

當然除了使用Map來儲存,可能還使用其他資料結構來儲存,比如redis使用了雙端連結串列,壓縮列表,整數集合,跳躍表和字典;當然這主要是因為redis對外提供的介面很豐富除了雜湊還有列表,集合,有序集合等功能;

2.物件上限

本地快取常見的一個屬性,一般快取都會有一個預設值比如1024,在使用者沒有指定的情況下預設指定;當快取的資料達到指定最大值時,需要有相關策略從快取中清除多餘的資料這就涉及到下面要介紹的清除策略;

3.清除策略

配合物件上限之後使用,場景的清除策略如:LRU(最近最少使用)、FIFO(先進先出)、LFU(最近最不常用)、SOFT(軟引用)、WEAK(弱引用);

LRU:Least Recently Used的縮寫最近最少使用,移除最長時間不被使用的物件;常見的使用LinkedHashMap來實現,也是很多本地快取預設使用的策略;

FIFO:先進先出,按物件進入快取的順序來移除它們;常見使用佇列Queue來實現;

LFU:Least Frequently Used的縮寫大概也是最近最少使用的意思,和LRU有點像;區別點在LRU的淘汰規則是基於訪問時間,而LFU是基於訪問次數的;可以透過HashMap並且記錄訪問次數來實現;

SOFT:軟引用基於垃圾回收器狀態和軟引用規則移除物件;常見使用SoftReference來實現;

WEAK:弱引用更積極地基於垃圾收集器狀態和弱引用規則移除物件;常見使用WeakReference來實現;

4.過期時間

設定過期時間,讓快取資料在指定時間過後自動刪除;常見的過期資料刪除策略有兩種方式:被動刪除和主動刪除;

被動刪除:每次進行get/put操作的時候都會檢查一下當前key是否已經過期,如果過期則刪除,類似如下程式碼:

if (System.currentTimeMillis() - lastClear > clearInterval) {

clear();

}

主動刪除:專門有一個job在後臺定期去檢查資料是否過期,如果過期則刪除,這其實可以有效的處理冷資料;

5.執行緒安全

儘量用執行緒安全的類去儲存資料,比如使用ConcurrentHashMap代替HashMap;或者提供相應的同步處理類,比如Mybatis提供了SynchronizedCache:

public synchronized void putObject(Object key, Object object) {

...省略...

}

@Override

public synchronized Object getObject(Object key) {

...省略...

}

6.簡明的介面

提供常用的get,put,remove,clear,getSize方法即可,比如Mybatis的Cache介面:

public interface Cache {

String getId();

void putObject(Object key, Object value);

Object getObject(Object key);

Object removeObject(Object key);

void clear();

int getSize();

ReadWriteLock getReadWriteLock();

}

再來看看guava提供的Cache介面,相對來說也是比較簡潔的:

public interface Cache<K, V> {

V getIfPresent(@CompatibleWith("K") Object key);

V get(K key, Callable<? extends V> loader) throws ExecutionException;

ImmutableMap<K, V> getAllPresent(Iterable<?> keys);

void put(K key, V value);

void putAll(Map<? extends K, ? extends V> m);

void invalidate(@CompatibleWith("K") Object key);

void invalidateAll(Iterable<?> keys);

void invalidateAll();

long size();

CacheStats stats();

ConcurrentMap<K, V> asMap();

void cleanUp();

}

7.是否持久化

持久化的好處是重啟之後可以再次載入檔案中的資料,這樣就起到類似熱載入的功效;比如ehcache提供了是否持久化磁碟快取的功能,將快取資料存放在一個.data檔案中;

diskPersistent="false" //是否持久化磁碟快取

redis更是將持久化功能發揮到極致,慢慢的有點像資料庫了;提供了AOF和RDB兩種持久化方式;當然很多情況下可以配合使用兩種方式;

8.阻塞機制

除了在Mybatis中看到了BlockingCache來實現此功能,之前在看<<java併發程式設計實戰>>的時候其中有實現一個很完美的快取,大致程式碼如下:

public class Memoizerl<A, V> implements Computable<A, V> {

private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();

private final Computable<A, V> c;

public Memoizerl(Computable<A, V> c) {

this.c = c;

}

@Override

public V compute(A arg) throws InterruptedException, ExecutionException {

while (true) {

Future<V> f = cache.get(arg);

if (f == null) {

Callable<V> eval = new Callable<V>() {

@Override

public V call() throws Exception {

return c.compute(arg);

}

};

FutureTask<V> ft = new FutureTask<V>(eval);

f = cache.putIfAbsent(arg, ft);

if (f == null) {

f = ft;

ft.run();

}

try {

return f.get();

} catch (CancellationException e) {

cache.remove(arg, f);

}

}

}

}

}

compute是一個計算很費時的方法,所以這裡把計算的結果快取起來,但是有個問題就是如果兩個執行緒同時進入此方法中怎麼保證只計算一次,這裡最核心的地方在於使用了ConcurrentHashMap的putIfAbsent方法,同時只會寫入一個FutureTask;

總結:要設計一個本地快取都需要考慮哪些點:資料結構,物件上限,清除策略,過期時間,執行緒安全,阻塞機制,實用的介面,是否持久化;當然肯定有其他考慮點,歡迎補充。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31548651/viewspace-2769039/,如需轉載,請註明出處,否則將追究法律責任。

相關文章