冷飯新炒:理解布隆過濾器演算法的實現原理

throwable發表於2021-03-06

前提

這是《冷飯新炒》系列的第六篇文章。

本文會翻炒一個用途比較廣的演算法 - 布隆過濾器演算法

布隆過濾器的一些概念

主要包括:

  • 簡介
  • 演算法
  • 引數
  • 優勢和劣勢

布隆過濾器簡介

布隆過濾器是一種空間高效概率性的資料結構(百科中原文是a space-efficient probabilistic data structure),該資料結構於1970年由Burton Howard Bloom提出,作用是測試一個元素是否某個集合的一個成員。布隆過濾器是可能出現false positive(這個是專有名詞"假陽性",可以理解為誤判的情況,下文如果用到這個名詞會保留英文單詞使用)匹配的,換言之,布隆過濾器在使用的時候有可能返回結果"可能存在於集合中"或者"必定不存在於集合中"。

布隆過濾器演算法描述

在場景複雜的網路爬蟲中,爬取到的網頁URL依賴有可能成環,例如在URL-1頁面中展示了URL-2,然後又在URL-2中的頁面展示了URL-1,這個時候需要一種方案記錄和判斷歷史訪問過的URL。這個時候可能會想到下面的方案:

  • 方案一:使用資料庫儲存已經訪問過的URL,例如MySQL表中基於URL建立唯一索引或者使用RedisSET資料型別
  • 方案二:使用HashSet(其實這裡不侷限於HashSet,連結串列、樹和雜湊表等資料結構都能滿足)儲存已經訪問過的URL
  • 方案三:基於方案一和方案二進行優化,儲存URL的摘要,使用摘要演算法如MD5SHA-n演算法針對URL字串生成摘要
  • 方案四:使用Hash函式處理對應的URL生成一個雜湊碼,再把雜湊碼通過一個對映函式對映到一個固定容量的BitSet中的某一個位元

對於方案一、方案二和方案三,在歷史訪問URL資料量極大的情況下,會消耗巨大的儲存空間(磁碟或者記憶體),對於方案四,如果URL100億個,那麼要把衝突機率降低到1%,那麼BitSet的容量需要設定為10000億。

所以上面的四種方案都有明顯的不足之處,而布隆過濾器演算法的基本思路跟方案四差不多,最大的不同點就是方案四中只提到使用了一個雜湊函式,而布隆過濾器中使用了kk >= 1)個相互獨立的高效低衝突的雜湊函式。

一個初始化的布隆過濾器是一個所有位元都設定為0的長度為m的位元陣列,也就是認知中的Bit ArrayBit Set或者Redis中的Bit Map概念。然後需要引入k個不同的雜湊函式,某個新增元素通過這k個雜湊函式處理之後,對映到位元陣列m個位元中的k個,並且把這些命中對映的k個位元位設定為1,產生一個均勻的隨機分佈。通常情況下,k的一個較小的常數,取決於所需的誤判率,而布隆過濾器容量m與雜湊函式個數k和需要新增元素數量呈正相關。

當需要新增的所有元素都新增到布隆過濾器之後,那麼位元陣列中的很多位元都被設定為1。這個時候如果需要判斷一個元素是否存在於布隆過濾器中,只需要通過k個雜湊函式處理得到位元陣列的k個下標,然後判斷位元陣列對應的下標所在位元是否為1。如果這k個下標所在位元中至少存在一個0,那麼這個需要判斷的元素必定不在布隆過濾器代表的集合中;如果這k個下標所在位元全部都為1,那麼那麼這個需要判斷的元素可能存在於布隆過濾器代表的集合中或者剛好是一個False Positive,至於誤差率分析見下文的布隆過濾器的相關引數一節。False Positive出現的情況可以見下圖:

當新增到布隆過濾器的元素數量比較大,並且布隆過濾器的容量設定不合理(過小),容易出現多個元素通過k個雜湊函式,對映到相同的k個位(如上圖的下標139所在的位),這個時候就無法準確判斷這k個位由具體那個元素對映而來。其實可以極端一點思考:假設布隆過濾器容量為24,雜湊函式只有一個,那麼新增最多25個不同元素,必定有兩個不同的元素的對映結果落在同一個位。

布隆過濾器的相關引數

在演算法描述一節已經提到過,布隆過濾器主要有下面的引數:

  • 初始化位元陣列容量m
  • 雜湊函式個數k
  • 誤判率ε(數學符號Epsilon,代表False Positive Rate
  • 需要新增到布隆過濾器的元素數量n

考慮到篇幅原因,這裡不做這幾個值的關係推導,直接整理出結果和關係式。

  • 誤判率ε的估算值為:[1 - e^(-kn/m)]^k
  • 最優雜湊函式數量k的推算值:對於給定的mn,當k = m/n * ln2的時候,誤判率ε最低
  • 推算初始化位元容量m的值,當k = m/n * ln2的時候,m >= n * log2(e) * log2(1/ε)

這裡貼一個參考資料中m/nkFalse Positive Rate之間的關係圖:

這裡可以推算一下表格中最大引數所需要的空間極限,假設n10億,m/n = 32,那麼m320億,而k24,此時的誤判率為2.17e-07(0.000000217),需要空間3814.69727m。一般規律是:

  • k固定的時候,m/n越大,誤判率越小
  • m/n固定的時候,k越大,誤判率越大

通常情況下,k需要固定,而n是無法確定準確值,最好要評估增長趨勢預先計算一個比較大的m值去降低誤判率,當然也要權衡m值過大導致空間消耗過大的問題。

既然引數的關係式都已經有推導結果,可以基於關係式寫一個引數生成器:

import java.math.BigDecimal;
import java.math.RoundingMode;

public class BloomFilterParamGenerator {

    public BigDecimal falsePositiveRate(int m, int n, int k) {
        double temp = Math.pow(1 - Math.exp(Math.floorDiv(-k * n, m)), k);
        return BigDecimal.valueOf(temp).setScale(10, RoundingMode.FLOOR);
    }

    public BigDecimal kForMinFalsePositiveRate(int m, int n) {
        BigDecimal k = BigDecimal.valueOf(Math.floorDiv(m, n) * Math.log(2));
        return k.setScale(10, RoundingMode.FLOOR);
    }

    public BigDecimal bestM(int n, double falsePositiveRate) {
        double temp = log2(Math.exp(1) + Math.floor(1 / falsePositiveRate));
        return BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(temp)).setScale(10, RoundingMode.FLOOR);
    }

    public double log2(double x) {
        return Math.log(x) / Math.log(2);
    }

    public static void main(String[] args) {
        BloomFilterParamGenerator generator = new BloomFilterParamGenerator();
        System.out.println(generator.falsePositiveRate(2, 1, 2));  // 0.3995764008
        System.out.println(generator.kForMinFalsePositiveRate(32, 1)); // 22.1807097779
        System.out.println(generator.bestM(1, 0.3995764009)); // 2.2382615950
    }
}

這裡的計算沒有考慮嚴格的進位和截斷,所以和實際的結果可能有偏差,只提供一個參考的例子。

布隆過濾器的優勢和劣勢

布隆過濾器的優勢:

  • 布隆過濾器相對於其他資料結構在時空上有巨大優勢,佔用記憶體少,查詢和插入元素的時間複雜度都是O(k)
  • 可以準確判斷元素不存在於布隆過濾器中的場景
  • 雜湊函式可以獨立設計
  • 布隆過濾器不需要儲存元素本身,適用於某些資料敏感和資料嚴格保密的場景

布隆過濾器的劣勢:

  • 不能準確判斷元素必定存在於布隆過濾器中的場景,存在誤判率,在km固定的情況下,新增的元素越多,誤判率越高
  • 沒有儲存全量的元素,對於一些準確查詢或者準確統計的場景不適用
  • 原生的布隆過濾器無法安全地刪除元素

這裡留一個很簡單的問題給讀者:為什麼原生的布隆過濾器無法安全地刪除元素?(可以翻看之前的False Positive介紹)

布隆過濾器演算法實現

著名的Java工具類庫Guava中自帶了一個beta版本的布隆過濾器實現,這裡參考其中的原始碼實現思路和上文中的演算法描述進行一次布隆過濾器的實現。先考慮設計雜湊函式,簡單一點的方式就是參考JavaBeanhashCode()方法的設計:

// 下面的方法來源於java.util.Arrays#hashCode
public static int hashCode(Object a[]) {
    if (a == null)
        return 0;
    int result = 1;
    for (Object element : a)
        result = 31 * result + (element == null ? 0 : element.hashCode());
    return result;
}

上面方法的31可以作為一個輸入的seed,每個雜湊函式設計一個獨立的seed,並且這個seed值選用素數基於字串中的每個char進行迭加就能實現計算出來的結果是相對獨立的:

import java.util.Objects;

public class HashFunction {

    /**
     * 布隆過濾器容量
     */
    private final int m;

    /**
     * 種子
     */
    private final int seed;

    public HashFunction(int m, int seed) {
        this.m = m;
        this.seed = seed;
    }

    public int hash(String element) {
        if (Objects.isNull(element)) {
            return 0;
        }
        int result = 1;
        int len = element.length();
        for (int i = 0; i < len; i++) {
            result = seed * result + element.charAt(i);
        }
        // 這裡確保計算出來的結果不會超過m
        return (m - 1) & result;
    }
}

接著實現布隆過濾器:

public class BloomFilter {

    private static final int[] K_SEED_ARRAY = {5, 7, 11, 13, 31, 37, 61, 67};

    private static final int MAX_K = K_SEED_ARRAY.length;

    private final int m;

    private final int k;

    private final BitSet bitSet;

    private final HashFunction[] hashFunctions;

    public BloomFilter(int m, int k) {
        this.k = k;
        if (k <= 0 && k > MAX_K) {
            throw new IllegalArgumentException("k = " + k);
        }
        this.m = m;
        this.bitSet = new BitSet(m);
        hashFunctions = new HashFunction[k];
        for (int i = 0; i < k; i++) {
            hashFunctions[i] = new HashFunction(m, K_SEED_ARRAY[i]);
        }
    }

    public void addElement(String element) {
        for (HashFunction hashFunction : hashFunctions) {
            bitSet.set(hashFunction.hash(element), true);
        }
    }

    public boolean contains(String element) {
        if (Objects.isNull(element)) {
            return false;
        }
        boolean result = true;
        for (HashFunction hashFunction : hashFunctions) {
            result = result && bitSet.get(hashFunction.hash(element));
        }
        return result;
    }

    public int m() {
        return m;
    }

    public int k() {
        return k;
    }

    public static void main(String[] args) {
        BloomFilter bf = new BloomFilter(24, 3);
        bf.addElement("throwable");
        bf.addElement("throwx");
        System.out.println(bf.contains("throwable"));  // true
    }
}

這裡的雜湊演算法和有限的k值不足以應對複雜的場景,僅僅為了說明如何實現布隆過濾器,總的來說,原生布隆過濾器演算法是比較簡單的。對於一些複雜的生產場景,可以使用一些現成的類庫如Guava中的布隆過濾器APIRedis中的布隆過濾器外掛或者RedissonRedis高階客戶端)中的布隆過濾器API

布隆過濾器應用

主要包括:

  • Guava中的API
  • Redisson中的API
  • 使用場景

使用Guava中的布隆過濾器API

引入Guava的依賴:

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

使用布隆過濾器:

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

import java.nio.charset.StandardCharsets;

public class GuavaBloomFilter {

    @SuppressWarnings("UnstableApiUsage")
    public static void main(String[] args) {
        BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.US_ASCII), 10000, 0.0444D);
        bloomFilter.put("throwable");
        bloomFilter.put("throwx");
        System.out.println(bloomFilter.mightContain("throwable"));
        System.out.println(bloomFilter.mightContain("throwx"));
    }
}

構造BloomFilter的最多引數的靜態工廠方法是BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions, double fpp, BloomFilter.Strategy strategy),引數如下:

  • funnel:主要是把任意型別的資料轉化成HashCode,是一個頂層介面,有大量內建實現,見Funnels
  • expectedInsertions:期望插入的元素個數
  • fpp:猜測是False Positive Percent,誤判率,小數而非百分數,預設值0.03
  • strategy:對映策略,目前只有MURMUR128_MITZ_32MURMUR128_MITZ_64(預設策略)

引數可以參照上面的表格或者引數生成器的指導,基於實際場景進行定製。

使用Redisson中的布隆過濾器API

高階Redis客戶端Redisson已經基於Redisbitmap資料結構做了封裝,遮蔽了複雜的實現邏輯,可以開箱即用。引入Redisson的依賴:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.1</version>
</dependency>

使用Redisson中的布隆過濾器API

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

public class RedissonBloomFilter {

    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379");
        RedissonClient redissonClient = Redisson.create(config);
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("ipBlockList");
        // 第一個引數expectedInsertions代表期望插入的元素個數,第二個引數falseProbability代表期望的誤判率,小數表示
        bloomFilter.tryInit(100000L, 0.03D);
        bloomFilter.add("127.0.0.1");
        bloomFilter.add("192.168.1.1");
        System.out.println(bloomFilter.contains("192.168.1.1")); // true
        System.out.println(bloomFilter.contains("192.168.1.2")); // false
    }
}

Redisson提供的布隆過濾器介面RBloomFilter很簡單:

常用的方法有tryInit()(初始化)、add()(新增元素)和contains()(判斷元素是否存在)。相對於Guava的記憶體態的布隆過濾器實現,Redisson提供了基於Redis實現的分散式布隆過濾器,可以滿足分散式叢集中布隆過濾器的使用。

布隆過濾器使用場景

其實布隆過濾器的使用場景可以用百科中的一張示意圖來描述:

基於上圖具體化的一些場景列舉如下:

  • 網站爬蟲應用中進行URL去重(不存在於布隆過濾器中的URL必定是未爬取過的URL
  • 防火牆應用中IP黑名單判斷(不侷限於IP黑名單,通用的黑名單判斷場景基本都可以使用布隆過濾器,不存在於布隆過濾器中的IP必定是白名單)
  • 用於規避快取穿透(不存在於布隆過濾器中的KEY必定不存在於後置的快取中)

布隆過濾器變體

布隆過濾器的變體十分多,主要是為了解決布隆過濾器演算法中的一些缺陷或者劣勢。常見的變體如下:

變體名稱 變體描述
Counting Bloom Filter 把原生布隆過濾器每個位替換成一個小的計數器(Counter),所謂計數器其實就是一個小的整數
Compressed Bloom Filter 對位陣列進行壓縮
Hierarchical Bloom Filters 分層,由多層布隆過濾器組成
Spectral Bloom Filters CBF的擴充套件,提供查詢集合元素的出現頻率功能
Bloomier Filters 儲存函式值,不僅僅是做位對映
Time-Decaying Bloom Filters 計數器陣列替換位向量,優化每個計數器儲存其值所需的最小空間
Space Code Bloom Filter -
Filter Banks -
Scalable Bloom filters -
Split Bloom Filters -
Retouched Bloom filters -
Generalized Bloom Filters -
Distance-sensitive Bloom filters -
Data Popularity Conscious Bloom Filters -
Memory-optimized Bloom Filter -
Weighted Bloom filter -
Secure Bloom filters -

這裡挑選Counting Bloom Filter(簡稱CBF)變體稍微展開一下。原生布隆過濾器的基礎資料結構是位向量,CBF擴充套件原生布隆過濾器的基礎資料結構,底層陣列的每個元素使用4位大小的計數器儲存新增元素到陣列某個下標時候對映成功的頻次,在插入新元素的時候,通過k個雜湊函式對映到k個具體計數器,這些命中的計數器值增加1;刪除元素的時候,通過k個雜湊函式對映到k個具體計數器,這些計數器值減少1。使用CBF判斷元素是否在集合中的時候:

  • 某個元素通過k個雜湊函式對映到k個具體計數器,所有計數器的值都為0,那麼元素必定不在集合中
  • 某個元素通過k個雜湊函式對映到k個具體計數器,至少有1個計數器的值大於0,那麼元素可能在集合中

小結

一句話簡單概括布隆過濾器的基本功能:不存在則必不存在,存在則不一定存在。

在使用布隆過濾器判斷一個元素是否屬於某個集合時,會有一定的誤判率。也就是有可能把不屬於某個集合的元素誤判為屬於這個集合,這種錯誤稱為False Positive,但不會把屬於某個集合的元素誤判為不屬於這個集合(相對於False Positive,"假陽性",如果屬於某個集合的元素誤判為不屬於這個集合的情況稱為False Negative,"假陰性")。False Positive,也就是錯誤率或者誤判率這個因素的引入,是布隆過濾器在設計上權衡空間效率的關鍵。

參考資料:

(本文完 c-1-w e-a-20210306)

相關文章