高性能緩存 Caffeine 原理及實戰

1、簡介

Caffeine 是基於Java 8 開發的、提供了近乎最佳命中率的高性能本地緩存組件,Spring5 開始再也不支持 Guava Cache,改成使用 Caffeine。html

下面是 Caffeine 官方測試報告java

高性能緩存 Caffeine 原理及實戰

高性能緩存 Caffeine 原理及實戰

高性能緩存 Caffeine 原理及實戰

由上面三幅圖可見:無論在併發讀、併發寫仍是併發讀寫的場景下,Caffeine 的性能都大幅領先於其餘本地開源緩存組件。git

本文先介紹 Caffeine 實現原理,再講解如何在項目中使用 Caffeine 。github

 2、Caffeine 原理

2.1 淘汰算法

2.1.1 常見算法

對於 Java 進程內緩存咱們能夠經過 HashMap 來實現。不過,Java 進程內存是有限的,不可能無限地往裏面放緩存對象。這就須要有合適的算法輔助咱們淘汰掉使用價值相對不高的對象,爲新進的對象留有空間。常見的緩存淘汰算法有 FIFO、LRU、LFU。redis

FIFO(First In First Out):先進先出。算法

它是優先淘汰掉最早緩存的數據、是最簡單的淘汰算法。缺點是若是先緩存的數據使用頻率比較高的話,那麼該數據就不停地進進出出,所以它的緩存命中率比較低。數據庫

LRU(Least Recently Used):最近最久未使用。json

它是優先淘汰掉最久未訪問到的數據。缺點是不能很好地應對偶然的突發流量。好比一個數據在一分鐘內的前59秒訪問不少次,而在最後1秒沒有訪問,可是有一批冷門數據在最後一秒進入緩存,那麼熱點數據就會被沖刷掉。數組

LFU(Least Frequently Used):緩存

最近最少頻率使用。它是優先淘汰掉最不常用的數據,須要維護一個表示使用頻率的字段。

主要有兩個缺點:

1、若是訪問頻率比較高的話,頻率字段會佔據必定的空間;

2、沒法合理更新新上的熱點數據,好比某個歌手的老歌播放歷史較多,新出的歌若是和老歌一塊兒排序的話,就永無出頭之日。

2.1.2 W-TinyLFU 算法

Caffeine 使用了 W-TinyLFU 算法,解決了 LRU 和LFU上述的缺點。W-TinyLFU 算法由論文《TinyLFU: A Highly Efficient Cache Admission Policy》提出。

它主要乾了兩件事:

1、採用 Count–Min Sketch 算法下降頻率信息帶來的內存消耗;

2、維護一個PK機制保障新上的熱點數據可以緩存。

以下圖所示,Count–Min Sketch 算法相似布隆過濾器 (Bloom filter)思想,對於頻率統計咱們其實不須要一個精確值。存儲數據時,對key進行屢次 hash 函數運算後,二維數組不一樣位置存儲頻率(Caffeine 實際實現的時候是用一維 long 型數組,每一個 long 型數字切分紅16份,每份4bit,默認15次爲最高訪問頻率,每一個key實際 hash 了四次,落在不一樣 long 型數字的16份中某個位置)。讀取某個key的訪問次數時,會比較全部位置上的頻率值,取最小值返回。對於全部key的訪問頻率之和有個最大值,當達到最大值時,會進行reset即對各個緩存key的頻率除以2。

高性能緩存 Caffeine 原理及實戰

以下圖緩存訪問頻率存儲主要分爲兩大部分,即 LRU 和 Segmented LRU 。新訪問的數據會進入第一個 LRU,在 Caffeine 裏叫 WindowDeque。當 WindowDeque 滿時,會進入 Segmented LRU 中的 ProbationDeque,在後續被訪問到時,它會被提高到 ProtectedDeque。當 ProtectedDeque 滿時,會有數據降級到 ProbationDeque 。數據須要淘汰的時候,對 ProbationDeque 中的數據進行淘汰。具體淘汰機制:取ProbationDeque 中的隊首和隊尾進行 PK,隊首數據是最早進入隊列的,稱爲受害者,隊尾的數據稱爲***者,比較二者 頻率大小,大勝小汰。

高性能緩存 Caffeine 原理及實戰

總的來講,經過 reset 衰減,避免歷史熱點數據因爲頻率值比較高一直淘汰不掉,而且經過對訪問隊列分紅三段,這樣避免了新加入的熱點數據早早地被淘汰掉。

2.2 高性能讀寫

Caffeine 認爲讀操做是頻繁的,寫操做是偶爾的,讀寫都是異步線程更新頻率信息。

2.2.1 讀緩衝

傳統的緩存實現將會爲每一個操做加鎖,以便可以安全的對每一個訪問隊列的元素進行排序。一種優化方案是將每一個操做按序加入到緩衝區中進行批處理操做。讀完把數據放到環形隊列 RingBuffer 中,爲了減小讀併發,採用多個 RingBuffer,每一個線程都有對應的 RingBuffer。環形隊列是一個定長數組,提供高性能的能力並最大程度上減小了 GC所帶來的性能開銷。數據丟到隊列以後就返回讀取結果,相似於數據庫的WAL機制,和ConcurrentHashMap 讀取數據相比,僅僅多了把數據放到隊列這一步。異步線程併發讀取 RingBuffer 數組,更新訪問信息,這邊的線程池使用的是下文實戰小節講的 Caffeine 配置參數中的 executor。

高性能緩存 Caffeine 原理及實戰

2.2.2 寫緩衝

與讀緩衝相似,寫緩衝是爲了儲存寫事件。讀緩衝中的事件主要是爲了優化驅逐策略的命中率,所以讀緩衝中的事件完整程度容許必定程度的有損。可是寫緩衝並不容許數據的丟失,所以其必須實現爲一個安全的隊列。Caffeine 寫是把數據放入MpscGrowableArrayQueue 阻塞隊列中,它參考了JCTools裏的MpscGrowableArrayQueue ,是針對 MPSC- 多生產者單消費者(Multi-Producer & Single-Consumer)場景的高性能實現。多個生產者同時併發地寫入隊列是線程安全的,可是同一時刻只容許一個消費者消費隊列。

 3、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 表達式經過註解的方式使用,仁者見仁智者見智,筆者主要考慮三點:

1、EL 表達式上手須要學習成本;

2、引入註解須要注意動態代理失效場景;

獲取緩存時經過以下方式:

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%左右。

 4、總結

Caffeine 是目前比較優秀的本地緩存解決方案,經過使用 W-TinyLFU 算法,實現了緩存高命中率、內存低消耗。若是以前使用過 Guava Cache,看下接口名基本就能上手。若是以前使用的是 Ehcache,筆者分享的使用方式能夠做爲參考。

5、參考文獻

  1. TinyLFU: A Highly Efficient Cache Admission Policy

  2. Design Of A Modern Cache

  3. Caffeine Github

做者:Zhang Zhenglin

相關文章
相關標籤/搜索