高性能緩存 Caffeine 原理及實戰

一、簡介

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 有如下參數:

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 裏面的配置項放入配置中心,如果需要動態生效,可以通過如下方式:

//動態調整緩存容量
cache.policy().eviction().ifPresent(eviction -> {
  eviction.setMaximum(2 * eviction.getMaximum());
});
//動態調整緩存有效時間
cache.policy().expireAfterAccess().ifPresent(expiration -> ...);
cache.policy().expireAfterWrite().ifPresent(expiration -> ...);
cache.policy().expireVariably().ifPresent(expiration -> ...);
cache.policy().refreshAfterWrite().ifPresent(expiration -> ...);

至於是否利用 Spring 的 EL 表達式通過註解的方式使用,仁者見仁智者見智,筆者主要考慮三點:

一、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,筆者分享的使用方式可以作爲參考。

五、參考文獻

  1. TinyLFU: A Highly Efficient Cache Admission Policy

  2. Design Of A Modern Cache

  3. Caffeine Github

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GBX0pTS4Jn9ZEVd_cLkovg