天天看點

高性能緩存 Caffeine 原理及實戰一、簡介 二、Caffeine 原理 三、Caffeine 實戰 四、總結五、參考文獻

一、簡介

Caffeine 是基于Java 8 開發的、提供了近乎最佳命中率的高性能本地緩存元件,Spring5 開始不再支援 Guava Cache,改為使用 Caffeine。

下面是 Caffeine 官方測試報告。

高性能緩存 Caffeine 原理及實戰一、簡介 二、Caffeine 原理 三、Caffeine 實戰 四、總結五、參考文獻
高性能緩存 Caffeine 原理及實戰一、簡介 二、Caffeine 原理 三、Caffeine 實戰 四、總結五、參考文獻
高性能緩存 Caffeine 原理及實戰一、簡介 二、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。

高性能緩存 Caffeine 原理及實戰一、簡介 二、Caffeine 原理 三、Caffeine 實戰 四、總結五、參考文獻

如下圖緩存通路頻率存儲主要分為兩大部分,即 LRU 和 Segmented LRU 。新通路的資料會進入第一個 LRU,在 Caffeine 裡叫 WindowDeque。當 WindowDeque 滿時,會進入 Segmented LRU 中的 ProbationDeque,在後續被通路到時,它會被提升到 ProtectedDeque。當 ProtectedDeque 滿時,會有資料降級到 ProbationDeque 。資料需要淘汰的時候,對 ProbationDeque 中的資料進行淘汰。具體淘汰機制:取ProbationDeque 中的隊首和隊尾進行 PK,隊首資料是最先進入隊列的,稱為受害者,隊尾的資料稱為攻擊者,比較兩者 頻率大小,大勝小汰。

高性能緩存 Caffeine 原理及實戰一、簡介 二、Caffeine 原理 三、Caffeine 實戰 四、總結五、參考文獻

總的來說,通過 reset 衰減,避免曆史熱點資料由于頻率值比較高一直淘汰不掉,并且通過對通路隊列分成三段,這樣避免了新加入的熱點資料早早地被淘汰掉。

2.2 高性能讀寫

Caffeine 認為讀操作是頻繁的,寫操作是偶爾的,讀寫都是異步線程更新頻率資訊。

2.2.1 讀緩沖

傳統的緩存實作将會為每個操作加鎖,以便能夠安全的對每個通路隊列的元素進行排序。一種優化方案是将每個操作按序加入到緩沖區中進行批處理操作。讀完把資料放到環形隊列 RingBuffer 中,為了減少讀并發,采用多個 RingBuffer,每個線程都有對應的 RingBuffer。環形隊列是一個定長數組,提供高性能的能力并最大程度上減少了 GC所帶來的性能開銷。資料丢到隊列之後就傳回讀取結果,類似于資料庫的WAL機制,和ConcurrentHashMap 讀取資料相比,僅僅多了把資料放到隊列這一步。異步線程并發讀取 RingBuffer 數組,更新通路資訊,這邊的線程池使用的是下文實戰小節講的 Caffeine 配置參數中的 executor。

高性能緩存 Caffeine 原理及實戰一、簡介 二、Caffeine 原理 三、Caffeine 實戰 四、總結五、參考文獻

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

五、參考文獻

  1. TinyLFU: A Highly Efficient Cache Admission Policy
  2. Design Of A Modern Cache
  3. Caffeine Github
作者:Zhang Zhenglin