高性能緩存 Caffeine 原理及實戰(zhàn)

一、簡介

Caffeine 是基于Java 8 開發(fā)的瓜客、提供了近乎最佳命中率的高性能本地緩存組件适瓦,Spring5 開始不再支持 Guava Cache,改為使用 Caffeine谱仪。

下面是 Caffeine 官方測試報告玻熙。

image
image
image

由上面三幅圖可見:不管在并發(fā)讀、并發(fā)寫還是并發(fā)讀寫的場景下疯攒,Caffeine 的性能都大幅領(lǐng)先于其他本地開源緩存組件嗦随。

本文先介紹 Caffeine 實現(xiàn)原理,再講解如何在項目中使用 Caffeine 卸例。

二称杨、Caffeine 原理

2.1 淘汰算法

2.1.1 常見算法

對于 Java 進程內(nèi)緩存我們可以通過 HashMap 來實現(xiàn)肌毅。不過筷转,Java 進程內(nèi)存是有限的,不可能無限地往里面放緩存對象悬而。這就需要有合適的算法輔助我們淘汰掉使用價值相對不高的對象呜舒,為新進的對象留有空間。常見的緩存淘汰算法有 FIFO笨奠、LRU袭蝗、LFU。

FIFO(First In First Out):先進先出般婆。

它是優(yōu)先淘汰掉最先緩存的數(shù)據(jù)到腥、是最簡單的淘汰算法。缺點是如果先緩存的數(shù)據(jù)使用頻率比較高的話蔚袍,那么該數(shù)據(jù)就不停地進進出出乡范,因此它的緩存命中率比較低配名。

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

它是優(yōu)先淘汰掉最久未訪問到的數(shù)據(jù)晋辆。缺點是不能很好地應對偶然的突發(fā)流量渠脉。比如一個數(shù)據(jù)在一分鐘內(nèi)的前59秒訪問很多次,而在最后1秒沒有訪問瓶佳,但是有一批冷門數(shù)據(jù)在最后一秒進入緩存芋膘,那么熱點數(shù)據(jù)就會被沖刷掉。

LFU(Least Frequently Used):

最近最少頻率使用霸饲。它是優(yōu)先淘汰掉最不經(jīng)常使用的數(shù)據(jù)为朋,需要維護一個表示使用頻率的字段。

主要有兩個缺點:

一贴彼、如果訪問頻率比較高的話潜腻,頻率字段會占據(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)存消耗臂容;

二科雳、維護一個PK機制保障新上的熱點數(shù)據(jù)能夠緩存。

如下圖所示脓杉,Count–Min Sketch 算法類似布隆過濾器 (Bloom filter)思想糟秘,對于頻率統(tǒng)計我們其實不需要一個精確值。存儲數(shù)據(jù)時球散,對key進行多次 hash 函數(shù)運算后尿赚,二維數(shù)組不同位置存儲頻率(Caffeine 實際實現(xiàn)的時候是用一維 long 型數(shù)組,每個 long 型數(shù)字切分成16份蕉堰,每份4bit凌净,默認15次為最高訪問頻率,每個key實際 hash 了四次屋讶,落在不同 long 型數(shù)字的16份中某個位置)冰寻。讀取某個key的訪問次數(shù)時,會比較所有位置上的頻率值皿渗,取最小值返回斩芭。對于所有key的訪問頻率之和有個最大值没卸,當達到最大值時,會進行reset即對各個緩存key的頻率除以2秒旋。

[圖片上傳失敗...(image-53c22a-1612230584271)]

如下圖緩存訪問頻率存儲主要分為兩大部分约计,即 LRU 和 Segmented LRU 。新訪問的數(shù)據(jù)會進入第一個 LRU迁筛,在 Caffeine 里叫 WindowDeque煤蚌。當 WindowDeque 滿時,會進入 Segmented LRU 中的 ProbationDeque细卧,在后續(xù)被訪問到時尉桩,它會被提升到 ProtectedDeque。當 ProtectedDeque 滿時贪庙,會有數(shù)據(jù)降級到 ProbationDeque 蜘犁。數(shù)據(jù)需要淘汰的時候,對 ProbationDeque 中的數(shù)據(jù)進行淘汰止邮。具體淘汰機制:取ProbationDeque 中的隊首和隊尾進行 PK这橙,隊首數(shù)據(jù)是最先進入隊列的,稱為受害者导披,隊尾的數(shù)據(jù)稱為攻擊者屈扎,比較兩者 頻率大小,大勝小汰撩匕。

[圖片上傳失敗...(image-74a1a4-1612230584271)]

總的來說鹰晨,通過 reset 衰減,避免歷史熱點數(shù)據(jù)由于頻率值比較高一直淘汰不掉止毕,并且通過對訪問隊列分成三段模蜡,這樣避免了新加入的熱點數(shù)據(jù)早早地被淘汰掉。

2.2 高性能讀寫

Caffeine 認為讀操作是頻繁的扁凛,寫操作是偶爾的忍疾,讀寫都是異步線程更新頻率信息。

2.2.1 讀緩沖

傳統(tǒng)的緩存實現(xiàn)將會為每個操作加鎖令漂,以便能夠安全的對每個訪問隊列的元素進行排序膝昆。一種優(yōu)化方案是將每個操作按序加入到緩沖區(qū)中進行批處理操作丸边。讀完把數(shù)據(jù)放到環(huán)形隊列 RingBuffer 中叠必,為了減少讀并發(fā),采用多個 RingBuffer妹窖,每個線程都有對應的 RingBuffer纬朝。環(huán)形隊列是一個定長數(shù)組,提供高性能的能力并最大程度上減少了 GC所帶來的性能開銷骄呼。數(shù)據(jù)丟到隊列之后就返回讀取結(jié)果共苛,類似于數(shù)據(jù)庫的WAL機制判没,和ConcurrentHashMap 讀取數(shù)據(jù)相比,僅僅多了把數(shù)據(jù)放到隊列這一步隅茎。異步線程并發(fā)讀取 RingBuffer 數(shù)組澄峰,更新訪問信息,這邊的線程池使用的是下文實戰(zhàn)小節(jié)講的 Caffeine 配置參數(shù)中的 executor辟犀。

image

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 很容易上手丸升,只需要改變相應的類名就行铆农。構(gòu)造一個緩存 Cache 示例代碼如下:

Cache cache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(6, TimeUnit.MINUTES).softValues().build();

Caffeine 類相當于建造者模式的 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:選擇自定義的線程池烘苹,默認的線程池實現(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);
        }
    }
}

當然也可以把 caffeine.properties 里面的配置項放入配置中心,如果需要動態(tài)生效丰滑,可以通過如下方式:

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

一褒墨、EL 表達式上手需要學習成本炫刷;

二、引入注解需要注意動態(tài)代理失效場景郁妈;

獲取緩存時通過如下方式:

caffeineManager.getCache(cacheName).get(redisKey, value -> getTFromRedis(redisKey, targetClass, supplier));

Caffeine 這種帶有回源函數(shù)的 get 方法最終都是調(diào)用 ConcurrentHashMap 的 compute 方法浑玛,它能確保高并發(fā)場景下,如果對一個熱點 key 進行回源時噩咪,單個進程內(nèi)只有一個線程回源顾彰,其他都在阻塞。業(yè)務(wù)需要確蔽改耄回源的方法耗時比較短涨享,防止線程阻塞時間比較久,系統(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 本身解決了進程內(nèi)同一個 key 只有一個線程回源茶鹃,需要注意多個業(yè)務(wù)節(jié)點的分布式情況下,如果 Redis 沒有緩存值闭翩,并發(fā)回源時會穿透到 MySQL 疗韵,所以回源時加了分布式鎖,保證只有一個節(jié)點回源侄非。

注意一點:從本地緩存獲取對象時蕉汪,如果業(yè)務(wù)要對緩存對象做更新,需要深拷貝一份對象逞怨,不然并發(fā)場景下多個線程取值會相互影響者疤。

筆者項目之前都是使用 Ehcache 作為本地緩存,切換成 Caffeine 后叠赦,涉及本地緩存的接口驹马,同樣 TPS 值時,CPU 使用率能降低 10% 左右除秀,接口性能都有一定程度提升窥翩,最多的提升了 25%。上線后觀察調(diào)用鏈鳞仙,平均響應時間降低24%左右寇蚊。

四、總結(jié)

Caffeine 是目前比較優(yōu)秀的本地緩存解決方案棍好,通過使用 W-TinyLFU 算法仗岸,實現(xiàn)了緩存高命中率、內(nèi)存低消耗借笙。如果之前使用過 Guava Cache扒怖,看下接口名基本就能上手。如果之前使用的是 Ehcache业稼,筆者分享的使用方式可以作為參考盗痒。

五、參考文獻

  1. TinyLFU: A Highly Efficient Cache Admission Policy

  2. Design Of A Modern Cache

  3. Caffeine Github

作者:Zhang Zhenglin

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市俯邓,隨后出現(xiàn)的幾起案子骡楼,更是在濱河造成了極大的恐慌,老刑警劉巖稽鞭,帶你破解...
    沈念sama閱讀 210,978評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件鸟整,死亡現(xiàn)場離奇詭異,居然都是意外死亡朦蕴,警方通過查閱死者的電腦和手機篮条,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,954評論 2 384
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來吩抓,“玉大人涉茧,你說我怎么就攤上這事≌钊ⅲ” “怎么了降瞳?”我有些...
    開封第一講書人閱讀 156,623評論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長蚓胸。 經(jīng)常有香客問我挣饥,道長,這世上最難降的妖魔是什么沛膳? 我笑而不...
    開封第一講書人閱讀 56,324評論 1 282
  • 正文 為了忘掉前任扔枫,我火速辦了婚禮,結(jié)果婚禮上锹安,老公的妹妹穿的比我還像新娘短荐。我一直安慰自己,他們只是感情好叹哭,可當我...
    茶點故事閱讀 65,390評論 5 384
  • 文/花漫 我一把揭開白布忍宋。 她就那樣靜靜地躺著,像睡著了一般风罩。 火紅的嫁衣襯著肌膚如雪糠排。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,741評論 1 289
  • 那天超升,我揣著相機與錄音入宦,去河邊找鬼。 笑死室琢,一個胖子當著我的面吹牛乾闰,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播盈滴,決...
    沈念sama閱讀 38,892評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼涯肩,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起病苗,我...
    開封第一講書人閱讀 37,655評論 0 266
  • 序言:老撾萬榮一對情侶失蹤疗垛,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后铅乡,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體继谚,經(jīng)...
    沈念sama閱讀 44,104評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡烈菌,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,451評論 2 325
  • 正文 我和宋清朗相戀三年阵幸,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片芽世。...
    茶點故事閱讀 38,569評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡挚赊,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出济瓢,到底是詐尸還是另有隱情荠割,我是刑警寧澤,帶...
    沈念sama閱讀 34,254評論 4 328
  • 正文 年R本政府宣布旺矾,位于F島的核電站蔑鹦,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏箕宙。R本人自食惡果不足惜嚎朽,卻給世界環(huán)境...
    茶點故事閱讀 39,834評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望柬帕。 院中可真熱鬧哟忍,春花似錦、人聲如沸陷寝。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,725評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽凤跑。三九已至爆安,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間仔引,已是汗流浹背鹏控。 一陣腳步聲響...
    開封第一講書人閱讀 31,950評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留肤寝,地道東北人当辐。 一個月前我還...
    沈念sama閱讀 46,260評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像鲤看,于是被迫代替她去往敵國和親缘揪。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,446評論 2 348

推薦閱讀更多精彩內(nèi)容