一、簡介
Caffeine 是基于Java 8 開發的、提供了近乎最佳命中率的高性能本地緩存元件,Spring5 開始不再支援 Guava Cache,改為使用 Caffeine。
下面是 Caffeine 官方測試報告。
由上面三幅圖可見:不管在并發讀、并發寫還是并發讀寫的場景下,Caffeine 的性能都大幅領先于其他本地開源緩存元件。
本文先介紹 Caffeine 實作原理,再講解如何在項目中使用 Caffeine 。
二、Caffeine 原理
2.1 淘汰算法
2.1.1 常見算法
對于 Java 程序内緩存我們可以通過 HashMap 來實作。不過,Java 程序記憶體是有限的,不可能無限地往裡面放緩存對象。這就需要有合适的算法輔助我們淘汰掉使用價值相對不高的對象,為新進的對象留有空間。常見的緩存淘汰算法有 FIFO、LRU、LFU。
FIFO(First In First Out):先進先出。
它是優先淘汰掉最先緩存的資料、是最簡單的淘汰算法。缺點是如果先緩存的資料使用頻率比較高的話,那麼該資料就不停地進進出出,是以它的緩存命中率比較低。
LRU(Least Recently Used):最近最久未使用。
它是優先淘汰掉最久未通路到的資料。缺點是不能很好地應對偶然的突發流量。比如一個資料在一分鐘内的前59秒通路很多次,而在最後1秒沒有通路,但是有一批冷門資料在最後一秒進入緩存,那麼熱點資料就會被沖刷掉。
LFU(Least Frequently Used):
最近最少頻率使用。它是優先淘汰掉最不經常使用的資料,需要維護一個表示使用頻率的字段。
主要有兩個缺點:
一、如果通路頻率比較高的話,頻率字段會占據一定的空間;
二、無法合理更新新上的熱點資料,比如某個歌手的老歌播放曆史較多,新出的歌如果和老歌一起排序的話,就永無出頭之日。
2.1.2 W-TinyLFU 算法
Caffeine 使用了 W-TinyLFU 算法,解決了 LRU 和LFU上述的缺點。W-TinyLFU 算法由論文《TinyLFU: A Highly Efficient Cache Admission Policy》提出。
它主要幹了兩件事:
一、采用 Count–Min Sketch 算法降低頻率資訊帶來的記憶體消耗;
二、維護一個PK機制保障新上的熱點資料能夠緩存。
如下圖所示,Count–Min Sketch 算法類似布隆過濾器 (Bloom filter)思想,對于頻率統計我們其實不需要一個精确值。存儲資料時,對key進行多次 hash 函數運算後,二維數組不同位置存儲頻率(Caffeine 實際實作的時候是用一維 long 型數組,每個 long 型數字切分成16份,每份4bit,預設15次為最高通路頻率,每個key實際 hash 了四次,落在不同 long 型數字的16份中某個位置)。讀取某個key的通路次數時,會比較所有位置上的頻率值,取最小值傳回。對于所有key的通路頻率之和有個最大值,當達到最大值時,會進行reset即對各個緩存key的頻率除以2。
如下圖緩存通路頻率存儲主要分為兩大部分,即 LRU 和 Segmented LRU 。新通路的資料會進入第一個 LRU,在 Caffeine 裡叫 WindowDeque。當 WindowDeque 滿時,會進入 Segmented LRU 中的 ProbationDeque,在後續被通路到時,它會被提升到 ProtectedDeque。當 ProtectedDeque 滿時,會有資料降級到 ProbationDeque 。資料需要淘汰的時候,對 ProbationDeque 中的資料進行淘汰。具體淘汰機制:取ProbationDeque 中的隊首和隊尾進行 PK,隊首資料是最先進入隊列的,稱為受害者,隊尾的資料稱為攻擊者,比較兩者 頻率大小,大勝小汰。
總的來說,通過 reset 衰減,避免曆史熱點資料由于頻率值比較高一直淘汰不掉,并且通過對通路隊列分成三段,這樣避免了新加入的熱點資料早早地被淘汰掉。
2.2 高性能讀寫
Caffeine 認為讀操作是頻繁的,寫操作是偶爾的,讀寫都是異步線程更新頻率資訊。
2.2.1 讀緩沖
傳統的緩存實作将會為每個操作加鎖,以便能夠安全的對每個通路隊列的元素進行排序。一種優化方案是将每個操作按序加入到緩沖區中進行批處理操作。讀完把資料放到環形隊列 RingBuffer 中,為了減少讀并發,采用多個 RingBuffer,每個線程都有對應的 RingBuffer。環形隊列是一個定長數組,提供高性能的能力并最大程度上減少了 GC所帶來的性能開銷。資料丢到隊列之後就傳回讀取結果,類似于資料庫的WAL機制,和ConcurrentHashMap 讀取資料相比,僅僅多了把資料放到隊列這一步。異步線程并發讀取 RingBuffer 數組,更新通路資訊,這邊的線程池使用的是下文實戰小節講的 Caffeine 配置參數中的 executor。
2.2.2 寫緩沖
與讀緩沖類似,寫緩沖是為了儲存寫事件。讀緩沖中的事件主要是為了優化驅逐政策的命中率,是以讀緩沖中的事件完整程度允許一定程度的有損。但是寫緩沖并不允許資料的丢失,是以其必須實作為一個安全的隊列。Caffeine 寫是把資料放入MpscGrowableArrayQueue 阻塞隊列中,它參考了JCTools裡的MpscGrowableArrayQueue ,是針對 MPSC- 多生産者單消費者(Multi-Producer & Single-Consumer)場景的高性能實作。多個生産者同時并發地寫入隊列是線程安全的,但是同一時刻隻允許一個消費者消費隊列。
三、Caffeine 實戰
3.1 配置參數
Caffeine 借鑒了Guava Cache 的設計思想,如果之前使用過 Guava Cache,那麼Caffeine 很容易上手,隻需要改變相應的類名就行。構造一個緩存 Cache 示例代碼如下:
Cache cache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(6, TimeUnit.MINUTES).softValues().build();
Caffeine 類相當于建造者模式的 Builder 類,通過 Caffeine 類配置 Cache,配置一個Cache 有如下參數:
- expireAfterWrite:寫入間隔多久淘汰;
- expireAfterAccess:最後通路後間隔多久淘汰;
- refreshAfterWrite:寫入後間隔多久重新整理,該重新整理是基于通路被動觸發的,支援異步重新整理和同步重新整理,如果和 expireAfterWrite 組合使用,能夠保證即使該緩存通路不到、也能在固定時間間隔後被淘汰,否則如果單獨使用容易造成OOM;
- expireAfter:自定義淘汰政策,該政策下 Caffeine 通過時間輪算法來實作不同key 的不同過期時間;
- maximumSize:緩存 key 的最大個數;
- weakKeys:key設定為弱引用,在 GC 時可以直接淘汰;
- weakValues:value設定為弱引用,在 GC 時可以直接淘汰;
- softValues:value設定為軟引用,在記憶體溢出前可以直接淘汰;
- executor:選擇自定義的線程池,預設的線程池實作是 ForkJoinPool.commonPool();
- maximumWeight:設定緩存最大權重;
- weigher:設定具體key權重;
- recordStats:緩存的統計資料,比如命中率等;
- removalListener:緩存淘汰監聽器;
- writer:緩存寫入、更新、淘汰的監聽器。
3.2 項目實戰
Caffeine 支援解析字元串參數,參照 Ehcache 的思想,可以把所有緩存項參數資訊放入配置檔案裡面,比如有一個 caffeine.properties 配置檔案,裡面配置參數如下:
users=maximumSize=10000,expireAfterWrite=180s,softValues
goods=maximumSize=10000,expireAfterWrite=180s,softValues
針對不同的緩存,解析配置檔案,并加入 Cache 容器裡面,代碼如下:
@Component
@Slf4j
public class CaffeineManager {
private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);
@PostConstruct
public void afterPropertiesSet() {
String filePath = CaffeineManager.class.getClassLoader().getResource("").getPath() + File.separator + "config"
+ File.separator + "caffeine.properties";
Resource resource = new FileSystemResource(filePath);
if (!resource.exists()) {
return;
}
Properties props = new Properties();
try (InputStream in = resource.getInputStream()) {
props.load(in);
Enumeration propNames = props.propertyNames();
while (propNames.hasMoreElements()) {
String caffeineKey = (String) propNames.nextElement();
String caffeineSpec = props.getProperty(caffeineKey);
CaffeineSpec spec = CaffeineSpec.parse(caffeineSpec);
Caffeine caffeine = Caffeine.from(spec);
Cache manualCache = caffeine.build();
cacheMap.put(caffeineKey, manualCache);
}
}
catch (IOException e) {
log.error("Initialize Caffeine failed.", e);
}
}
}
當然也可以把 caffeine.properties 裡面的配置項放入配置中心,如果需要動态生效,可以通過如下方式:
至于是否利用 Spring 的 EL 表達式通過注解的方式使用,仁者見仁智者見智,筆者主要考慮三點:
一、EL 表達式上手需要學習成本;
二、引入注解需要注意動态代理失效場景;
擷取緩存時通過如下方式:
caffeineManager.getCache(cacheName).get(redisKey, value -> getTFromRedis(redisKey, targetClass, supplier));
Caffeine 這種帶有回源函數的 get 方法最終都是調用 ConcurrentHashMap 的 compute 方法,它能確定高并發場景下,如果對一個熱點 key 進行回源時,單個程序内隻有一個線程回源,其他都在阻塞。業務需要確定回源的方法耗時比較短,防止線程阻塞時間比較久,系統可用性下降。
筆者實際開發中用了 Caffeine 和 Redis 兩級緩存。Caffeine 的 cache 緩存 key 和 Redis 裡面一緻,都是命名為 redisKey。targetClass 是傳回對象類型,從 Redis 中擷取字元串反序列化成實際對象時使用。supplier 是函數式接口,是緩存回源到資料庫的業務邏輯。
getTFromRedis 方法實作如下:
private <T> T getTFromRedis(String redisKey, Class targetClass, Supplier supplier) {
String data;
T value;
String redisValue = UUID.randomUUID().toString();
if (tryGetDistributedLockWithRetry(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue, 30)) {
try {
data = getFromRedis(redisKey);
if (StringUtils.isEmpty(data)) {
value = (T) supplier.get();
setToRedis(redisKey, JackSonParser.bean2Json(value));
}
else {
value = json2Bean(targetClass, data);
}
}
finally {
releaseDistributedLock(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue);
}
}
else {
value = json2Bean(targetClass, getFromRedis(redisKey));
}
return value;
}
由于回源都是從 MySQL 查詢,雖然 Caffeine 本身解決了程序内同一個 key 隻有一個線程回源,需要注意多個業務節點的分布式情況下,如果 Redis 沒有緩存值,并發回源時會穿透到 MySQL ,是以回源時加了分布式鎖,保證隻有一個節點回源。
注意一點:從本地緩存擷取對象時,如果業務要對緩存對象做更新,需要深拷貝一份對象,不然并發場景下多個線程取值會互相影響。
筆者項目之前都是使用 Ehcache 作為本地緩存,切換成 Caffeine 後,涉及本地緩存的接口,同樣 TPS 值時,CPU 使用率能降低 10% 左右,接口性能都有一定程度提升,最多的提升了 25%。上線後觀察調用鍊,平均響應時間降低24%左右。
四、總結
Caffeine 是目前比較優秀的本地緩存解決方案,通過使用 W-TinyLFU 算法,實作了緩存高命中率、記憶體低消耗。如果之前使用過 Guava Cache,看下接口名基本就能上手。如果之前使用的是 Ehcache,筆者分享的使用方式可以作為參考。
五、參考文獻
- TinyLFU: A Highly Efficient Cache Admission Policy
- Design Of A Modern Cache
- Caffeine Github
作者:Zhang Zhenglin