重新認識下JVM級別的本地快取框架Guava Cache——優秀從何而來

2022-11-22 12:02:37

大家好,又見面了。


本文是筆者作為掘金技術社群簽約作者的身份輸出的快取專欄系列內容,將會通過系列專題,講清楚快取的方方面面。如果感興趣,歡迎關注以獲取後續更新。


不知不覺,這已經是《深入理解快取原理與實戰設計》系列專欄的第6篇文章了。經過前面5篇文章的鋪墊,我們系統且全面的介紹了快取相關的概念與典型問題,也手動實操瞭如何構建一個本地最簡版本的通用快取框架,還對JAVA主流的本地快取規範進行了解讀。

秉持著不重複造輪子的理念,本篇文章中,我們就來一起深入剖析JAVA本地快取的優秀「輪子」 —— 來自Google家族的Guava Cache。聊一聊其實現機制、看一看如何使用

Guava Cache初識

Guava是Google提供的一套JAVA的工具包,而Guava Cache則是該工具包中提供的一套完善的JVM級別的高並行快取框架。其實現機制類似ConcurrentHashMap,但是進行了眾多的封裝與能力擴充套件。作為JVM級別的本地快取框架,Guava Cache具備快取框架該有的眾多基礎特性。當然,Guava Cache能從眾多本地快取類產品中脫穎而出,除了具備上述基礎快取特性外,還有眾多貼心的能力增強,絕對算得上是工具包屆的超級暖男!為什麼這麼說呢?我們一起看下Guava Cache的能力介紹,應該可以有所體會。

支援快取記錄的過期設定

作為一個合格的快取容器,支援快取記錄過期是一個基礎能力。Guava Cache不但支援設定過期時間,還支援選擇是根據插入時間進行過期處理(建立過期)、或者是根據最後存取時間進行過期處理(存取過期)。

過期策略 具體說明
建立過期 基於快取記錄的插入時間判斷。比如設定10分鐘過期,則記錄加入快取之後,不管有沒有存取,10分鐘時間到則
存取過期 基於最後一次的存取時間來判斷是否過期。比如設定10分鐘過期,如果快取記錄被存取到,則以最後一次存取時間重新計時;只有連續10分鐘沒有被存取的時候才會過期,否則將一直存在快取中不會被過期。

實際使用的時候,可以在建立快取容器的時候指定過期策略即可:

  • 基於建立時間過期
public Cache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
        .expireAfterWrite(30L, TimeUnit.MINUTES)
        .build();
}
  • 基於存取時間過期
public Cache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
        .expireAfterAccess(30L, TimeUnit.MINUTES)
        .build();
}

是不是很方便?

支援快取容量限制與不同淘汰策略

作為記憶體型快取,必須要防止出現記憶體溢位的風險。Guava Cache支援設定快取容器的最大儲存上限,並支援根據快取記錄條數或者基於每條快取記錄的權重(後面會具體介紹)進行判斷是否達到容量閾值。

當容量觸達閾值後,支援根據FIFO + LRU策略實施具體淘汰處理以騰出位置給新的記錄使用。

淘汰策略 具體說明
FIFO 根據快取記錄寫入的順序,先寫入的先淘汰
LRU 根據存取順序,淘汰最久沒有存取的記錄

實際使用的時候,同樣是在建立快取容器的時候指定容量上限與淘汰策略,這樣就可以放心大膽的使用而不用擔心記憶體溢位問題咯。

  • 限制快取記錄條數
public Cache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
            .maximumSize(10000L)
            .build();
}
  • 限制快取記錄權重
public Cache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
            .maximumWeight(10000L)
            .weigher((key, value) -> (int) Math.ceil(instrumentation.getObjectSize(value) / 1024L))
            .build();
    }

這裡需要注意:按照權重進行限制快取容量的時候必須要指定weighter屬性才可以生效。上面程式碼中我們通過計算value物件的位元組數(byte)來計算其權重資訊,每1kb的位元組數作為1個權重,整個快取容器的總權重限制為1w,這樣就可以實現將快取記憶體佔用控制在10000*1k≈10M左右。

有沒有很省心?

支援整合資料來源能力

在前面文章中,我們有介紹過快取的三種模型,分別是旁路型穿透型非同步型。Guava Cache作為一個封裝好的快取框架,是一個典型的穿透型快取。正常業務使用快取時通常會使用旁路型快取,即先去快取中嘗試查詢獲取資料,如果獲取不到則會從資料庫中進行查詢並加入到快取中;而為了簡化業務端使用複雜度,Guava Cache支援整合資料來源,業務層面呼叫介面查詢快取資料的時候,如果快取資料不存在,則會自動去資料來源中進行資料獲取並加入快取中。

public User findUser(Cache<String, User> cache, String userId) {
    try {
        return cache.get(userId, () -> {
            System.out.println(userId + "使用者快取不存在,嘗試回源查詢並回填...");
            return userDao.getUser(userId);
        });
    } catch (ExecutionException e) {
        e.printStackTrace();
    }
    return null;
}

實際使用的時候如果查詢的使用者不存在,則會自動去回源查詢並寫入快取裡,再次獲取的時候便可以從快取直接獲取:

上面的方法裡,是通過在get方法裡傳入Callable實現的方式指定回源獲取資料的方式,來實現快取不存在情況的自動資料拉取與回填到快取中的。實際使用的時候,除了Callable方式,還有一種CacheLoader的模式,也可以實現這一效果。

需要我們在建立快取容器的時候宣告容器為LoadingCache型別(下面的章節中有介紹),並且指定CacheLoader處理邏輯:

public LoadingCache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
            .build(new CacheLoader<String, User>() {
                @Override
                public User load(String key) throws Exception {
                    System.out.println(key + "使用者快取不存在,嘗試CacheLoader回源查詢並回填...");
                    return userDao.getUser(key);
                }
            });
    }

這樣,獲取不到資料的時候,也會自動回源查詢並填充。比如我們執行如下呼叫邏輯:

    public static void main(String[] args) {
        CacheService cacheService = new CacheService();
        LoadingCache<String, User> cache = cacheService.createUserCache();
        try {
            System.out.println(cache.get("123"));
            System.out.println(cache.get("124"));
            System.out.println(cache.get("123"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

執行結果如下:

123使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=123, userName=鐵柱, department=研發部)
124使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=124, userName=翠花, department=測試部)
User(userId=123, userName=鐵柱, department=研發部)

兩種方式都可以實現這一效果,實際可以根據需要與場景選擇合適的方式。

當然,有些時候,可能也會涉及到CacheLoaderCallable兩種方式結合使用的場景,這種情況下優先會執行Callable提供的邏輯,Callable缺失的場景會使用CacheLoader提供的邏輯。

public static void main(String[] args) {
    CacheService cacheService = new CacheService();
    LoadingCache<String, User> cache = cacheService.createUserCache();
    try {
        System.out.println(cache.get("123", () -> new User("xxx")));
        System.out.println(cache.get("124"));
        System.out.println(cache.get("123"));
    } catch (Exception e) {
        e.printStackTrace();
    }
}

執行後,可以看出Callable邏輯被優先執行,而CacheLoader作為兜底策略存在:

User(userId=xxx, userName=null, department=null)
124使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=124, userName=翠花, department=測試部)
User(userId=xxx, userName=null, department=null)

支援更新鎖定能力

這個是與上面資料來源整合一起的輔助增強能力。在高並行場景下,如果某個key值沒有命中快取,大量的請求同步打到下游模組處理的時候,很容易造成快取擊穿問題。

為了防止快取擊穿問題,可以通過加鎖的方式來規避。當快取不可用時,僅持鎖的執行緒負責從資料庫中查詢資料並寫入快取中,其餘請求重試時先嚐試從快取中獲取資料,避免所有的並行請求全部同時打到資料庫上。

作為穿透型快取的保護策略之一,Guava Cache自帶了並行鎖定機制,同一時刻僅允許一個請求去回源獲取資料並回填到快取中,而其餘請求則阻塞等待,不會造成資料來源的壓力過大。

有沒有被暖心到?

提供了快取相關的一些監控統計

引入快取的一個初衷是希望快取能夠提升系統的處理效能,而有限快取容量中僅儲存部分資料的時候,我們會希望儲存的有限資料可以儘可能的覆蓋並抗住大部分的請求流量,所以對快取的命中率會非常關注。

Guava Cache深知這一點,所以提供了stat統計紀錄檔,支援檢視快取資料的載入或者命中情況統計。我們可以基於命中情況,不斷的去優化程式碼中快取的資料策略,以發揮出快取的最大價值。

Guava Cache的統計資訊封裝為CacheStats物件進行承載,主要包含一下幾個關鍵指標項:

指標 含義說明
hitCount 命中快取次數
missCount 沒有命中快取次數(查詢的時候記憶體中沒有)
loadSuccessCount 回源載入的時候載入成功次數
loadExceptionCount 回源載入但是載入失敗的次數
totalLoadTime 回源載入操作總耗時
evictionCount 刪除記錄的次數

快取容器建立的時候,可以通過recordStats()開啟快取行為的統計記錄:

    public static void main(String[] args) {
        LoadingCache<String, User> cache = CacheBuilder.newBuilder()
                .recordStats()
                .build(new CacheLoader<String, User>() {
                    @Override
                    public User load(String key) throws Exception {
                        System.out.println(key + "使用者快取不存在,嘗試CacheLoader回源查詢並回填...");
                        User user = userDao.getUser(key);
                        if (user == null) {
                            System.out.println(key + "使用者不存在");
                        }
                        return user;
                    }
                });

        try {
            System.out.println(cache.get("123");
            System.out.println(cache.get("124"));
            System.out.println(cache.get("123"));
            System.out.println(cache.get("126"));

        } catch (Exception e) {
        } finally {
            CacheStats stats = cache.stats();
            System.out.println(stats);
        }
    }

上述程式碼執行之後結果輸出如下:

123使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=123, userName=鐵柱, department=研發部)
124使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=124, userName=翠花, department=測試部)
User(userId=123, userName=鐵柱, department=研發部)
126使用者快取不存在,嘗試CacheLoader回源查詢並回填...
126使用者不存在
CacheStats{hitCount=1, missCount=3, loadSuccessCount=2, loadExceptionCount=1, totalLoadTime=1972799, evictionCount=0}

可以看出,一共執行了4次請求,其中1次命中,3次回源處理,2次回源載入成功,1次回源沒找到資料,與列印出來的CacheStats統計結果完全吻合。

有著上述能力的加持,前面將Guava Cache稱作「暖男」不過分吧?

Guava Cache適用場景

在本系列專欄的第一篇文章《聊一聊作為高並行系統基石之一的快取,會用很簡單,用好才是技術活》中,我們在快取的一步步演進介紹中提過本地快取與集中式快取的區別,也聊了各自的優缺點。

作為一款純粹的本地快取框架,Guava Cache具備本地快取該有的優勢,也無可避免的存在著本地快取的弊端

維度 簡要概述
優勢 基於空間換時間的策略,利用記憶體的高速處理效率,提升機器的處理效能,減少大量對外的IO請求互動,比如讀取DB、請求外部網路、讀取本地磁碟資料等等操作。
弊端 整體容量受限,可能對本機記憶體造成壓力。此外,對於分散式多節點叢集部署的場景,快取更新場景會出現快取漂移問題,導致各個節點之間的快取資料不一致

鑑於上述優劣綜合判斷,可以大致圈定Guava Cache的實際適用場合:

  • 資料讀多寫少且對一致性要求不高的場景

這類場景中,會將資料快取到本地記憶體中,採用定時觸發(或者事件推播)的策略重新載入到記憶體中。這樣業務處理邏輯直接從記憶體讀取需要的資料,修改系統設定項之後,需要等待一定的時間後方可生效。

很多的設定中心採用的都是這個快取策略。統一設定中心中管理設定資料,然後各個業務節點會從統一設定中心拉取設定並儲存在自己原生的記憶體中然後使用本地記憶體中的資料。這樣可以有效規避設定中心的單點故障問題,降低了設定中心的請求壓力,也提升了業務節點自身的業務處理效能(減少了與設定中心之間的網路互動請求)。

  • 效能要求極其嚴苛的場景

對於分散式系統而言,集中式快取是一個常規場景中很好的選項。但是對於一些超大並行量且讀效能要求嚴苛的系統而言,一個請求流程中需要頻繁的去與Redis互動,其網路開銷也是不可忍受的。所以可以採用將資料本機記憶體快取的方式,分散redis的壓力,降低對外請求互動的次數,提升介面響應速度。

  • 簡單的本地資料快取,作為HashMap/ConcurrentHashMap替代品

這種場景也很常見,我們在專案中經常會遇到一些資料的需要臨時快取一下,為了方便很多時候直接使用的HashMap或者ConcurrentHashMap來實現。而Guava Cache聚焦快取場景做了很多額外的功能增強(比如資料過期能力支援、容量上限約束等),可以完美替換掉HashMap/ConcurrentHashMap,更適合快取場景使用。

Guava Cache使用

引入依賴

使用Guava Cache,首先需要引入對應的依賴包。對於Maven專案,可以在pom.xml中新增對應的依賴宣告即可:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>

這樣,就完成了依賴引入。

容器建立 —— CacheBuilder

具體使用前首先面臨的就是如何建立Guava Cache範例。可以藉助CacheBuilder以一種優雅的方式來構建出合乎我們訴求的Cache範例。

CacheBuilder中常見的屬性方法,歸納說明如下:

方法 含義說明
newBuilder 構造出一個Builder範例類
initialCapacity 待建立的快取容器的初始容量大小(記錄條數
maximumSize 指定此快取容器的最大容量(最大快取記錄條數)
maximumWeight 指定此快取容器的最大容量(最大比重值),需結合weighter方可體現出效果
expireAfterWrite 設定過期策略,按照資料寫入時間進行計算
expireAfterAccess 設定過期策略,按照資料最後存取時間來計算
weighter 入參為一個函數式介面,用於指定每條存入的快取資料的權重佔比情況。這個需要與maximumWeight結合使用
refreshAfterWrite 快取寫入到快取之後
concurrencyLevel 用於控制快取的並行處理能力,同時支援多少個執行緒並行寫入操作
recordStats 設定開啟此容器的資料載入與快取命中情況統計

基於CacheBuilder及其提供的各種方法,我們可以輕鬆的進行快取容器的構建、並指定容器的各種約束條件。

比如下面這樣:

public LoadingCache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
            .initialCapacity(1000) // 初始容量
            .maximumSize(10000L)   // 設定最大容量
            .expireAfterWrite(30L, TimeUnit.MINUTES) // 設定寫入過期時間
            .concurrencyLevel(8)  // 設定最大並行寫操作執行緒數
            .refreshAfterWrite(1L, TimeUnit.MINUTES) // 設定自動重新整理資料時間
            .recordStats() // 開啟快取執行情況統計
            .build(new CacheLoader<String, User>() {
                @Override
                public User load(String key) throws Exception {
                    return userDao.getUser(key);
                }
            });
}

業務層使用

Guava Cache容器物件建立完成後,可以基於其提供的對外介面完成相關快取的具體操作。首先可以瞭解下Cache提供的對外操作介面:

對關鍵介面的含義梳理歸納如下:

介面名稱 具體說明
get 查詢指定key對應的value值,如果快取中沒匹配,則基於給定的Callable邏輯去獲取資料回填快取中並返回
getIfPresent 如果快取中存在指定的key值,則返回對應的value值,否則返回null(此方法不會觸發自動回源與回填操作)
getAllPresent 針對傳入的key列表,返回快取中存在的對應value值列表(不會觸發自動回源與回填操作)
put 往快取中新增key-value鍵值對
putAll 批次往快取中新增key-value鍵值對
invalidate 從快取中刪除指定的記錄
invalidateAll 從快取中批次刪除指定記錄,如果無引數,則清空所有快取
size 獲取快取容器中的總記錄數
stats 獲取快取容器當前的統計資料
asMap 將快取中的資料轉換為ConcurrentHashMap格式返回
cleanUp 清理所有的已過期的資料

在專案中,可以基於上述介面,實現各種快取操作功能。

public static void main(String[] args) {
    CacheService cacheService = new CacheService();
    LoadingCache<String, User> cache = cacheService.createUserCache6();
    cache.put("122", new User("122"));
    cache.put("122", new User("122"));
    System.out.println("put操作後查詢:" + cache.getIfPresent("122"));
    cache.invalidate("122");
    System.out.println("invalidate操作後查詢:" + cache.getIfPresent("122"));
    System.out.println(cache.stats());
}

執行後,結果如下:

put操作後查詢:User(userId=122, userName=null, department=null)
invalidate操作後查詢:null
CacheStats{hitCount=1, missCount=1, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0}

當然,上述範例程式碼中這種使用方式有個明顯的弊端就是業務層面對Guava Cache的私有API依賴過深,後續如果需要替換Cache元件的時候會比較痛苦,需要對業務呼叫的地方進行大改。所以真正專案裡面,最好還是對其適當封裝,以實現業務層面的解耦。如果你的專案是使用Spring框架,也可以基於Spring Cache統一規範來整合並使用Guava Cache,降低對業務邏輯的侵入

小結回顧

好啦,關於Guava Cache的功能與關鍵特性介紹,以及專案中具體的整合與使用方法,就介紹到這裡了。總結一下,Guava Cache其實就是一個增強版的大號ConcurrentHashMap,在保證執行緒安全的情況下,增加了快取必備的資料過期、容量限制、回源策略等能力,既保證了本身的精簡,又使得整體能力足以滿足大部分本地快取場景的使用訴求。也正是由於這些原因,Guava Cache在JAVA領域廣受好評,使用範圍非常的廣泛。

下一篇文章中,我們將繼續對Guava Cache展開討論,跳出使用層面,剖析其內部核心實現邏輯。如果有興趣,歡迎關注後續文章的更新。

那麼,關於本文中提及的內容,你是否有自己的一些想法與見解呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。