布隆過濾器 與 Redis BitMap

KerryWu發表於2023-03-06

1. 前言

在前些年的開發中就遇到幾個類似的場景:

場景1

場景:我當前業務表結構冗餘了人員的資訊欄位,當人員的基本資訊發生變更或刪除時,會推送MQ。我當前業務監聽到有人員資訊變更的MQ訊息,會查資料庫,看看該人員在當前表中是否存在。如果存在則更新,如果不存在則無需處理。

問題:每當監聽到人員變更的的MQ訊息,就需要查詢表中是否存在,給資料庫帶來消耗。

嘗試方案:將當前表中的所有人員ID,存入redis Set集合中。在監聽到人員變更的MQ訊息時,先去Set中檢查一下是否存在。如果存在再去更新資料庫。

嘗試方案的問題:當人員數量幾十萬的時候,redis Set集合也會很大,redis key太大影響效能,檢查人員是否存在也會變慢。

場景2

我們有些高頻呼叫的查詢介面,因為呼叫頻率高,查庫複雜,因此已經做了快取。快取的策略是:如果查詢對應的快取key存在,則從快取獲取;如果不存在,則在查庫,如果查庫能獲得有效值,再將結果存入快取。

帶來的問題:介面對外暴露後,因為前臺呼叫方不可控,可能總會查詢不存在的key。造成快取穿透,頻繁的無效查庫。

嘗試方案:針對快取穿透,當查詢某個key,在庫中不存在時。在快取中依然建立一個key,value設為null。

嘗試方案的問題:設為null的key,就只有等過期時間到了後,自行刪除。有些時候上一秒查庫不存在,給對應的key快取了一個為null的value。但下一秒庫中有了,走快取拿到的依然是null。

更好的解決方案

對於上面可能遇到的問題,和嘗試的解決方案,有一個更好的解決方案:布隆過濾器。

可以理解為場景1裡面的那個Set,只不過容量更小,檢索效能更高。那麼針對場景2快取穿透的問題,將所有的快取key存入布隆過濾器中,如果在過濾器中的key,再透過快取、資料庫獲取。

2. 布隆過濾器

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

通常我們會遇到很多要判斷一個元素是否在某個集合中的業務場景,一般想到的是將集合中所有元素儲存起來,然後透過比較確定。連結串列、樹、雜湊表(又叫雜湊表,Hash table)等等資料結構都是這種思路。但是隨著集合中元素的增加,我們需要的儲存空間也會呈現線性增長,最終達到瓶頸。同時檢索速度也越來越慢。上述三種結構的檢索時間複雜度分別為:

  • O(n):連結串列
  • O(logn):樹
  • O(1):雜湊表

這個時候,布隆過濾器(Bloom Filter)就應運而生。

2.1. 原理

當一個元素被加入集合時,透過N個Hash函式將這個元素進行Hash,算出一個整數索引值,然後對陣列長度進行取模運算,從而得到一個位置,每個Hash函式都會得到一個不同的位置,然後再把位陣列中的幾個位置點都置為1。

檢索時,也會把雜湊的幾個位置算出來,然後看看位陣列中對應的幾個位置是否都為1,只要有一個位為0,那麼就說明布隆過濾器裡不存在這個元素。

但是,這幾個位置都為1,並不能完全說明這個元素就一定存在其中。因為雜湊函式是會有碰撞的,不同的輸入,可能在雜湊後為同一個位置。即有可能這些位置為1是因為其他元素的存在,這就是布隆過濾器會出現誤判的原因。

因此查詢某個變數的時候我們只要看看這些點是不是都是 1 就可以大機率知道集合中有沒有它了

  • 如果這些點有任何一個 0,則被查詢變數一定不在。
  • 如果都是 1,則被查詢變數很可能存在。

2.2. 特性與優缺點

1. 不存在時一定不存在

一個元素如果判斷結果為存在的時候元素不一定存在,但是判斷結果為不存在的時候則一定不存在。

原因:布隆過濾器的誤判是指多個輸入經過雜湊之後,在相同的bit位的值置1了,這樣就無法判斷究竟是哪個輸入產生的,因此誤判的根源在於相同的 bit 位被多次對映且置 1。

2. 只增不刪

布隆過濾器可以新增元素,但是不能刪除元素。因為刪掉元素會導致誤判率增加。

原因:上述原因中的情況,多個輸入經過雜湊之後,在相同的bit位的值置1了,也造成了布隆過濾器的刪除問題。因為布隆過濾器的每一個 bit 並不是獨佔的,很有可能多個元素共享了某一位。如果我們直接刪除這一位的話,會影響其他的元素。

如果需要刪除一批元素,可以考慮重新初始化一個布隆過濾器,替換原來的。

3. 優點
  • 佔用空間極小,插入和查詢速度極快;
  • 布隆過濾器可以表示全集,其它任何資料結構都不能;
3. 缺點
  • 誤算率隨著元素的增加而增加;
  • 一般情況下無法刪除元素;

2.3. 應用場景

基於上述的功能,我們大致可以把布隆過濾器用於以下的場景之中:

1. 大資料判斷是否存在來實現去重

這就可以實現出上述的去重功能,如果你的伺服器記憶體足夠大的話,那麼使用 HashMap 可能是一個不錯的解決方案,理論上時間複雜度可以達到 O(1) 的級別,但是當資料量起來之後,還是隻能考慮布隆過濾器。

2. 判斷使用者是否訪問過

判斷使用者是否閱讀過某影片或文章,比如抖音或頭條,當然會導致一定的誤判,但不會讓使用者看到重複的內容。

3. 爬蟲/郵箱等系統的過濾

平時不知道你有沒有注意到有一些正常的郵件也會被放進垃圾郵件目錄中,這就是使用布隆過濾器誤判導致的。

4. 天然適合快取穿透場景

布隆過濾器天然就能應對快取穿透的場景。

首先,布隆過濾器的應用策略正好和快取是相反的:

  • 快取策略:快取中不存在的,再去查db。
  • 布隆過濾器策略:過濾器中存在的,再去查快取(db)。

然後,由於它的特性:一個元素如果判斷結果為存在的時候元素不一定存在,但是判斷結果為不存在的時候則一定不存在。

這表明它的誤判率並不影響它的策略:

  • 當判斷結果為存在時:不一定存在。帶來的不好的結果,頂多就是多查一次快取。
  • 當判斷結果為不存在時:一定不存在。策略中判斷不存在時,當前請求就會被攔截,這方面是沒有誤判的。

所以說,布隆過濾器天然適合快取穿透的場景,它的誤判率對與該場景沒有絲毫影響。

2.4. 實現

有很多布隆過濾器的實現,就如同之前將限流器的實現有 guava 和 redisson,布隆過濾器的實現也一樣。

下面兩種實現方式十分相似,都會初始化兩個引數:

  • 初始容量:當實際元素的數量超過這個初始化容量時,誤判率上升。設定的過大,會浪費儲存空間,設定的過小,就會影響準確率,所以在使用之前一定要儘可能地精確估計好元素數量,還需要加上一定的冗餘空間以避免實際元素可能會意外高出設定值很多。
  • 期望錯誤率:期望錯誤率越低,需要的空間就越大。錯誤率越小,需要的儲存空間就越大,對於不需要過於精確的場景,錯誤率設定稍大一點也可以。

2.4.1. guava

1. pom
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>23.0</version>
</dependency>
2. main
    private static String STR_1="str_1";
    private static String STR_2="str_2";
    private static String STR_101="str_101";

    public static void main(String[] args) {
        BloomFilter<String> bloomFilter=BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),10000,0.0001);
        for (int i = 0; i < 100; i++) {
            bloomFilter.put("str_" + i);
        }
        log.info("{}是否存在:{}",STR_1,bloomFilter.mightContain(STR_1));
        log.info("{}是否存在:{}",STR_2,bloomFilter.mightContain(STR_2));
        log.info("{}是否存在:{}",STR_101,bloomFilter.mightContain(STR_101));
    }

執行後返回的結果是:

23:30:45.960 [main] INFO pers.kerry.redislimitservice.controller.DemoController - str_1是否存在:true
23:30:45.965 [main] INFO pers.kerry.redislimitservice.controller.DemoController - str_2是否存在:true
23:30:45.966 [main] INFO pers.kerry.redislimitservice.controller.DemoController - str_101是否存在:false

Guava 提供的布隆過濾器的實現還是很不錯的 ,但是它有一個重大的缺陷就是隻能單機使用 (另外,容量擴充套件也不容易),而現在網際網路一般都是分散式的場景。為瞭解決這個問題,我們就需要用到 Redis 中的布隆過濾器了。

2.4.2. redisson

1. pom
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.15.5</version>
        </dependency>
2. application
spring:
  redis:
    redisson:
      config:
        singleServerConfig:
          address: redis://127.0.0.1:6379
          database: 0
3. controller
    @GetMapping("bloom-filter")
    public boolean bloomFilter(String str) {
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloom:filter:test1");
        bloomFilter.tryInit(10000, 0.0001);
        for (int i = 0; i < 100; i++) {
            bloomFilter.add("str_" + i);
        }
        return bloomFilter.contains(str);
    }

3. Redis BitMap

上述講到 Redisson 基於布隆過濾器的實現,本質上是redis支援了布隆過濾器,這裡就要講到 redis 的 BitMap 結構。

redis BitMap 並不作為redis基礎資料型別,redis的基本資料型別只有5種:string、list、set、zset、hash。

而 BitMap 就是基於SDS(Simple Dynamic String,簡單動態字串)實現的,所以針對 BitMap Key 的名稱執行 TYPE KEY_NAME 命令時,返回的是 string。

因此,我們在講 BitMap 資料結構之前,先講一下 SDS。

3.1. SDS字串

1. 資料結構

SDS 的資料結構定義為:

struct sdshdr {

    unsigned int len;

    unsigned int free;

    char buf[];
};
  • len:記錄buf陣列中已使用位元組的數量,即等於SDS所儲存字串的長度。
  • free:記錄buf陣列中未使用位元組的數量
  • buf:char陣列,用於儲存實際字串資料,注意陣列末尾總會保留一個空字串'\0'。
2. 著重介紹一下buf

它是 char 陣列,char 是C語言中的字元型別,佔1個位元組(Byte),即8個位(Bit)。

buf 尾部自動追加一個'\0'字元並不會計算在 SDS 的len中,這是為了遵循 C 字串以空字串結尾的慣例,使得 SDS 可以直接使用一部分string.h庫中的函式,如strlen。

3. SDS優點

SDS 具有以下優點,但這裡就不展開了。這裡就簡單列一下,可後期專門看這方面的資料:

  • 常數複雜度獲取字串長度。
  • 杜絕緩衝區溢位。
  • 減少修改字串長度時所需的記憶體重分配次數。
  • 二進位制安全。
  • 相容部分C字串函式。

3.2. BitMap點陣圖

在簡單瞭解 SDS 的資料結構後,就比較方便理解 BitMap了。

如果我們需要記錄某一使用者在一年中每天是否有登入我們的系統這一需求該如何完成呢?如果使用KV儲存,每個使用者需要記錄365個,當使用者量上億時,這所需要的儲存空間是驚人的。

Redis 為我們提供了BitMap點陣圖這一資料結構,每個使用者每天的登入記錄只佔據一位,365天就是365位。8位(Bit)1個位元組(Byte),因此僅僅需要46位元組就可儲存,極大地節約了儲存空間。

BitMap 簡稱點陣圖,是由多個二進位制位組成的陣列,陣列中的每個二進位制位都有與之對應的偏移量,可以透過這些偏移量對點陣圖中指定的一個或多個二進位制位進行操作。

1. 命令

Redis提供了SETBIT、GETBIT、BITCOUNT、BITOP四個常用命令用於處理二進位制位陣列。

  • SETBIT:為位陣列指定偏移量上的二進位制位設定值,偏移量從0開始計數,二進位制位的值只能為0或1。返回原位置值。
  • GETBIT:獲取指定偏移量上二進位制位的值。
  • BITCOUNT:統計位陣列中值為1的二進位制位數量。
  • BITOP:對多個位陣列進行按位與、或、異或運算。

最常用的兩個命令 setbit、getbit 執行的複雜度都是 O(1),算是拿空間換時間的做法。

2. 資料結構

BitMap 是基於 SDS實現的,所以說資料結構上一樣。還記得之前 SDS 的資料結構中,buf 是一個char位元組陣列吧,陣列中每個元素char有8個位,每個位中就可以儲存我們 BitMap 中的資料。

gitbit 命令的原始碼如下:

void getbitCommand(client *c) {
    robj *o;
    char llbuf[32];
    uint64_t bitoffset;
    size_t byte, bit;
    size_t bitval = 0;
    // 獲取offset
    if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset,0,0) != C_OK)
        return;
    // 查詢對應的點陣圖物件
    if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.czero)) == NULL ||
        checkType(c,o,OBJ_STRING)) return;
  // 計算offset位於位陣列的哪一行
    byte = bitoffset >> 3;
    // 計算offset在一行中的第幾位,等同於取模
    bit = 7 - (bitoffset & 0x7);
    // #define sdsEncodedObject(objptr) (objptr->encoding == OBJ_ENCODING_RAW || objptr->encoding == OBJ_ENCODING_EMBSTR)
    if (sdsEncodedObject(o)) {
        // SDS 是RAW 或者 EMBSTR型別
        if (byte < sdslen(o->ptr))
            // 獲取指定位置的值
            // 注意它不是真正的一個二維陣列不能用((uint8_t*)o->ptr)[byte][bit]去獲取呀~
            bitval = ((uint8_t*)o->ptr)[byte] & (1 << bit);
    } else {
        //  SDS 是 REDIS_ENCODING_INT 型別的整數,先轉為String
        if (byte < (size_t)ll2string(llbuf,sizeof(llbuf),(long)o->ptr))
            bitval = llbuf[byte] & (1 << bit);
    }

    addReply(c, bitval ? shared.cone : shared.czero);
}

getbit 命令的執行過程如下:

  1. 計算 byte=offset/8,byte 值表示指定的 offset 位於位陣列的哪個位元組(計算在第幾行);
  2. 指定 buf[i] 中的i了,接下來就要計算在8個位元組中的第幾位呢?使用 bit=(offset mod 8)+1 計算可得;
  3. 根據 byte 和 bit 在位陣列中定位到目標值返回即可。

以GETBIT array 3為例,array表示上圖中三個位元組的位陣列。

  1. byte=[3/8] 得到值為0,說明在 buf[0] 上
  2. bit=(3 mod 8)+1 得到值為4
  3. 定位到 buf[0] 位元組的從左至右第4個位置上

引用 :

相關文章