Guava的布隆過濾器

ztwindy發表於2019-04-28

 程式世界的演算法都要在時間,資源佔用甚至正確率等多種因素間進行平衡。同樣的問題,所屬的量級或場景不同,所用演算法也會不同,其中也會涉及很多的trade-off。

If there’s one rule in programming, it’s this: there will always be trade-offs.

你是否真的存在

 今天我們就來探討如何判斷一個值是否存在於已有的集合問題。這類問題在很多場景下都會遇到,比如說防止快取擊穿,爬蟲重複URL檢測,字典糾纏和CDN代理快取等。

Guava的布隆過濾器

 我們以網路爬蟲為例。網路間的連結錯綜複雜,爬蟲程式在網路間“爬行”很可能會形成“環”。為了避免形成“環”,程式需要知道已經訪問過網站的URL。當程式又遇到一個網站,根據它的URL,怎麼判斷是否已經訪問過呢?

 第一個想法就是將已有URL放置在HashSet中,然後利用HashSet的特性進行判斷。它只花費O(1)的時間。但是,該方法消耗的記憶體空間很大,就算只有1億個URL,每個URL只算50個字元,就需要大約5GB記憶體。

 如何減少記憶體佔用呢?URL可能太長,我們使用MD5等單向雜湊處理後再存到HashSet中吧,處理後的欄位只有128Bit,這樣可以節省大量的空間。我們的網路爬蟲程式又可以繼續執行了。

 但是好景不長,網路世界浩瀚如海,URL的數量急速增加,以128bit的大小進行儲存也要佔據大量的記憶體。

 這種情況下,我們還可以使用BitSet,使用雜湊函式將URL處理為1bit,儲存在BitSet中。但是,雜湊函式發生衝突的概率比較高,若要降低衝突概率到1%,就要將BitSet的長度設定為URL個數的100倍。

 但是衝突無法避免,這就帶來了誤判。理想中的演算法總是又準確又快捷,但是現實中往往是“一地雞毛”。我們真的需要100%的正確率嗎?如果需要,時間和空間的開銷無法避免;如果能夠忍受低概率的錯誤,就有極大地降低時間和空間的開銷的方法。

Guava的布隆過濾器

 所以,一切都要trade-off。布隆過濾器(Bloom Filter)就是一種具有較低錯誤率,但是極大節約空間消耗的演算法。

布隆過濾器

 Bloom Filter是一種空間效率很高的隨機資料結構,它利用位陣列很簡潔地表示一個集合,並能判斷一個元素是否屬於這個集合。Bloom Filter的這種高效是有一定代價的:在判斷一個元素是否屬於某個集合時,有可能會把不屬於這個集合的元素誤認為屬於這個集合(false positive)。因此,Bloom Filter不適合那些“零錯誤”的應用場合。而在能容忍低錯誤率的應用場合下,Bloom Filter通過極少的錯誤換取了儲存空間的極大節省。

A Bloom filter is a space-efficient probabilistic data structure, conceived by Burton Howard Bloom in 1970, that is used to test whether an element is a member of a set. False positive matches are possible, but false negatives are not, thus a Bloom filter has a 100% recall rate. In other words, a query returns either “possibly in set” or “definitely not in set”.

 上述描述引自維基百科,特點總結為如下:

  • 空間效率高的概率型資料結構,用來檢查一個元素是否在一個集合中。
  • 對於一個元素檢測是否存在的呼叫,BloomFilter會告訴呼叫者兩個結果之一:可能存在或者一定不存在。

 布隆過濾器的使用場景很多,除了上文說的網路爬蟲,還有處理快取擊穿和避免磁碟讀取等。Goole Bigtable,Apache HBase和Postgresql等都使用了布隆過濾器。

 我們就以下面這個例子具體描述使用BloomFilter的場景,以及在此場景下,BloomFilter的優勢和劣勢。

 一組元素存在於磁碟中,資料量特別大,應用程式希望在元素不存在的時候儘量不讀磁碟,此時,可以在記憶體中構建這些磁碟資料的BloomFilter,對於一次讀資料的情況,分為以下幾種情況:

image.png

 我們知道HashMap或者Set等資料結構也可以支援上述場景,這裡我們就具體比較一下二者的優劣,並給出具體的資料。

精確度量十分重要,對於演算法的效能,我們不能只是簡單的感官上比較,要進行具體的計算和效能測試。找到不同演算法之間的平衡點,根據平衡點和現實情況來決定使用哪種演算法。就像Redis一樣,它物件在不同情況下使用不同的資料結構,比如說列表物件的內建結構可以為ziplist或者linkedlist,在不同的場景下使用不同的資料結構。

 請求的元素不在磁碟中,如果BloomFilter返回不存在,那麼應用不需要走讀盤邏輯,假設此概率為P1。如果BloomFilter返回可能存在,那麼屬於誤判情況,假設此概率為P2。請求的元素在磁碟中,BloomFilter返回存在,假設此概率為P3。

 如果使用HashMap等資料結構,情況如下:

  • 請求的資料不在磁碟中,應用不走讀盤邏輯,此概率為P1+P2
  • 請求的元素在磁碟中,應用走讀盤邏輯,此概率為P3

 假設應用不讀盤邏輯的開銷為C1,走讀盤邏輯的開銷為C2,那麼,BloomFilter和hashmap的開銷分別為

  • Cost(BloomFilter) = P1 * C1 + (P2 + P3) * C2
  • Cost(HashMap) = (P1 + P2) * C1 + P3 * C2;
  • Delta = Cost(BloomFilter) - Cost(HashMap) = P2 * (C2 - C1)

 因此,BloomFilter相當於以增加P2 * (C2 - C1)的時間開銷,來獲得相對於HashMap而言更少的空間開銷。

 既然P2是影響BloomFilter效能開銷的主要因素,那麼BloomFilter設計時如何降低概率P2(即誤判率false positive probability)呢?,接下來的BloomFilter的原理將回答這個問題。

原理解析

 初始狀態下,布隆過濾器是一個包含m位的位陣列,每一位都置為0。

 為了表達S={x1, x2,…,xn}這樣一個n個元素的集合,Bloom Filter使用k個相互獨立的雜湊函式,它們分別將集合中的每個元素對映到{1,…,m}的範圍中。對任意一個元素x,第i個雜湊函式對映的位置hi(x)就會被置為1(1≤i≤k)。注意,如果一個位置多次被置為1,那麼只有第一次會起作用,後面幾次將沒有任何效果。在下圖中,k=3,且有兩個雜湊函式選中同一個位置(從左邊數第五位)。

image.png

 在判斷y是否屬於這個集合時,我們對y應用k次雜湊函式,如果所有hi(y)的位置都是1(1≤i≤k),那麼我們就認為y是集合中的元素,否則就認為y不是集合中的元素。下圖中y1就不是集合中的元素。y2則可能屬於這個集合,或者剛好是一個誤判。

image.png

 下面我們來看一下具體的例子,雜湊函式的數量為3,首先加入1,10兩個元素。通過下面兩個圖,我們可以清晰看到1,10兩個元素被三個不同的韓系函式對映到不同的bit上,然後判斷3是否在集合中,3對映的3個bit都沒有值,所以判斷絕對不在集合中。

示意圖-絕對不在

示意圖-可能在

 關於誤判率,實際的使用中,期望能給定一個誤判率期望和將要插入的元素數量,能計算出分配多少的儲存空間較合適。這涉及很多最優數值計算問題,比如說錯誤率估計,最優的雜湊函式個數和位陣列的大小等,相關公式計算感興趣的同學可以自行百度,重溫一下大學的計算微積分時光。

Guava的布隆過濾器

 這就又要提起我們的Guava了,它是Google開源的Java包,提供了很多常用的功能,比如說我們之前總結的超詳細的Guava RateLimiter限流原理解析

 Guava中,布隆過濾器的實現主要涉及到2個類,BloomFilterBloomFilterStrategies,首先來看一下BloomFilter的成員變數。需要注意的是不同Guava版本的BloomFilter實現不同。

 /** guava實現的以CAS方式設定每個bit位的bit陣列 */
  private final LockFreeBitArray bits;
  /** hash函式的個數 */
  private final int numHashFunctions;
  /** guava中將物件轉換為byte的通道 */
  private final Funnel<? super T> funnel;
  /**
   * 將byte轉換為n個bit的策略,也是bloomfilter hash對映的具體實現
   */
  private final Strategy strategy;
複製程式碼

 這是它的4個成員變數:

  • LockFreeBitArray是定義在BloomFilterStrategies中的內部類,封裝了布隆過濾器底層bit陣列的操作。
  • numHashFunctions表示雜湊函式的個數。
  • Funnel,它和PrimitiveSink配套使用,能將任意型別的物件轉化成Java基本資料型別,預設用java.nio.ByteBuffer實現,最終均轉化為byte陣列。
  • Strategy是定義在BloomFilter類內部的介面,程式碼如下,主要有2個方法,putmightContain
interface Strategy extends java.io.Serializable {
    /** 設定元素 */
    <T> boolean put(T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits);
    /** 判斷元素是否存在*/
    <T> boolean mightContain(
    T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits);
    .....
}
複製程式碼

 建立布隆過濾器,BloomFilter並沒有公有的建構函式,只有一個私有建構函式,而對外它提供了5個過載的create方法,在預設情況下誤判率設定為3%,採用BloomFilterStrategies.MURMUR128_MITZ_64的實現。

BloomFilterStrategies.MURMUR128_MITZ_64Strategy的兩個實現之一,Guava以列舉的方式提供這兩個實現,這也是《Effective Java》書中推薦的提供物件的方法之一。

enum BloomFilterStrategies implements BloomFilter.Strategy {
    MURMUR128_MITZ_32() {//....}
    MURMUR128_MITZ_64() {//....}
}
複製程式碼

 二者對應了32位雜湊對映函式,和64位雜湊對映函式,後者使用了murmur3 hash生成的所有128位,具有更大的空間,不過原理是相通的,我們選擇相對簡單的MURMUR128_MITZ_32來分析。

 先來看一下它的put方法,它用兩個hash函式來模擬多個hash函式的情況,這是布隆過濾器的一種優化。

public <T> boolean put(
    T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) {
    long bitSize = bits.bitSize();
    // 先利用murmur3 hash對輸入的funnel計算得到128位的雜湊值,funnel現將object轉換為byte陣列,
    // 然後在使用雜湊函式轉換為long
    long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
    // 根據hash值的高低位算出hash1和hash2
    int hash1 = (int) hash64;
    int hash2 = (int) (hash64 >>> 32);

    boolean bitsChanged = false;
    // 迴圈體內採用了2個函式模擬其他函式的思想,相當於每次累加hash2
    for (int i = 1; i <= numHashFunctions; i++) {
    int combinedHash = hash1 + (i * hash2);
    // 如果是負數就變為正數
    if (combinedHash < 0) {
        combinedHash = ~combinedHash;
    }
    // 通過基於bitSize取模的方式獲取bit陣列中的索引,然後呼叫set函式設定。
    bitsChanged |= bits.set(combinedHash % bitSize);
    }
    return bitsChanged;
}
複製程式碼

 在put方法中,先是將索引位置上的二進位制置為1,然後用bitsChanged記錄插入結果,如果返回true表明沒有重複插入成功,而mightContain方法則是將索引位置上的數值取出,並判斷是否為0,只要其中出現一個0,那麼立即判斷為不存在。

public <T> boolean mightContain(
    T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) {
    long bitSize = bits.bitSize();
    long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
    int hash1 = (int) hash64;
    int hash2 = (int) (hash64 >>> 32);

    for (int i = 1; i <= numHashFunctions; i++) {
    int combinedHash = hash1 + (i * hash2);
    // Flip all the bits if it's negative (guaranteed positive number)
    if (combinedHash < 0) {
        combinedHash = ~combinedHash;
    }
    // 和put的區別就在這裡,從set轉換為get,來判斷是否存在
    if (!bits.get(combinedHash % bitSize)) {
        return false;
    }
    }
    return true;
}
複製程式碼

Guava為了提供效率,自己實現了LockFreeBitArray來提供bit陣列的無鎖設定和讀取。我們只來看一下它的put函式。

boolean set(long bitIndex) {
    if (get(bitIndex)) {
    return false;
    }

    int longIndex = (int) (bitIndex >>> LONG_ADDRESSABLE_BITS);
    long mask = 1L << bitIndex; // only cares about low 6 bits of bitIndex

    long oldValue;
    long newValue;
    // 經典的CAS自旋重試機制
    do {
    oldValue = data.get(longIndex);
    newValue = oldValue | mask;
    if (oldValue == newValue) {
        return false;
    }
    } while (!data.compareAndSet(longIndex, oldValue, newValue));

    bitCount.increment();
    return true;
}
複製程式碼

後記

 歡迎大家留言和持續關注我。

image.png

參考

相關文章