1. Guava Cache 使用小結(jié)

        共 10428字,需瀏覽 21分鐘

         ·

        2022-02-26 02:41

        閑聊

        話說原創(chuàng)文章已經(jīng)斷更 2 個(gè)月了,倒也不是因?yàn)槊?,主要還是懶。但是也感覺可以拿出來跟大家分享的技術(shù)點(diǎn)越來越少了,一方面主要是最近在從事一些“內(nèi)部項(xiàng)目”的研發(fā),縱使我很想分享,也沒法搬到公眾號(hào) & 博客上來;一方面是一些我并不是很擅長(zhǎng)的技術(shù)點(diǎn),在我還是新手時(shí),我敢于去寫,而有了一定工作年限之后,反而有些包袱了,我的讀者會(huì)不會(huì)介意呢?思來想去,我回憶起了寫作的初心,不就是為了記錄自己的學(xué)習(xí)過程嗎?于是乎,我還是按照我之前的文風(fēng)記錄下了此文,以避免成為一名斷更的博主。

        以下是正文。

        前言

        “緩存”一直是我們程序員聊的最多的那一類技術(shù)點(diǎn),諸如 Redis、Encache、Guava Cache,你至少會(huì)聽說過一個(gè)。需要承認(rèn)的是,無論是面試八股文的風(fēng)氣,還是實(shí)際使用的頻繁度,Redis 分布式緩存的確是當(dāng)下最為流行的緩存技術(shù),但同時(shí),從我個(gè)人的項(xiàng)目經(jīng)驗(yàn)來看,本地緩存也是非常常用的一個(gè)技術(shù)點(diǎn)。

        分析 Redis 緩存的文章很多,例如 Redis 雪崩、Redis 過期機(jī)制等等,諸如此類的公眾號(hào)標(biāo)題不鮮出現(xiàn)在我朋友圈的 timeline 中,但是分析本地緩存的文章在我的映像中很少。

        在最近的項(xiàng)目中,有一位新人同事使用了 Guava Cache 來對(duì)一個(gè) RPC 接口的響應(yīng)進(jìn)行緩存,我在 review 其代碼時(shí)恰好發(fā)現(xiàn)了一個(gè)不太合理的寫法,遂有此文。

        本文將會(huì)介紹 Guava Cache 的一些常用操作:基礎(chǔ) API 使用,過期策略,刷新策略。并且按照我的寫作習(xí)慣,會(huì)附帶上實(shí)際開發(fā)中的一些總結(jié)。需要事先說明的是,我沒有閱讀過 Guava Cache 的源碼,對(duì)其的介紹僅僅是一些使用經(jīng)驗(yàn)或者最佳實(shí)踐,不會(huì)有過多深入的解析。

        先簡(jiǎn)單介紹一下 Guava Cache,它是 Google 封裝的基礎(chǔ)工具包 guava 中的一個(gè)內(nèi)存緩存模塊,主要提供了以下能力:

        • 封裝了緩存與數(shù)據(jù)源交互的流程,使得開發(fā)更關(guān)注于業(yè)務(wù)操作
        • 提供線程安全的存取操作(可以類比 ConcurrentHashMap)
        • 提供常用的緩存過期策略,緩存刷新策略
        • 提供緩存命中率的監(jiān)控

        基礎(chǔ)使用

        使用一個(gè)示例介紹 Guava Cache 的基礎(chǔ)使用方法 -- 緩存大小寫轉(zhuǎn)換的返回值。

        private?String?fetchValueFromServer(String?key)?{
        ????return?key.toUpperCase();
        }

        @Test
        public?void?whenCacheMiss_thenFetchValueFromServer()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?fetchValueFromServer(key);
        ????????????}
        ????????});

        ????assertEquals(0,?cache.size());
        ????assertEquals("HELLO",?cache.getUnchecked("hello"));
        ????assertEquals("HELLO",?cache.get("hello"));
        ????assertEquals(1,?cache.size());
        }

        使用 Guava Cache 的好處已經(jīng)躍然于紙上了,它解耦了緩存存取與業(yè)務(wù)操作。CacheLoader?的?load?方法可以理解為從數(shù)據(jù)源加載原始數(shù)據(jù)的入口,當(dāng)調(diào)用 LoadingCache 的?getUnchecked?或者?get方法時(shí),Guava Cache 行為如下:

        • 緩存未命中時(shí),同步調(diào)用 load 接口,加載進(jìn)緩存,返回緩存值
        • 緩存命中,直接返回緩存值
        • 多線程緩存未命中時(shí),A 線程 load 時(shí),會(huì)阻塞 B 線程的請(qǐng)求,直到緩存加載完畢

        注意到,Guava 提供了兩個(gè)?getUnchecked?或者?get?加載方法,沒有太大的區(qū)別,無論使用哪一個(gè),都需要注意,數(shù)據(jù)源無論是 RPC 接口的返回值還是數(shù)據(jù)庫,都要考慮訪問超時(shí)或者失敗的情況,做好異常處理。

        預(yù)加載緩存

        預(yù)加載緩存的常見使用場(chǎng)景:

        • 老生常談的秒殺場(chǎng)景,事先緩存預(yù)熱,將熱點(diǎn)商品加入緩存;
        • 系統(tǒng)重啟過后,事先加載好緩存,避免真實(shí)請(qǐng)求擊穿緩存

        Guava Cache 提供了?put?和?putAll?方法

        @Test
        public?void?whenPreloadCache_thenPut()?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?fetchValueFromServer(key);
        ????????????}
        ????????});

        ????String?key?=?"kirito";
        ????cache.put(key,fetchValueFromServer(key));

        ????assertEquals(1,?cache.size());
        }

        操作和 HashMap 一模一樣。

        這里有一個(gè)誤區(qū),而那位新人同事恰好踩到了,也是我寫這篇文章的初衷,請(qǐng)務(wù)必僅在預(yù)加載緩存這個(gè)場(chǎng)景使用 put,其他任何場(chǎng)景都應(yīng)該使用 load 去觸發(fā)加載緩存??聪旅孢@個(gè)反面示例

        //?注意這是一個(gè)反面示例
        @Test
        public?void?wrong_usage_whenCacheMiss_thenPut()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?"";
        ????????????}
        ????????});

        ????String?key?=?"kirito";
        ????String?cacheValue?=?cache.get(key);
        ????if?("".equals(cacheValue))?{
        ????????cacheValue?=?fetchValueFromServer(key);
        ????????cache.put(key,?cacheValue);
        ????}
        ????cache.put(key,?cacheValue);

        ????assertEquals(1,?cache.size());
        }

        這樣的寫法,在 load 方法中設(shè)置了一個(gè)空值,后續(xù)通過手動(dòng) put + get 的方式使用緩存,這種習(xí)慣更像是在操作一個(gè) HashMap,但并不推薦在 Cache 中使用。在前面介紹過 get 配合 load 是由 Guava Cache 去保障了線程安全,保障多個(gè)線程訪問緩存時(shí),第一個(gè)請(qǐng)求加載緩存的同時(shí),阻塞后續(xù)請(qǐng)求,這樣的 HashMap 用法既不優(yōu)雅,在極端情況下還會(huì)引發(fā)緩存擊穿、線程安全等問題。

        請(qǐng)務(wù)必僅僅將 put 方法用作預(yù)加載緩存場(chǎng)景。

        緩存過期

        前面的介紹使用起來依舊沒有脫離 ConcurrentHashMap 的范疇,Cache 與其的第一個(gè)區(qū)別在“緩存過期”這個(gè)場(chǎng)景可以被體現(xiàn)出來。本節(jié)介紹 Guava 一些常見的緩存過期行為及策略。

        緩存固定數(shù)量的值

        @Test
        public?void?whenReachMaxSize_thenEviction()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().maximumSize(3).build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?fetchValueFromServer(key);
        ????????????}
        ????????});

        ????cache.get("one");
        ????cache.get("two");
        ????cache.get("three");
        ????cache.get("four");
        ????assertEquals(3,?cache.size());
        ????assertNull(cache.getIfPresent("one"));
        ????assertEquals("FOUR",?cache.getIfPresent("four"));
        }

        使用?ConcurrentHashMap?做緩存的一個(gè)最大的問題,便是我們沒有簡(jiǎn)易有效的手段阻止其無限增長(zhǎng),而 Guava Cache 可以通過初始化 LoadingCache 的過程,配置?maximumSize?,以確保緩存內(nèi)容不導(dǎo)致你的系統(tǒng)出現(xiàn) OOM。

        值得注意的是,我這里的測(cè)試用例使用的是除了?get?、getUnchecked?外的第三種獲取緩存的方式,如字面意思描述的那樣,getIfPresent?在緩存不存在時(shí),并不會(huì)觸發(fā)?load?方法加載數(shù)據(jù)源。

        LRU 過期策略

        依舊沿用上述的示例,我們?cè)谠O(shè)置容量為 3 時(shí),僅獲悉 LoadingCache 可以存儲(chǔ) 3 個(gè)值,卻并未得知第 4 個(gè)值存入后,哪一個(gè)舊值需要淘汰,為新值騰出空位。實(shí)際上,Guava Cache 默認(rèn)采取了 LRU 緩存淘汰策略。Least Recently Used 即最近最少使用,這個(gè)算法你可能沒有實(shí)現(xiàn)過,但一定會(huì)聽說過,在 Guava Cache 中 Used 的語義代表任意一次訪問,例如 put、get。繼續(xù)看下面的示例。

        @Test
        public?void?whenReachMaxSize_thenEviction()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().maximumSize(3).build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?fetchValueFromServer(key);
        ????????????}
        ????????});

        ????cache.get("one");
        ????cache.get("two");
        ????cache.get("three");
        ????//?access?one
        ????cache.get("one");
        ????cache.get("four");
        ????assertEquals(3,?cache.size());
        ????assertNull(cache.getIfPresent("two"));
        ????assertEquals("ONE",?cache.getIfPresent("one"));
        }

        注意此示例與上一節(jié)示例的區(qū)別:第四次 get 訪問 one 后,two 變成了最久未被使用的值,當(dāng)?shù)谒膫€(gè)值 four 存入后,淘汰的對(duì)象變成了 two,而不再是 one 了。

        緩存固定時(shí)間

        為緩存設(shè)置過期時(shí)間,也是區(qū)分 HashMap 和 Cache 的一個(gè)重要特性。Guava Cache 提供了expireAfterAccess、?expireAfterWrite?的方案,為 LoadingCache 中的緩存值設(shè)置過期時(shí)間。

        @Test
        public?void?whenEntryIdle_thenEviction()
        ????throws?InterruptedException,?ExecutionException?
        {

        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().expireAfterAccess(1,?TimeUnit.SECONDS).build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?fetchValueFromServer(key);
        ????????????}
        ????????});

        ????cache.get("kirito");
        ????assertEquals(1,?cache.size());

        ????cache.get("kirito");
        ????Thread.sleep(2000);

        ????assertNull(cache.getIfPresent("kirito"));
        }

        緩存失效

        @Test
        public?void?whenInvalidate_thenGetNull()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder()
        ????????????.build(new?CacheLoader()?{
        ????????????????@Override
        ????????????????public?String?load(String?key)?{
        ????????????????????return?fetchValueFromServer(key);
        ????????????????}
        ????????????});

        ????String?name?=?cache.get("kirito");
        ????assertEquals("KIRITO",?name);

        ????cache.invalidate("kirito");
        ????assertNull(cache.getIfPresent("kirito"));
        }

        使用?void invalidate(Object key)?移除單個(gè)緩存,使用?void invalidateAll()?移除所有緩存。

        緩存刷新

        緩存刷新的常用于使用數(shù)據(jù)源的新值覆蓋緩存舊值,Guava Cache 提供了兩類刷新機(jī)制:手動(dòng)刷新和定時(shí)刷新。

        手動(dòng)刷新

        cache.refresh("kirito");

        refresh 方法將會(huì)觸發(fā) load 邏輯,嘗試從數(shù)據(jù)源加載緩存。

        需要注意點(diǎn)的是,refresh 方法并不會(huì)阻塞 get 方法,所以在 refresh 期間,舊的緩存值依舊會(huì)被訪問到,直到 load 完畢,看下面的示例。

        @Test
        public?void?whenCacheRefresh_thenLoad()
        ????throws?InterruptedException,?ExecutionException?
        {

        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().expireAfterWrite(1,?TimeUnit.SECONDS).build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?throws?InterruptedException?{
        ????????????????Thread.sleep(2000);
        ????????????????return?key?+?ThreadLocalRandom.current().nextInt(100);
        ????????????}
        ????????});

        ????String?oldValue?=?cache.get("kirito");

        ????new?Thread(()?->?{
        ????????cache.refresh("kirito");
        ????}).start();

        ????//?make?sure?another?refresh?thread?is?scheduling
        ????Thread.sleep(500);

        ????String?val1?=?cache.get("kirito");

        ????assertEquals(oldValue,?val1);

        ????//?make?sure?refresh?cache?
        ????Thread.sleep(2000);

        ????String?val2?=?cache.get("kirito");
        ????assertNotEquals(oldValue,?val2);

        }

        其實(shí)任何情況下,緩存值都有可能和數(shù)據(jù)源出現(xiàn)不一致,業(yè)務(wù)層面需要做好訪問到舊值的容錯(cuò)邏輯。

        自動(dòng)刷新

        @Test
        public?void?whenTTL_thenRefresh()?throws?ExecutionException,?InterruptedException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().refreshAfterWrite(1,?TimeUnit.SECONDS).build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?key?+?ThreadLocalRandom.current().nextInt(100);
        ????????????}
        ????????});

        ????String?first?=?cache.get("kirito");
        ????Thread.sleep(1000);
        ????String?second?=?cache.get("kirito");

        ????assertNotEquals(first,?second);
        }

        和上節(jié)的 refresh 機(jī)制一樣,refreshAfterWrite?同樣不會(huì)阻塞 get 線程,依舊有訪問舊值的可能性。

        緩存命中統(tǒng)計(jì)

        Guava Cache 默認(rèn)情況不會(huì)對(duì)命中情況進(jìn)行統(tǒng)計(jì),需要在構(gòu)建 CacheBuilder 時(shí)顯式配置?recordStats

        @Test
        public?void?whenRecordStats_thenPrint()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().maximumSize(100).recordStats().build(new?CacheLoader()?{
        ????????????@Override
        ????????????public?String?load(String?key)?{
        ????????????????return?fetchValueFromServer(key);
        ????????????}
        ????????});

        ????cache.get("one");
        ????cache.get("two");
        ????cache.get("three");
        ????cache.get("four");

        ????cache.get("one");
        ????cache.get("four");

        ????CacheStats?stats?=?cache.stats();
        ????System.out.println(stats);
        }
        ---
        CacheStats{hitCount=2,?missCount=4,?loadSuccessCount=4,?loadExceptionCount=0,?totalLoadTime=1184001,?evictionCount=0}

        緩存移除的通知機(jī)制

        在一些業(yè)務(wù)場(chǎng)景中,我們希望對(duì)緩存失效進(jìn)行一些監(jiān)測(cè),或者是針對(duì)失效的緩存做一些回調(diào)處理,就可以使用?RemovalNotification?機(jī)制。

        @Test
        public?void?whenRemoval_thenNotify()?throws?ExecutionException?{
        ????LoadingCache?cache?=
        ????????CacheBuilder.newBuilder().maximumSize(3)
        ????????????.removalListener(
        ????????????????cacheItem?->?System.out.println(cacheItem?+?"?is?removed,?cause?by?"?+?cacheItem.getCause()))
        ????????????.build(new?CacheLoader()?{
        ????????????????@Override
        ????????????????public?String?load(String?key)?{
        ????????????????????return?fetchValueFromServer(key);
        ????????????????}
        ????????????});

        ????cache.get("one");
        ????cache.get("two");
        ????cache.get("three");
        ????cache.get("four");
        }
        ---
        one=ONE?is?removed,?cause?by?SIZE

        removalListener?可以給 LoadingCache 增加一個(gè)回調(diào)處理器,RemovalNotification?實(shí)例包含了緩存的鍵值對(duì)以及移除原因。

        Weak Keys & Soft Values

        Java 基礎(chǔ)中的弱引用和軟引用的概念相信大家都學(xué)習(xí)過,這里先給大家復(fù)習(xí)一下

        • 軟引用:如果一個(gè)對(duì)象只具有軟引用,則內(nèi)存空間充足時(shí),垃圾回收器不會(huì)回收它;如果內(nèi)存空間不足,就會(huì)回收這些對(duì)象。只要垃圾回收器沒有回收它,該對(duì)象就可以被程序使用
        • 弱引用:只具有弱引用的對(duì)象擁有更短暫生命周期。在垃圾回收器線程掃描它所管轄的內(nèi)存區(qū)域的過程中,一旦發(fā)現(xiàn)了只具有弱引用的對(duì)象,不管當(dāng)前內(nèi)存空間足夠與否,都會(huì)回收它的內(nèi)存。

        在 Guava Cache 中,CacheBuilder 提供了 weakKeys、weakValues、softValues 三種方法,將緩存的鍵值對(duì)與 JVM 垃圾回收機(jī)制產(chǎn)生關(guān)聯(lián)。

        該操作可能有它適用的場(chǎng)景,例如最大限度的使用 JVM 內(nèi)存做緩存,但依賴 GC 清理,性能可想而知會(huì)比較低??傊沂遣粫?huì)依賴 JVM 的機(jī)制來清理緩存的,所以這個(gè)特性我不敢使用,線上還是穩(wěn)定性第一。

        如果需要設(shè)置清理策略,可以參考緩存過期小結(jié)中的介紹固定數(shù)量和固定時(shí)間兩個(gè)方案,結(jié)合使用確保使用緩存獲得高性能的同時(shí),不把內(nèi)存打掛。

        總結(jié)

        本文介紹了 Guava Cache 一些常用的 API 、用法示例,以及需要警惕的一些使用誤區(qū)。

        在選擇使用 Guava 時(shí),我一般會(huì)結(jié)合實(shí)際使用場(chǎng)景,做出以下的考慮:

        1. 為什么不用 Redis?

          如果本地緩存能夠解決,我不希望額外引入一個(gè)中間件。

        2. 如果保證緩存和數(shù)據(jù)源數(shù)據(jù)的一致性?

          一種情況,我會(huì)在數(shù)據(jù)要求敏感度不高的場(chǎng)景使用緩存,所以短暫的不一致可以忍受;另外一些情況,我會(huì)在設(shè)置定期刷新緩存以及手動(dòng)刷新緩存的機(jī)制。舉個(gè)例子,頁面上有一個(gè)顯示應(yīng)用 developer 列表的功能,而本地僅存儲(chǔ)了應(yīng)用名,developer 列表是通過一個(gè) RPC 接口查詢獲取的,而由于對(duì)方的限制,該接口 qps 承受能力非常低,便可以考慮緩存 developer 列表,并配置 maximumSize 以及 expireAfterAccess。如果有用戶在 developer 數(shù)據(jù)源中新增了數(shù)據(jù),導(dǎo)致了數(shù)據(jù)不一致,頁面也可以設(shè)置一個(gè)同步按鈕,讓用戶去主動(dòng) refresh;或者,如果判斷當(dāng)前用戶不在 developer 列表,也可以程序 refresh 一次??傊浅l`活,使用 Guava Cache 的 API 可以滿足大多數(shù)業(yè)務(wù)場(chǎng)景的緩存需求。

        3. 為什么是 Guava Cache,它的性能怎么樣?

          我現(xiàn)在主要是出于穩(wěn)定性考慮,項(xiàng)目一直在使用 Guava Cache。據(jù)說有比 Guava Cache 快的本地緩存,但那點(diǎn)性能我的系統(tǒng)不是特別關(guān)心。

        - END -

        瀏覽 53
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 国产一区视频在线看 | 欧美裸体走秀XXXXXX | 张开双腿嬷嬷调教宫口h | 亚洲老女人性爱视频 | 亚洲天堂999 |