說說 Redis 緩存穿透場景與相應的解決方法

Redis 緩存主要緩存穿透、緩存擊穿與緩存雪崩異常場景,今天我們來講講緩存穿透。

1 場景描述

緩存穿透是指客戶端請求一個緩存和數據庫中都不存在的 key。由於緩存中不存在,所以請求會透過緩存查詢數據庫;由於數據庫中也不存在,所以也沒辦法更新緩存。因此下一次同樣的請求還是會打在數據庫上。

好像緩存被穿透了一樣,緩存形如虛設。所有的壓力都在數據庫之上,如果請求量巨大,可能造成數據庫崩潰。

2 解決方法

緩存穿透有以下幾種解決方法。

2.1 接口校驗

在請求入口進行校驗,比如對用戶進行鑑權、數據合法性檢查等操作,這樣可以減少緩存穿透發生的概率。


這種方式減輕了對 Redis 以及數據庫的壓力,但是增加了客戶端的編碼與維護的工作量。如果請求的入口很多,那麼工作量很大。

2.2 緩存空值

當緩存與數據庫中都沒有 key 時,就設置一個空值寫入緩存,並同時設置一個比較短的過期時間。由於在緩存中設置空值,所以請求在緩存這一級別就返回,也就不會被穿透。這些所說的不會被穿透只是針對某個 key 而言的。其它沒有設置空值的 key,仍然存在被穿透的可能。

該方法的問題是:由於不存在的 key 幾乎是無限,不可被窮舉的,所以不可能都設置到緩存中。而且大量這樣的空值 key 設置到緩存,也會佔用大量的內存空間。

解決:採用下面提到的布隆過濾器直接過濾掉不存在的 key。

2.3 布隆過濾器

布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進制向量和一系列隨機映射函數。布隆過濾器可以用於判斷一個元素是否在一個集合中2

布隆過濾器的特點是判斷爲不存在的,則一定不存在;判斷存在的,則大概率存在。

2.3.1 布隆過濾器原理

布隆過濾器的原理是當一個元素被加入集合時,通過K個散列函數將這個元素映射成一個位數組中的K個點,把它們置爲1。檢索時,我們只要看看這些點是不是都是1就(大約)知道集合中有沒有它了:如果這些點有任何一個0,則被檢元素一定不在;如果都是1,則被檢元素很可能在。這就是布隆過濾器的基本思想3

假設有這樣一個集合 S,它包含 a b c 三個元素。那麼布隆過濾器會利用多個哈希函數(圖中是三個哈希函數 h1、h2、h3)來計算所在的位,然後將該位設置爲 1。比如元素 a,經過三個哈希函數計算後,將相應的位設置爲 1,也就是圖中的紅線。元素 b 和 c 也是按照相應的方法進行計算處理。這時布隆過濾器初始化完畢。

假設有一個元素 d,需要判斷它是否在我們剛纔所創建的布隆過濾器中(圖中的黃色線條)。經過三個哈希函數 h1、h2、h3 計算後,發現相應的位都是 1,布隆過濾器會返回 true。也就是認爲這個元素可能在,也可能不在集合中。看到這裏,有同學就會問:“既然這個布隆過濾器都不知道這個元素是不是在集合中,對我們有什麼用呢?”

布隆過濾器的強大之處是可以利用較小的緩存,就可以判斷出某個元素是否不在集合中。比如又來了一個元素 e,經過三個哈希函數 h1、h2、h3 計算後,發現 h1(e) 所對應的位是 0。那麼這個元素 e 肯定不在集合中。有同學又說了:“我用 HashMap 不是也能判斷出某個元素在不在集合中呀?”

HashMap 是可以判斷,但需要存儲集合中所有的元素。如果集合中有上億個元素,那麼就會佔用大量的內存。內存空間畢竟是有限,可能還不一定放的下這麼多的元素。與 HashMap 相比, 布隆過濾器佔用的空間很小,所以很適合判斷大集合中某個元素是否不存在。

2.3.2 布隆過濾器誤識別率

之前的示例中可以看出,布隆過濾器判斷爲不存在的元素,則一定不存在;而判斷存在的元素,則大概率存在。也就是說,有的元素可能並不在集合中,但是布隆過濾器會認爲它存在。這就涉及到一個概念:誤識別率。誤識別率指的錯誤判斷一個元素在集合中的概率。

假設布隆過濾器有 m bit 大小,需要放入 n 個元素,每個元素使用 k 個哈希函數,那麼它的誤識別率如下表所示4

2.3.3 使用布隆過濾器

Google 的 Guava 庫提供了使用布隆過濾器的 API 類(BloomFilter.class),它是線程安全的。

首先創建布隆過濾器:

//創建存儲整型的布隆過濾器
bloomFilter =
        BloomFilter.create(Funnels.integerFunnel(), expectedInsertions, fpp);

創建布隆過濾器的方法有以下幾個入參:

入參 說明
Funnels 實例 用於後續把類對象轉換爲相應的 hash 值。
expectedInsertions 期望插入過濾器的元素個數。
fpp 誤識別率,該值必須大於 0 且小於 1.0。

Funnel 類定義瞭如何把一個具體的對象類型分解爲原生字段值,從而將值分解爲 Byte 以供後面 BloomFilter 進行 hash 運算5。也就是說 Guava 的布隆過濾器會根據Funnel 類的定義,計算一個對象的哈希值,放入過濾器。

Guava 官方提供了這樣一個創建可插入自定義類的布隆過濾器示例。

首先創建一個 Person 類:

@Data
@AllArgsConstructor
public class Person {
    private String firstName;
    private String lastName;
    private int age;
}

然後創建一個 PersonFunnel 類,它實現了 Funnel 類中的 funnel(Person from, PrimitiveSink into) 方法:

public class PersonFunnel implements Funnel<Person> {

    @Override
    public void funnel(Person from, PrimitiveSink into) {
        into.putUnencodedChars(from.getFirstName()).putUnencodedChars(from.getLastName()).putInt(from.getAge());
    }
}

這個方法主要是把 Person 類中的各個屬性(名字、年齡)寫入 PrimitiveSink 對象。 PrimitiveSink 提供了支持各種寫入類型的方法:

接着把元素放入布隆過濾器:

bloomFilter.put(new Person("deniro","lee",20));
bloomFilter.put(new Person("lily","lee",16));

最後就是判斷某個元素是否在布隆過濾器中:

Assert.assertFalse(bloomFilter.mightContain(new Person("jack","lee",20)));
Assert.assertTrue(bloomFilter.mightContain(new Person("deniro","lee",20)));

Funnels 是個工具類,內置了一些創建基本類型的 Funnel:


我們可以利用這些 Funnel,來創建包含基本類型元素的布隆過濾器,比如創建一個包含整型元素的布隆過濾器:

 BloomFilter<Integer> bloomFilter =
            BloomFilter.create(Funnels.integerFunnel(), size, fpp);

最後,讓我們總結一波。

可以看到接口校驗方法與 Guava 版的布隆過濾器方法都是在客戶側進行處理。布隆過濾器也可以在緩存層進行處理。相對來說,布隆過濾器方法比接口校驗方法少了很多代碼量與維護成本。緩存空值不可取,畢竟內存空間是有限的。

利用布隆過濾器,我們可以攔截絕大多數不存在的 key,因此很適合解決 Redis 緩存穿透問題。


參考資料:

  1. 緩存穿透、緩存擊穿、緩存雪崩解決方案
  2. 布隆過濾器
  3. 大數據量下的集合過濾—Bloom Filter
  4. 吳軍. 數學之美.第2版[M]. 人民郵電出版社, 2014. p208-209.
  5. 結合Guava源碼解讀布隆過濾器
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章