Guava Cache 使用小結

閒聊

話說原創文章已經斷更 2 個月了,倒也不是因爲忙,主要還是懶。但是也感覺可以拿出來跟大家分享的技術點越來越少了,一方面主要是最近在從事一些 “內部項目” 的研發,縱使我很想分享,也沒法搬到公衆號 & 博客上來;一方面是一些我並不是很擅長的技術點,在我還是新手時,我敢於去寫,而有了一定工作年限之後,反而有些包袱了,我的讀者會不會介意呢?思來想去,我回憶起了寫作的初心,不就是爲了記錄自己的學習過程嗎?於是乎,我還是按照我之前的文風記錄下了此文,以避免成爲一名斷更的博主。

以下是正文。

前言

“緩存” 一直是我們程序員聊的最多的那一類技術點,諸如 Redis、Encache、Guava Cache,你至少會聽說過一個。需要承認的是,無論是面試八股文的風氣,還是實際使用的頻繁度,Redis 分佈式緩存的確是當下最爲流行的緩存技術,但同時,從我個人的項目經驗來看,本地緩存也是非常常用的一個技術點。

分析 Redis 緩存的文章很多,例如 Redis 雪崩、Redis 過期機制等等,諸如此類的公衆號標題不鮮出現在我朋友圈的 timeline 中,但是分析本地緩存的文章在我的映像中很少。

在最近的項目中,有一位新人同事使用了 Guava Cache 來對一個 RPC 接口的響應進行緩存,我在 review 其代碼時恰好發現了一個不太合理的寫法,遂有此文。

本文將會介紹 Guava Cache 的一些常用操作:基礎 API 使用,過期策略,刷新策略。並且按照我的寫作習慣,會附帶上實際開發中的一些總結。需要事先說明的是,我沒有閱讀過 Guava Cache 的源碼,對其的介紹僅僅是一些使用經驗或者最佳實踐,不會有過多深入的解析。

先簡單介紹一下 Guava Cache,它是 Google 封裝的基礎工具包 guava 中的一個內存緩存模塊,它主要提供了以下能力:

基礎使用

使用一個示例介紹 Guava Cache 的基礎使用方法 -- 緩存大小寫轉換的返回值。

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

@Test
public void whenCacheMiss_thenFetchValueFromServer() throws ExecutionException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().build(new CacheLoader<String, String>() {
            @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 的好處已經躍然於紙上了,它解耦了緩存存取與業務操作。CacheLoader 的 load 方法可以理解爲從數據源加載原始數據的入口,當調用 LoadingCache 的 getUnchecked 或者 get方法時,Guava Cache 行爲如下:

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

預加載緩存

預加載緩存的常見使用場景:

Guava Cache 提供了 put 和 putAll 方法

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

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

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

操作和 HashMap 一模一樣。

這裏有一個誤區,而那位新人同事恰好踩到了,也是我寫這篇文章的初衷,請務必僅在預加載緩存這個場景使用 put,其他任何場景都應該使用 load 去觸發加載緩存。看下面這個反面示例

// 注意這是一個反面示例
@Test
public void wrong_usage_whenCacheMiss_thenPut() throws ExecutionException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().build(new CacheLoader<String, String>() {
            @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 方法中設置了一個空值,後續通過手動 put + get 的方式使用緩存,這種習慣更像是在操作一個 HashMap,但並不推薦在 Cache 中使用。在前面介紹過 get 配合 load 是由 Guava Cache 去保障了線程安全,保障多個線程訪問緩存時,第一個請求加載緩存的同時,阻塞後續請求,這樣的 HashMap 用法既不優雅,在極端情況下還會引發緩存擊穿、線程安全等問題。

請務必僅僅將 put 方法用作預加載緩存場景。

緩存過期

前面的介紹使用起來依舊沒有脫離 ConcurrentHashMap 的範疇,Cache 與其的第一個區別在 “緩存過期” 這個場景可以被體現出來。本節介紹 Guava 一些常見的緩存過期行爲及策略。

緩存固定數量的值

@Test
public void whenReachMaxSize_thenEviction() throws ExecutionException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().maximumSize(3).build(new CacheLoader<String, String>() {
            @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 做緩存的一個最大的問題,便是我們沒有簡易有效的手段阻止其無限增長,而 Guava Cache 可以通過初始化 LoadingCache 的過程,配置 maximumSize ,以確保緩存內容不導致你的系統出現 OOM。

值得注意的是,我這裏的測試用例使用的是除了 get 、getUnchecked 外的第三種獲取緩存的方式,如字面意思描述的那樣,getIfPresent 在緩存不存在時,並不會觸發 load 方法加載數據源。

LRU 過期策略

依舊沿用上述的示例,我們在設置容量爲 3 時,僅獲悉 LoadingCache 可以存儲 3 個值,卻並未得知第 4 個值存入後,哪一個舊值需要淘汰,爲新值騰出空位。實際上,Guava Cache 默認採取了 LRU 緩存淘汰策略。Least Recently Used 即最近最少使用,這個算法你可能沒有實現過,但一定會聽說過,在 Guava Cache 中 Used 的語義代表任意一次訪問,例如 put、get。繼續看下面的示例。

@Test
public void whenReachMaxSize_thenEviction() throws ExecutionException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().maximumSize(3).build(new CacheLoader<String, String>() {
            @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"));
}

注意此示例與上一節示例的區別:第四次 get 訪問 one 後,two 變成了最久未被使用的值,當第四個值 four 存入後,淘汰的對象變成了 two,而不再是 one 了。

緩存固定時間

爲緩存設置過期時間,也是區分 HashMap 和 Cache 的一個重要特性。Guava Cache 提供了expireAfterAccess、 expireAfterWrite 的方案,爲 LoadingCache 中的緩存值設置過期時間。

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

    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
            @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<String, String> cache =
        CacheBuilder.newBuilder()
            .build(new CacheLoader<String, String>() {
                @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) 移除單個緩存,使用 void invalidateAll() 移除所有緩存。

緩存刷新

緩存刷新的常用於使用數據源的新值覆蓋緩存舊值,Guava Cache 提供了兩類刷新機制:手動刷新和定時刷新。

手動刷新

cache.refresh("kirito");

refresh 方法將會觸發 load 邏輯,嘗試從數據源加載緩存。

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

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

    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
            @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);

}

其實任何情況下,緩存值都有可能和數據源出現不一致,業務層面需要做好訪問到舊值的容錯邏輯。

自動刷新

@Test
public void whenTTL_thenRefresh() throws ExecutionException, InterruptedException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
            @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);
}

和上節的 refresh 機制一樣,refreshAfterWrite 同樣不會阻塞 get 線程,依舊有訪問舊值的可能性。

緩存命中統計

Guava Cache 默認情況不會對命中情況進行統計,需要在構建 CacheBuilder 時顯式配置 recordStats

@Test
public void whenRecordStats_thenPrint() throws ExecutionException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().maximumSize(100).recordStats().build(new CacheLoader<String, String>() {
            @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}

緩存移除的通知機制

在一些業務場景中,我們希望對緩存失效進行一些監測,或者是針對失效的緩存做一些回調處理,就可以使用 RemovalNotification 機制。

@Test
public void whenRemoval_thenNotify() throws ExecutionException {
    LoadingCache<String, String> cache =
        CacheBuilder.newBuilder().maximumSize(3)
            .removalListener(
                cacheItem -> System.out.println(cacheItem + " is removed, cause by " + cacheItem.getCause()))
            .build(new CacheLoader<String, String>() {
                @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 增加一個回調處理器,RemovalNotification 實例包含了緩存的鍵值對以及移除原因。

Weak Keys & Soft Values

Java 基礎中的弱引用和軟引用的概念相信大家都學習過,這裏先給大家複習一下

在 Guava Cache 中,CacheBuilder 提供了 weakKeys、weakValues、softValues 三種方法,將緩存的鍵值對與 JVM 垃圾回收機制產生關聯。

該操作可能有它適用的場景,例如最大限度的使用 JVM 內存做緩存,但依賴 GC 清理,性能可想而知會比較低。總之我是不會依賴 JVM 的機制來清理緩存的,所以這個特性我不敢使用,線上還是穩定性第一。

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

總結

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

在選擇使用 Guava 時,我一般會結合實際使用場景,做出以下的考慮:

  1. 爲什麼不用 Redis?

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

  2. 如果保證緩存和數據源數據的一致性?

    一種情況,我會在數據要求敏感度不高的場景使用緩存,所以短暫的不一致可以忍受;另外一些情況,我會在設置定期刷新緩存以及手動刷新緩存的機制。舉個例子,頁面上有一個顯示應用 developer 列表的功能,而本地僅存儲了應用名,developer 列表是通過一個 RPC 接口查詢獲取的,而由於對方的限制,該接口 qps 承受能力非常低,便可以考慮緩存 developer 列表,並配置 maximumSize 以及 expireAfterAccess。如果有用戶在 developer 數據源中新增了數據,導致了數據不一致,頁面也可以設置一個同步按鈕,讓用戶去主動 refresh;或者,如果判斷當前用戶不在 developer 列表,也可以程序 refresh 一次。總之非常靈活,使用 Guava Cache 的 API 可以滿足大多數業務場景的緩存需求。

  3. 爲什麼是 Guava Cache,它的性能怎麼樣?

    我現在主要是出於穩定性考慮,項目一直在使用 Guava Cache。據說有比 Guava Cache 快的本地緩存,但那點性能我的系統不是特別關心。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/n7B3eq90bV_rWeDoCWBMLg