布隆過濾器 Bloom Filter

Mr_ηobody發表於2021-03-06

一 前言

假如有一個15億使用者的系統,每天有幾億使用者訪問系統,要如何快速判斷是否為系統中的使用者呢?

  • 方法一,將15億使用者儲存在資料庫中,每次使用者訪問系統,都到資料庫進行查詢判斷,準確性高,但是查詢速度會比較慢。
  • 方法二,將15億使用者快取在Redis記憶體中,每次使用者訪問系統,都到Redis中進行查詢判斷,準確性高,查詢速度也快,但是佔用記憶體極大。即使只儲存使用者ID,一個使用者ID一個字元,則15億*8位元組=12GB,對於一些記憶體空間有限的伺服器來說相對浪費。

還有對於網站爬蟲的專案,我們都知道世界上的網站數量及其之多,每當我們爬一個新的網站url時,如何快速判斷是否爬蟲過了呢?還有垃圾郵箱的過濾,廣告電話的過濾等等。如果還是用上面2種方法,顯然不是最好的解決方案。

再者,查詢是一個系統最高頻的操作,當查詢一個資料,首先會先到快取查詢(例如Redis),如果快取沒命中,於是到持久層資料庫(mongo,mysql等)查詢,發現也沒有此資料,於是本此查詢失敗。如果使用者很多的時候,並且快取都沒命中,進而全部請求了持久層資料庫,這就給資料庫帶來很大壓力,嚴重可能拖垮資料庫。俗稱快取穿透

可能大家也聽到另一個詞叫快取擊穿,它是指一個熱點key,不停著扛著高併發,突然這個key失效了,在失效的瞬間,大量的請求快取就沒命中,全部請求到資料庫。

對於以上這些以及類似的場景,如何高效的解決呢?針對此,布隆過濾器應運而生了。


二 布隆過濾器

布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進位制向量和一系列隨機對映函式。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的演算法要好的多,缺點是有一定的誤識別率和刪除困難。

二進位制向量,簡單理解就是一個二進位制陣列。這個陣列裡面存放的值要麼是0,要麼是1。

對映函式,它可以將一個元素對映成一個位陣列(Bit array)中的一個點。所以通過這個點,就能判斷集合中是否有此元素。

基本思想

  • 當一個元素被加入集合時,通過K個雜湊函式將這個元素對映到一個位陣列中的K個點,把它們置為1。
  • 檢索某個元素時,再通過這K個雜湊函式將這個元素對映,看看這些位置是不是都是1就能知道集合中這個元素存不存在。如果這些位置有任何一個0,則該元素一定不存在;如果都是1,則被檢元素很可能存在。

Bloom Filter跟單個雜湊函式對映不同,Bloom Filter使用了k個雜湊函式,每個元素跟k個bit對應。從而降低了衝突的概率。

在這裡插入圖片描述

優點

  1. 二進位制組成的陣列,記憶體佔用空間少,並且插入和查詢速度很快,常數級別。
  2. Hash函式相互之間沒有必然聯絡,方便由硬體並行實現。
  3. 只儲存0和1,不需要儲存元素本身,在某些對保密要求非常嚴格的場合有優勢。

缺點

  1. 存在誤差率。隨著存入的元素數量增加,誤算率隨之增加。(比如現實中你是否遇到正常郵件也被放入垃圾郵件目錄,正常簡訊被攔截)可以增加一個小的白名單,儲存那些可能被誤判的元素。
  2. 刪除困難。一個元素對映到bit陣列的k個位置上是1,刪除的時候不能簡單的直接置為0,可能會影響其他元素的判斷。因為其他元素的對映也有可能在相同的位置置為1。可以採用Counting Bloom Filter解決。

三 Redis實現

在Redis中,有一種資料結構叫點陣圖,即bitmap。以下是一些常用的操作命令。

在Redis命令中,SETBIT key offset value,此命令表示將key對應的值的二進位制陣列,從左向右起,offset下標的二進位制數字設定為value。

在這裡插入圖片描述

鍵k1對應的值為keke,對應ASCII碼為107 101 107 101,對應的二進位制為 0110 1011,0110 0101,0110 1011,0110 0101。將下標5的位置設定為1,所以變成 0110 1111,0110 0101,0110 1011,0110 0101。即 oeke。

GETBIT key offset命令,它用來獲取指定下標的值。

在這裡插入圖片描述

還有一個比較常用的命令,BITCOUNT key [start end],用來獲取點陣圖中指定範圍值為1的個數。注意,start和end指定的是位元組的個數,而不是位陣列下標。

在這裡插入圖片描述

Redisson是用於在Java程式中操作Redis的庫,利用Redisson我們可以在程式中輕鬆地使用Redis。Redisson這個客戶端工具實現了布隆過濾器,其底層就是通過bitmap這種資料結構來實現的。

Redis 4.0提供了外掛功能之後,Redis就提供了布隆過濾器功能。布隆過濾器作為一個外掛載入到了Redis Server之中,給Redis提供了強大的布隆去重功能。此文就不細講了,大家感興趣地可到官方檢視詳細文件介紹。它又如下常用命令:

  1. bf.add:新增元素
  2. bf.madd:批量新增元素
  3. bf.exists:檢索元素是否存在
  4. bf.mexists:檢索多個元素是否存在
  5. bf.reserve:自定義布隆過濾器,設定key,error_rate和initial_size

下面演示是在本地單節點Redis實現的,如果資料量很大,並且誤差率又很低的情況下,那單節點記憶體可能會不足。當然,在叢集Redis中,也是可以通過Redisson實現分散式布隆過濾器的。

引入依賴

<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

程式碼測試

package com.nobody;

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

/**
 * @Description
 * @Author Mr.nobody
 * @Date 2021/3/6
 * @Version 1.0
 */
public class RedissonDemo {

    public static void main(String[] args) {

        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        // config.useSingleServer().setPassword("123456");

        RedissonClient redissonClient = Redisson.create(config);
        // 獲取一個redis key為users的布隆過濾器
        RBloomFilter<Integer> bloomFilter = redissonClient.getBloomFilter("users");

        // 假設元素個數為10萬
        int size = 100000;

        // 進行初始化,預計元素為10萬,誤差率為1%
        bloomFilter.tryInit(size, 0.01);

        // 將1至100000這十萬個數對映到布隆過濾器中
        for (int i = 1; i <= size; i++) {
            bloomFilter.add(i);
        }

        // 檢查已在過濾器中的值,是否有匹配不上的
        for (int i = 1; i <= size; i++) {
            if (!bloomFilter.contains(i)) {
                System.out.println("存在不匹配的值:" + i);
            }
        }

        // 檢查不在過濾器中的1000個值,是否有匹配上的
        int matchCount = 0;
        for (int i = size + 1; i <= size + 1000; i++) {
            if (bloomFilter.contains(i)) {
                matchCount++;
            }
        }
        System.out.println("誤判個數:" + matchCount);
    }
}

結果存在的10萬個元素都匹配上了;不存在布隆過濾器中的1千個元素,有23個誤判。

誤判個數:23

四 Guava實現

布隆過濾器有許多實現與優化,Guava中就提供了一種實現。Google Guava提供的布隆過濾器的位陣列是儲存在JVM記憶體中,故是單機版的,並且最大位長為int型別的最大值。

  • 使用布隆過濾器時,重要關注點是預估資料量n以及期望的誤判率fpp。
  • 實現布隆過濾器時,重要關注點是hash函式的選取以及bit陣列的大小。

Bit陣列大小選擇

根據預估資料量n以及誤判率fpp,bit陣列大小的m的計算方式:

在這裡插入圖片描述

Guava中原始碼實現如下:

@VisibleForTesting
static long optimalNumOfBits(long n, double p) {
  if (p == 0) {
    p = Double.MIN_VALUE;
  }
  return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}

雜湊函式選擇

​雜湊函式的個數的選擇也是挺講究的,雜湊函式的選擇影響著效能的好壞,而且一個好的雜湊函式能近似等概率的將元素對映到各個Bit。如何選擇構造k個函式呢,一種簡單的方法是選擇一個雜湊函式,然後送入k個不同的引數。

雜湊函式的個數k,可以根據預估資料量n和bit陣列長度m計算而來:

在這裡插入圖片描述

Guava中原始碼實現如下:

@VisibleForTesting
  static int optimalNumOfHashFunctions(long n, long m) {
    // (m / n) * log(2), but avoid truncation due to division!
    return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
  }

引入依賴

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.2-jre</version>
</dependency>

程式碼測試

package com.nobody;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

/**
 * @Description
 * @Author Mr.nobody
 * @Date 2021/3/6
 * @Version 1.0
 */
public class GuavaDemo {

    public static void main(String[] args) {

        // 假設元素個數為10萬
        int size = 100000;

        // 預計元素為10萬,誤差率為1%
        BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.01);

        // 將1至100000這十萬個數對映到布隆過濾器中
        for (int i = 1; i <= size; i++) {
            bloomFilter.put(i);
        }

        // 檢查已在過濾器中的值,是否有匹配不上的
        for (int i = 1; i <= size; i++) {
            if (!bloomFilter.mightContain(i)) {
                System.out.println("存在不匹配的值:" + i);
            }
        }

        // 檢查不在過濾器中的1000個值,是否有匹配上的
        int matchCount = 0;
        for (int i = size + 1; i <= size + 1000; i++) {
            if (bloomFilter.mightContain(i)) {
                matchCount++;
            }
        }
        System.out.println("誤判個數:" + matchCount);

    }
}

結果存在的10萬個元素都匹配上了;不存在布隆過濾器中的1千個元素,有10個誤判。

誤判個數:10

當fpp的值改為為0.001,即降低誤差率時,誤判個數為0個。

誤判個數:0

分析結果可知,誤判率確實跟我們傳入的容錯率差不多,而且在布隆過濾器中的元素都匹配到了。

原始碼分析

通過debug建立布隆過濾器的方法,當預計元素為10萬個,fpp的值為0.01時,需要位數958505個,hash函式個數為7個。

當預計元素為10萬個,fpp的值為0.001時,需要位數1437758個,hash函式個數為10個。

得出結論

  • 容錯率越大,所需空間和時間越小,容錯率越小,所需空間和時間越大。
  • 理論上存10萬個數,一個int是4位元組,即32位,需要320萬位。如果使用HashMap儲存,按HashMap50%的儲存效率,需要640萬位。而布隆過濾器即使容錯率fpp為0.001,也才需要1437758位,可以看出BloomFilter的儲存空間很小。

五 擴充套件知識點

假如有一臺伺服器,記憶體只有4GB,磁碟上有2個大檔案,檔案A儲存100億個URL,檔案B儲存100億個URL。請問如何模糊找出兩個檔案的URL交集?如何精緻找出兩個檔案的URL交集。

模糊交集:

藉助布隆過濾器思想,先將一個檔案的URL通過hash函式對映到bit陣列中,這樣大大減少了記憶體儲存,再讀取另一個檔案URL,去bit陣列中進行匹配。

精緻交集:

對大檔案進行hash拆分成小檔案,例如拆分成1000個小檔案(如果伺服器記憶體更小,則可以拆分更多個更小的檔案),比如檔案A拆分為A1,A2,A3...An,檔案B拆分為B1,B2,B3...Bn。而且通過相同的hash函式,相同的URL一定被對映到相同下標的小檔案中,例如A檔案的www.baidu.com被對映到A1中,那B檔案的www.baidu.com也一定被對映到B1檔案中。最後再通過求相同下標的小檔案(例如A1和B1)(A2和B2)的交集即可。


歡迎關注微信公眾號:「Java之言」技術文章持續更新,請持續關注......

  • 第一時間學習最新技術文章
  • 領取最新技術學習資料視訊
  • 最新網際網路資訊和麵試經驗

相關文章