詳解高性能緩存Caffeine原理及實戰(zhàn)
一、簡介
下面是Caffeine 官方測試報告。
由上面三幅圖可見:不管在并發(fā)讀、并發(fā)寫還是并發(fā)讀寫的場景下,Caffeine 的性能都大幅領(lǐng)先于其他本地開源緩存組件。
本文先介紹 Caffeine 實現(xiàn)原理,再講解如何在項目中使用 Caffeine 。
二、Caffeine 原理
2.1、淘汰算法
2.1.1、常見算法
對于 Java 進(jìn)程內(nèi)緩存我們可以通過 HashMap 來實現(xiàn)。不過,Java 進(jìn)程內(nèi)存是有限的,不可能無限地往里面放緩存對象。這就需要有合適的算法輔助我們淘汰掉使用價值相對不高的對象,為新進(jìn)的對象留有空間。常見的緩存淘汰算法有 FIFO、LRU、LFU。
FIFO(First In First Out):先進(jìn)先出。
它是優(yōu)先淘汰掉最先緩存的數(shù)據(jù)、是最簡單的淘汰算法。缺點是如果先緩存的數(shù)據(jù)使用頻率比較高的話,那么該數(shù)據(jù)就不停地進(jìn)進(jìn)出出,因此它的緩存命中率比較低。
LRU(Least Recently Used):最近最久未使用。
它是優(yōu)先淘汰掉最久未訪問到的數(shù)據(jù)。缺點是不能很好地應(yīng)對偶然的突發(fā)流量。比如一個數(shù)據(jù)在一分鐘內(nèi)的前59秒訪問很多次,而在最后1秒沒有訪問,但是有一批冷門數(shù)據(jù)在最后一秒進(jìn)入緩存,那么熱點數(shù)據(jù)就會被沖刷掉。
LFU(Least Frequently Used):
最近最少頻率使用。它是優(yōu)先淘汰掉最不經(jīng)常使用的數(shù)據(jù),需要維護(hù)一個表示使用頻率的字段。
主要有兩個缺點:
一、如果訪問頻率比較高的話,頻率字段會占據(jù)一定的空間;
二、無法合理更新新上的熱點數(shù)據(jù),比如某個歌手的老歌播放歷史較多,新出的歌如果和老歌一起排序的話,就永無出頭之日。
2.1.2、W-TinyLFU 算法
Caffeine 使用了 W-TinyLFU 算法,解決了 LRU 和LFU上述的缺點。W-TinyLFU 算法由論文《TinyLFU: A Highly Efficient Cache Admission Policy》提出。
它主要干了兩件事:
一、采用 Count–Min Sketch 算法降低頻率信息帶來的內(nèi)存消耗;
二、維護(hù)一個PK機(jī)制保障新上的熱點數(shù)據(jù)能夠緩存。
如下圖所示,Count–Min Sketch 算法類似布隆過濾器 (Bloom filter)思想,對于頻率統(tǒng)計我們其實不需要一個精確值。存儲數(shù)據(jù)時,對key進(jìn)行多次 hash 函數(shù)運(yùn)算后,二維數(shù)組不同位置存儲頻率(Caffeine 實際實現(xiàn)的時候是用一維 long 型數(shù)組,每個 long 型數(shù)字切分成16份,每份4bit,默認(rèn)15次為最高訪問頻率,每個key實際 hash 了四次,落在不同 long 型數(shù)字的16份中某個位置)。讀取某個key的訪問次數(shù)時,會比較所有位置上的頻率值,取最小值返回。對于所有key的訪問頻率之和有個最大值,當(dāng)達(dá)到最大值時,會進(jìn)行reset即對各個緩存key的頻率除以2。
如下圖緩存訪問頻率存儲主要分為兩大部分,即 LRU 和 Segmented LRU 。新訪問的數(shù)據(jù)會進(jìn)入第一個 LRU,在 Caffeine 里叫 WindowDeque。當(dāng) WindowDeque 滿時,會進(jìn)入 Segmented LRU 中的 ProbationDeque,在后續(xù)被訪問到時,它會被提升到 ProtectedDeque。當(dāng) ProtectedDeque 滿時,會有數(shù)據(jù)降級到 ProbationDeque 。數(shù)據(jù)需要淘汰的時候,對 ProbationDeque 中的數(shù)據(jù)進(jìn)行淘汰。具體淘汰機(jī)制:取ProbationDeque 中的隊首和隊尾進(jìn)行 PK,隊首數(shù)據(jù)是最先進(jìn)入隊列的,稱為受害者,隊尾的數(shù)據(jù)稱為攻擊者,比較兩者 頻率大小,大勝小汰。
總的來說,通過 reset 衰減,避免歷史熱點數(shù)據(jù)由于頻率值比較高一直淘汰不掉,并且通過對訪問隊列分成三段,這樣避免了新加入的熱點數(shù)據(jù)早早地被淘汰掉。
2.2、高性能讀寫
Caffeine 認(rèn)為讀操作是頻繁的,寫操作是偶爾的,讀寫都是異步線程更新頻率信息。
2.2.1、讀緩沖
傳統(tǒng)的緩存實現(xiàn)將會為每個操作加鎖,以便能夠安全的對每個訪問隊列的元素進(jìn)行排序。一種優(yōu)化方案是將每個操作按序加入到緩沖區(qū)中進(jìn)行批處理操作。讀完把數(shù)據(jù)放到環(huán)形隊列 RingBuffer 中,為了減少讀并發(fā),采用多個 RingBuffer,每個線程都有對應(yīng)的 RingBuffer。環(huán)形隊列是一個定長數(shù)組,提供高性能的能力并最大程度上減少了 GC所帶來的性能開銷。數(shù)據(jù)丟到隊列之后就返回讀取結(jié)果,類似于數(shù)據(jù)庫的WAL機(jī)制,和ConcurrentHashMap 讀取數(shù)據(jù)相比,僅僅多了把數(shù)據(jù)放到隊列這一步。異步線程并發(fā)讀取 RingBuffer 數(shù)組,更新訪問信息,這邊的線程池使用的是下文實戰(zhàn)小節(jié)講的 Caffeine 配置參數(shù)中的 executor。
2.2.2、寫緩沖
與讀緩沖類似,寫緩沖是為了儲存寫事件。讀緩沖中的事件主要是為了優(yōu)化驅(qū)逐策略的命中率,因此讀緩沖中的事件完整程度允許一定程度的有損。但是寫緩沖并不允許數(shù)據(jù)的丟失,因此其必須實現(xiàn)為一個安全的隊列。Caffeine 寫是把數(shù)據(jù)放入MpscGrowableArrayQueue 阻塞隊列中,它參考了JCTools里的MpscGrowableArrayQueue ,是針對 MPSC- 多生產(chǎn)者單消費者(Multi-Producer & Single-Consumer)場景的高性能實現(xiàn)。多個生產(chǎn)者同時并發(fā)地寫入隊列是線程安全的,但是同一時刻只允許一個消費者消費隊列。
三、Caffeine 實戰(zhàn)
3.1、配置參數(shù)
Caffeine 借鑒了Guava Cache 的設(shè)計思想,如果之前使用過 Guava Cache,那么Caffeine 很容易上手,只需要改變相應(yīng)的類名就行。構(gòu)造一個緩存 Cache 示例代碼如下:
Cache cache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(6, TimeUnit.MINUTES).softValues().build();
Caffeine 類相當(dāng)于建造者模式的 Builder 類,通過 Caffeine 類配置 Cache,配置一個Cache 有如下參數(shù):
- expireAfterWrite:寫入間隔多久淘汰;
- expireAfterAccess:最后訪問后間隔多久淘汰;
- refreshAfterWrite:寫入后間隔多久刷新,該刷新是基于訪問被動觸發(fā)的,支持異步刷新和同步刷新,如果和 expireAfterWrite 組合使用,能夠保證即使該緩存訪問不到、也能在固定時間間隔后被淘汰,否則如果單獨使用容易造成OOM;
- expireAfter:自定義淘汰策略,該策略下 Caffeine 通過時間輪算法來實現(xiàn)不同key 的不同過期時間;
- maximumSize:緩存 key 的最大個數(shù);weakKeys:key設(shè)置為弱引用,在 GC 時可以直接淘汰;
- weakValues:value設(shè)置為弱引用,在 GC 時可以直接淘汰;
- softValues:value設(shè)置為軟引用,在內(nèi)存溢出前可以直接淘汰;
- executor:選擇自定義的線程池,默認(rèn)的線程池實現(xiàn)是 ForkJoinPool.commonPool();
- maximumWeight:設(shè)置緩存最大權(quán)重;weigher:設(shè)置具體key權(quán)重;
- recordStats:緩存的統(tǒng)計數(shù)據(jù),比如命中率等;
- removalListener:緩存淘汰監(jiān)聽器;writer:緩存寫入、更新、淘汰的監(jiān)聽器。
3.2、項目實戰(zhàn)
Caffeine 支持解析字符串參數(shù),參照 Ehcache 的思想,可以把所有緩存項參數(shù)信息放入配置文件里面,比如有一個 caffeine.properties 配置文件,里面配置參數(shù)如下:
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); } } }
當(dāng)然也可以把 caffeine.properties 里面的配置項放入配置中心,如果需要動態(tài)生效,可以通過如下方式:
至于是否利用 Spring 的 EL 表達(dá)式通過注解的方式使用,仁者見仁智者見智,筆者主要考慮幾點:
一、EL 表達(dá)式上手需要學(xué)習(xí)成本;
二、引入注解需要注意動態(tài)代理失效場景;
獲取緩存時通過如下方式:
caffeineManager.getCache(cacheName).get(redisKey, value -> getTFromRedis(redisKey, targetClass, supplier));
Caffeine 這種帶有回源函數(shù)的 get 方法最終都是調(diào)用 ConcurrentHashMap 的 compute 方法,它能確保高并發(fā)場景下,如果對一個熱點 key 進(jìn)行回源時,單個進(jìn)程內(nèi)只有一個線程回源,其他都在阻塞。業(yè)務(wù)需要確?;卦吹姆椒ê臅r比較短,防止線程阻塞時間比較久,系統(tǒng)可用性下降。
筆者實際開發(fā)中用了 Caffeine 和 Redis 兩級緩存。Caffeine 的 cache 緩存 key 和 Redis 里面一致,都是命名為 redisKey。targetClass 是返回對象類型,從 Redis 中獲取字符串反序列化成實際對象時使用。supplier 是函數(shù)式接口,是緩存回源到數(shù)據(jù)庫的業(yè)務(wù)邏輯。
getTFromRedis 方法實現(xiàn)如下:
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 本身解決了進(jìn)程內(nèi)同一個 key 只有一個線程回源,需要注意多個業(yè)務(wù)節(jié)點的分布式情況下,如果 Redis 沒有緩存值,并發(fā)回源時會穿透到 MySQL ,所以回源時加了分布式鎖,保證只有一個節(jié)點回源。
注意一點:從本地緩存獲取對象時,如果業(yè)務(wù)要對緩存對象做更新,需要深拷貝一份對象,不然并發(fā)場景下多個線程取值會相互影響。
筆者項目之前都是使用 Ehcache 作為本地緩存,切換成 Caffeine 后,涉及本地緩存的接口,同樣 TPS 值時,CPU 使用率能降低 10% 左右,接口性能都有一定程度提升,最多的提升了 25%。上線后觀察調(diào)用鏈,平均響應(yīng)時間降低24%左右。
四、總結(jié)
Caffeine 是目前比較優(yōu)秀的本地緩存解決方案,通過使用 W-TinyLFU 算法,實現(xiàn)了緩存高命中率、內(nèi)存低消耗。如果之前使用過 Guava Cache,看下接口名基本就能上手。如果之前使用的是 Ehcache,筆者分享的使用方式可以作為參考。
以上就是詳解高性能緩存Caffeine原理及實戰(zhàn)的詳細(xì)內(nèi)容,更多關(guān)于Caffeine 原理的資料請關(guān)注腳本之家其它相關(guān)文章!
- SpringBoot集成Caffeine緩存的實現(xiàn)步驟
- SpringBoot+SpringCache實現(xiàn)兩級緩存(Redis+Caffeine)
- Spring Boot 2.x 把 Guava 干掉了選擇本地緩存之王 Caffeine(推薦)
- Springboot Caffeine本地緩存使用示例
- Spring Boot緩存實戰(zhàn) Caffeine示例
- spring boot+spring cache實現(xiàn)兩級緩存(redis+caffeine)
- 詳細(xì)介紹高性能Java緩存庫Caffeine
- JavaScript 學(xué)習(xí)筆記 Black.Caffeine 09.11.28
- 輕松了解java中Caffeine高性能緩存庫
相關(guān)文章
Springboot整合mybatisplus的項目實戰(zhàn)
本文主要介紹了Springboot整合mybatisplus的項目實戰(zhàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-06-06java數(shù)組實現(xiàn)隊列及環(huán)形隊列實現(xiàn)過程解析
這篇文章主要介紹了java數(shù)組實現(xiàn)隊列及環(huán)形隊列實現(xiàn)過程解析,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-10-10SpringMVC框架整合Junit進(jìn)行單元測試(案例詳解)
本文詳細(xì)介紹在SpringMVC任何使用Junit框架。首先介紹了如何引入依賴,接著介紹了編寫一個測試基類,并且對其中涉及的各個注解做了一個詳細(xì)說明,感興趣的朋友跟隨小編一起看看吧2021-05-05Java21增強(qiáng)對Emoji表情符號處理示例詳解
這篇文章主要為大家介紹了Java21增強(qiáng)對Emoji表情符號處理示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11解決Spring Cloud Gateway獲取body內(nèi)容,不影響GET請求的操作
這篇文章主要介紹了解決Spring Cloud Gateway獲取body內(nèi)容,不影響GET請求的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-12-12