幹掉GuavaCache:Caffeine纔是本地緩存的王

點擊上方“朱小廝的博客”,選擇“設爲星標”

後臺回覆"加羣",加入新技術

話說,中間件的選擇上,Spring(SpringBoot)一直是業界的風向標。比如Spring一直使用「Jackson」,而沒有使用Gson和fastjson。SpringBoot2.0默認數據庫連接池從TomcatPool換到了「HikariCP」。在本地緩存方面,SpringFramework5.0(SpringBoot2.0)放棄了Google的GuavaCache,選擇了「Caffeine」(Drop Guava caching - superseded by Caffeine [SPR-13797] #18370)。那麼Caffeine有什麼魔力,能幹掉Google的Guava呢?

壓力測試

我們用數據說話。Caffeine官方利用最權威的壓測工具「JMH」對Caffeine、ConcurrentMap、GuavaCache、ehcache等做了詳細的壓測對比,結果如下:

更多壓測對比請參考:https://github.com/ben-manes/caffeine/wiki/Benchmarks。從官方的壓測結果來看,無論是全讀場景、全寫場景、或者讀寫混合場景,無論是8個線程,還是16個線程,Caffeine都是完勝、碾壓,簡直就是拿着望遠鏡都看不到對手。

簡介

官方介紹Caffeine是基於JDK8的高性能本地緩存庫,提供了幾乎完美的命中率。它有點類似JDK中的ConcurrentMap,實際上,Caffeine中的LocalCache接口就是實現了JDK中的ConcurrentMap接口,但兩者並不完全一樣。最根本的區別就是,ConcurrentMap保存所有添加的元素,除非顯示刪除之(比如調用remove方法)。而本地緩存一般會配置自動剔除策略,爲了保護應用程序,限制內存佔用情況,防止內存溢出。

Caffeine提供了靈活的構造方法,從而創建可以滿足如下特性的本地緩存:

  1. 自動把數據加載到本地緩存中,並且可以配置異步;

  2. 基於數量剔除策略;

  3. 基於失效時間剔除策略,這個時間是從最後一次訪問或者寫入算起;

  4. 異步刷新;

  5. Key會被包裝成Weak引用;

  6. Value會被包裝成Weak或者Soft引用,從而能被GC掉,而不至於內存泄漏;

  7. 數據剔除提醒;

  8. 寫入廣播機制;

  9. 緩存訪問可以統計;

使用

Caffeine使用還是非常簡單的,如果你用過GuavaCache,那就更簡單了,因爲Caffeine的API設計大量借鑑了GuavaCache。首先,引入Maven依賴:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.8.4</version>
</dependency>

然後構造Cache使用即可:

Cache<String, String> cache = Caffeine.newBuilder()
        // 數量上限
        .maximumSize(1024)
        // 過期機制
        .expireAfterWrite(5, TimeUnit.MINUTES)
        // 弱引用key
        .weakKeys()
        // 弱引用value
        .weakValues()
        // 剔除監聽
        .removalListener((RemovalListener<String, String>) (key, value, cause) -> 
                System.out.println("key:" + key + ", value:" + value + ", 刪除原因:" + cause.toString()))
        .build();
// 將數據放入本地緩存中
cache.put("username", "afei");
cache.put("password", "123456");
// 從本地緩存中取出數據
System.out.println(cache.getIfPresent("username"));
System.out.println(cache.getIfPresent("password"));
System.out.println(cache.get("blog", key -> {
    // 本地緩存沒有的話,從數據庫或者Redis中獲取
    return getValue(key);
}));

當然,使用本地緩存時,我們也可以使用異步加載機制:

AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
        // 數量上限
        .maximumSize(2)
        // 失效時間
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .refreshAfterWrite(1, TimeUnit.MINUTES)
        // 異步加載機制
        .buildAsync(new CacheLoader<String, String>() {
            @Nullable
            @Override
            public String load(@NonNull String key) throws Exception {
                return getValue(key);
            }
        });
System.out.println(cache.get("username").get());
System.out.println(cache.get("password").get(10, TimeUnit.MINUTES));
System.out.println(cache.get("username").get(10, TimeUnit.MINUTES));
System.out.println(cache.get("blog").get());

接下來,我們對一些重要特性進行更加深入的分析。

過期機制

本地緩存的過期機制是非常重要的,因爲本地緩存中的數據並不像業務數據那樣需要保證不丟失。本地緩存的數據一般都會要求保證命中率的前提下,儘可能的佔用更少的內存,並可在極端情況下,可以被GC掉。

Caffeine的過期機制都是在構造Cache的時候申明,主要有如下幾種:

  1. expireAfterWrite:表示自從最後一次寫入後多久就會過期;

  2. expireAfterAccess:表示自從最後一次訪問(寫入或者讀取)後多久就會過期;

  3. expireAfter:自定義過期策略;

刷新機制

在構造Cache時通過refreshAfterWrite方法指定刷新週期,例如refreshAfterWrite(10, TimeUnit.SECONDS)表示10秒鐘刷新一次:

.build(new CacheLoader<String, String>() {
    @Override
    public String load(String k) {
        // 這裏我們就可以從數據庫或者其他地方查詢最新的數據
        return getValue(k);
    }
});

需要注意的是,Caffeine的刷新機制是「被動」的。舉個例子,假如我們申明瞭10秒刷新一次。我們在時間T訪問並獲取到值v1,在T+5秒的時候,數據庫中這個值已經更新爲v2。但是在T+12秒,即已經過了10秒我們通過Caffeine從本地緩存中獲取到的「還是v1」,並不是v2。在這個獲取過程中,Caffeine發現時間已經過了10秒,然後會將v2加載到本地緩存中,下一次獲取時才能拿到v2。即它的實現原理是在get方法中,調用afterRead的時候,調用refreshIfNeeded方法判斷是否需要刷新數據。這就意味着,如果不讀取本地緩存中的數據的話,無論刷新時間間隔是多少,本地緩存中的數據永遠是舊的數據!

剔除機制

在構造Cache時可以通過removalListener方法申明剔除監聽器,從而可以跟蹤本地緩存中被剔除的數據歷史信息。根據RemovalCause.java枚舉值可知,剔除策略有如下5種:

  • 「EXPLICIT」:調用方法(例如:cache.invalidate(key)、cache.invalidateAll)顯示剔除數據;

  • 「REPLACED」:不是真正被剔除,而是用戶調用一些方法(例如:put(),putAll()等)蓋了之前的值;

  • 「COLLECTED」:表示緩存中的Key或者Value被垃圾回收掉了;

  • 「EXPIRED」: expireAfterWrite/expireAfterAccess約定時間內沒有任何訪問導致被剔除;

  • 「SIZE」:超過maximumSize限制的元素個數被剔除的原因;

GuavaCache和Caffeine差異

  1. 剔除算法方面,GuavaCache採用的是「LRU」算法,而Caffeine採用的是「Window TinyLFU」算法,這是兩者之間最大,也是根本的區別。

  2. 立即失效方面,Guava會把立即失效 (例如:expireAfterAccess(0) and expireAfterWrite(0)) 轉成設置最大Size爲0。這就會導致剔除提醒的原因是SIZE而不是EXPIRED。Caffiene能正確識別這種剔除原因。

  3. 取代提醒方面,Guava只要數據被替換,不管什麼原因,都會觸發剔除監聽器。而Caffiene在取代值和先前值的引用完全一樣時不會觸發監聽器。

  4. 異步化方方面,Caffiene的很多工作都是交給線程池去做的(默認:ForkJoinPool.commonPool()),例如:剔除監聽器,刷新機制,維護工作等。

內存佔用對比

Caffeine可以根據使用情況延遲初始化,或者動態調整它內部數據結構。這樣能減少對內存的佔用。如下圖所示,使用了gradle memoryOverhead對內存佔用進行了壓測。結果可能會受到JVM的指針壓縮、對象Padding等影響:

LRU P.K. W-TinyLFU

緩存的驅逐策略是爲了預測哪些數據在短期內最可能被再次用到,從而提升緩存的命中率。由於簡潔的實現、高效的運行時表現以及在常規的使用場景下有不錯的命中率,LRU(Least Recently Used)策略或許是最流行的驅逐策略,,它在保持算法簡單的前提下,效果還不錯。但LRU對未來的預測有明顯的侷限性,它會認爲「最後到來的數據是最可能被再次訪問」的,從而給予它最高的優先級。

現代緩存擴展了對歷史數據的使用,結合就近程度(recency)和訪問頻次(frequency)來更好的預測數據。其中一種保留歷史信息的方式是使用「popularity sketch」(一種壓縮、概率性的數據結構)來從一大堆訪問事件中定位頻繁的訪問者。可以參考「CountMin Sketch」算法,它由計數矩陣和多個哈希方法實現。發生一次讀取時,矩陣中每行對應的計數器增加計數,估算頻率時,取數據對應是所有行中計數的最小值。這個方法讓我們從空間、效率、以及適配矩陣的長寬引起的哈希碰撞的錯誤率上做權衡:

Window TinyLFU(W-TinyLFU)算法將Sketch作爲過濾器,當新來的數據比要驅逐的數據高頻時,這個數據纔會被緩存接納(admission)。這個許可窗口給予每個數據項積累熱度的機會,而「不是立即過濾掉」。這避免了持續的未命中,特別是在突然流量暴漲的的場景中,一些短暫的重複流量就不會被長期保留。爲了刷新歷史數據,一個時間衰減進程被週期性或增量的執行,給所有計數器減半:

對於長期保留的數據,W-TinyLFU使用了分段LRU(Segmented LRU,縮寫SLRU)策略。起初,一個數據項存儲被存儲在試用段(probationary segment)中,在後續被訪問到時,它會被提升到保護段(protected segment)中(保護段佔總容量的80%)。保護段滿後,有的數據會被淘汰回試用段,這也可能級聯的觸發試用段的淘汰。這套機制確保了訪問間隔小的熱數據被保存下來,而被重複訪問少的冷數據則被回收:

如圖中數據庫和搜索場景的結果展示,通過考慮就近程度和頻率能大大提升LRU的表現。一些高級的策略,像ARC,LIRS和W-TinyLFU都提供了接近最理想的命中率。想看更多的場景測試,請查看相應的論文,也可以在使用simulator來測試自己的場景:

Guava遷移

那麼,如果我的項目之前用的是GuavaCache,如何以儘可能低的成本遷移到Caffeine上來呢?嘿嘿,Caffeine已經想到了這一點,它提供了一個適配器,讓你用Guava的接口操作它的緩存。代碼片段如下所示:

// Guava's LoadingCache interface
LoadingCache<Key, Graph> graphs = CaffeinatedGuava.build(
    Caffeine.newBuilder().maximumSize(10_000),
    new CacheLoader<Key, Graph>() { // Guava's CacheLoader
        @Override public Graph load(Key key) throws Exception {
          return createExpensiveGraph(key);
        }
    });

參考

https://github.com/ben-manes/caffeine/wiki/Design 

http://highscalability.com/blog/2016/1/25/design-of-a-modern-cache.html

想知道更多?描下面的二維碼關注我

後臺回覆”加羣“獲取公衆號專屬羣聊入口

更新一下噹噹618圖書優惠碼:“實付滿150再減30”的優惠碼「 YREFVG 」或者 「 NSRM2M 」,(使用時間:6.9 - 6.20 ,使用渠道:噹噹小程序或噹噹APP)。以前的是“實付200-30”,這次更加給力點。

點個在看少個 bug ????

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章