1. 程式人生 > >布隆過濾---判斷一個元素在億級資料中是否存在

布隆過濾---判斷一個元素在億級資料中是否存在

如何判斷一個元素在億級資料中是否存在?

https://www.cnblogs.com/crossoverJie/p/10018231.html

前言
最近有朋友問我這麼一個面試題目:

現在有一個非常龐大的資料,假設全是 int 型別。現在我給你一個數,你需要告訴我它是否存在其中(儘量高效)。

需求其實很清晰,只是要判斷一個數據是否存在即可。

但這裡有一個比較重要的前提:非常龐大的資料。

常規實現
先不考慮這個條件,我們腦海中出現的第一種方案是什麼?

我想大多數想到的都是用 HashMap 來存放資料,因為它的寫入查詢的效率都比較高。

寫入和判斷元素是否存在都有對應的 API,所以實現起來也比較簡單。

為此我寫了一個單測,利用 HashSet 來存資料(底層也是 HashMap );同時為了後面的對比將堆記憶體寫死:

-Xms64m -Xmx64m -XX:+PrintHeapAtGC -XX:+HeapDumpOnOutOfMemoryError
為了方便除錯加入了 GC 日誌的列印,以及記憶體溢位後 Dump 記憶體。

@Test
public void hashMapTest(){
    long star = System.currentTimeMillis();

    Set<Integer> hashset = new HashSet<>(100) ;
    for (int i = 0; i < 100; i++) {
        hashset.add(i) ;
    }
    Assert.assertTrue(hashset.contains(1));
    Assert.assertTrue(hashset.contains(2));
    Assert.assertTrue(hashset.contains(3));

    long end = System.currentTimeMillis();
    System.out.println("執行時間:" + (end - star));
}

當我只寫入 100 條資料時自然是沒有問題的。

還是在這個基礎上,寫入 1000W 資料試試:

執行後馬上就記憶體溢位。

可見在記憶體有限的情況下我們不能使用這種方式。

實際情況也是如此;既然要判斷一個數據是否存在於集合中,考慮的演算法的效率以及準確性肯定是要把資料全部 load 到記憶體中的。

Bloom Filter
基於上面分析的條件,要實現這個需求最需要解決的是如何將龐大的資料 load 到記憶體中。

而我們是否可以換種思路,因為只是需要判斷資料是否存在,也不是需要把資料查詢出來,所以完全沒有必要將真正的資料存放進去。

偉大的科學家們已經幫我們想到了這樣的需求。

Burton Howard Bloom 在 1970 年提出了一個叫做 Bloom Filter(中文翻譯:布隆過濾)的演算法。

它主要就是用於解決判斷一個元素是否在一個集合中,但它的優勢是隻需要佔用很小的記憶體空間以及有著高效的查詢效率。

所以在這個場景下在合適不過了。

Bloom Filter 原理
下面來分析下它的實現原理。

官方的說法是:它是一個儲存了很長的二級制向量,同時結合 Hash 函式實現的。

聽起來比較繞,但是通過一個圖就比較容易理解了。

如圖所示:

首先需要初始化一個二進位制的陣列,長度設為 L(圖中為 8),同時初始值全為 0 。
當寫入一個 A1=1000 的資料時,需要進行 H 次 hash 函式的運算(這裡為 2 次);與 HashMap 有點類似,通過算出的 HashCode 與 L 取模後定位到 0、2 處,將該處的值設為 1。
A2=2000 也是同理計算後將 4、7 位置設為 1。
當有一個 B1=1000 需要判斷是否存在時,也是做兩次 Hash 運算,定位到 0、2 處,此時他們的值都為 1 ,所以認為 B1=1000 存在於集合中。
當有一個 B2=3000 時,也是同理。第一次 Hash 定位到 index=4 時,陣列中的值為 1,所以再進行第二次 Hash 運算,結果定位到 index=5 的值為 0,所以認為 B2=3000 不存在於集合中。
整個的寫入、查詢的流程就是這樣,彙總起來就是:

對寫入的資料做 H 次 hash 運算定位到陣列中的位置,同時將資料改為 1 。當有資料查詢時也是同樣的方式定位到陣列中。
一旦其中的有一位為 0 則認為資料肯定不存在於集合,否則資料可能存在於集合中。

所以布隆過濾有以下幾個特點:

只要返回資料不存在,則肯定不存在。
返回資料存在,但只能是大概率存在。
同時不能清除其中的資料。
第一點應該都能理解,重點解釋下 2、3 點。

為什麼返回存在的資料卻是可能存在呢,這其實也和 HashMap 類似。

在有限的陣列長度中存放大量的資料,即便是再完美的 Hash 演算法也會有衝突,所以有可能兩個完全不同的 A、B 兩個資料最後定位到的位置是一模一樣的。

這時拿 B 進行查詢時那自然就是誤報了。

刪除資料也是同理,當我把 B 的資料刪除時,其實也相當於是把 A 的資料刪掉了,這樣也會造成後續的誤報。

基於以上的 Hash 衝突的前提,所以 Bloom Filter 有一定的誤報率,這個誤報率和 Hash 演算法的次數 H,以及陣列長度 L 都是有關的。

自己實現一個布隆過濾
演算法其實很簡單不難理解,於是利用 Java 實現了一個簡單的雛形。

public class BloomFilters {

/**
 * 陣列長度
 */
private int arraySize;

/**
 * 陣列
 */
private int[] array;

public BloomFilters(int arraySize) {
    this.arraySize = arraySize;
    array = new int[arraySize];
}

/**
 * 寫入資料
 * @param key
 */
public void add(String key) {
    int first = hashcode_1(key);
    int second = hashcode_2(key);
    int third = hashcode_3(key);

    array[first % arraySize] = 1;
    array[second % arraySize] = 1;
    array[third % arraySize] = 1;

}

/**
 * 判斷資料是否存在
 * @param key
 * @return
 */
public boolean check(String key) {
    int first = hashcode_1(key);
    int second = hashcode_2(key);
    int third = hashcode_3(key);

    int firstIndex = array[first % arraySize];
    if (firstIndex == 0) {
        return false;
    }

    int secondIndex = array[second % arraySize];
    if (secondIndex == 0) {
        return false;
    }

    int thirdIndex = array[third % arraySize];
    if (thirdIndex == 0) {
        return false;
    }

    return true;

}


/**
 * hash 演算法1
 * @param key
 * @return
 */
private int hashcode_1(String key) {
    int hash = 0;
    int i;
    for (i = 0; i < key.length(); ++i) {
        hash = 33 * hash + key.charAt(i);
    }
    return Math.abs(hash);
}

/**
 * hash 演算法2
 * @param data
 * @return
 */
private int hashcode_2(String data) {
    final int p = 16777619;
    int hash = (int) 2166136261L;
    for (int i = 0; i < data.length(); i++) {
        hash = (hash ^ data.charAt(i)) * p;
    }
    hash += hash << 13;
    hash ^= hash >> 7;
    hash += hash << 3;
    hash ^= hash >> 17;
    hash += hash << 5;
    return Math.abs(hash);
}

/**
 *  hash 演算法3
 * @param key
 * @return
 */
private int hashcode_3(String key) {
    int hash, i;
    for (hash = 0, i = 0; i < key.length(); ++i) {
        hash += key.charAt(i);
        hash += (hash << 10);
        hash ^= (hash >> 6);
    }
    hash += (hash << 3);
    hash ^= (hash >> 11);
    hash += (hash << 15);
    return Math.abs(hash);
}

}
首先初始化了一個 int 陣列。
寫入資料的時候進行三次 hash 運算,同時把對應的位置置為 1。
查詢時同樣的三次 hash 運算,取到對應的值,一旦值為 0 ,則認為資料不存在。
實現邏輯其實就和上文描述的一樣。

下面來測試一下,同樣的引數:

-Xms64m -Xmx64m -XX:+PrintHeapAtGC
@Test
public void bloomFilterTest(){
long star = System.currentTimeMillis();
BloomFilters bloomFilters = new BloomFilters(10000000) ;
for (int i = 0; i < 10000000; i++) {
bloomFilters.add(i + "") ;
}
Assert.assertTrue(bloomFilters.check(1+""));
Assert.assertTrue(bloomFilters.check(2+""));
Assert.assertTrue(bloomFilters.check(3+""));
Assert.assertTrue(bloomFilters.check(999999+""));
Assert.assertFalse(bloomFilters.check(400230340+""));
long end = System.currentTimeMillis();
System.out.println("執行時間:" + (end - star));
}
執行結果如下:

只花了 3 秒鐘就寫入了 1000W 的資料同時做出來準確的判斷。

當讓我把陣列長度縮小到了 100W 時就出現了一個誤報,400230340 這個數明明沒在集合裡,卻返回了存在。

這也體現了 Bloom Filter 的誤報率。

我們提高陣列長度以及 hash 計算次數可以降低誤報率,但相應的 CPU、記憶體的消耗就會提高;這就需要根據業務需要自行權衡。

Guava 實現

剛才的方式雖然實現了功能,也滿足了大量資料。但其實觀察 GC 日誌非常頻繁,同時老年代也使用了 90%,接近崩潰的邊緣。

總的來說就是記憶體利用率做的不好。

其實 Google Guava 庫中也實現了該演算法,下面來看看業界權威的實現。

-Xms64m -Xmx64m -XX:+PrintHeapAtGC
@Test
public void guavaTest() {
long star = System.currentTimeMillis();
BloomFilter filter = BloomFilter.create(
Funnels.integerFunnel(),
10000000,
0.01);

    for (int i = 0; i < 10000000; i++) {
        filter.put(i);
    }

    Assert.assertTrue(filter.mightContain(1));
    Assert.assertTrue(filter.mightContain(2));
    Assert.assertTrue(filter.mightContain(3));
    Assert.assertFalse(filter.mightContain(10000000));
    long end = System.currentTimeMillis();
    System.out.println("執行時間:" + (end - star));
}

也是同樣寫入了 1000W 的資料,執行沒有問題。

觀察 GC 日誌會發現沒有一次 fullGC,同時老年代的使用率很低。和剛才的一對比這裡明顯的要好上很多,也可以寫入更多的資料。

原始碼分析
那就來看看 Guava 它是如何實現的。

構造方法中有兩個比較重要的引數,一個是預計存放多少資料,一個是可以接受的誤報率。
我這裡的測試 demo 分別是 1000W 以及 0.01。

Guava 會通過你預計的數量以及誤報率幫你計算出你應當會使用的陣列大小 numBits 以及需要計算幾次 Hash 函式 numHashFunctions 。

這個演算法計算規則可以參考維基百科。

put 寫入函式
真正存放資料的 put 函式如下:

根據 murmur3_128 方法的到一個 128 位長度的 byte[]。
分別取高低 8 位的到兩個 hash 值。
再根據初始化時的到的執行 hash 的次數進行 hash 運算。
bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);
其實也是 hash取模拿到 index 後去賦值 1.

重點是 bits.set() 方法。

其實 set 方法是 BitArray 中的一個函式,BitArray 就是真正存放資料的底層資料結構。

利用了一個 long[] data 來存放資料。

所以 set() 時候也是對這個 data 做處理。

在 set 之前先通過 get() 判斷這個資料是否存在於集合中,如果已經存在則直接返回告知客戶端寫入失敗。
接下來就是通過位運算進行位或賦值。
get() 方法的計算邏輯和 set 類似,只要判斷為 0 就直接返回存在該值。
mightContain 是否存在函式

前面幾步的邏輯都是類似的,只是呼叫了剛才的 get() 方法判斷元素是否存在而已。

總結
布隆過濾的應用還是蠻多的,比如資料庫、爬蟲、防快取擊穿等。

特別是需要精確知道某個資料不存在時做點什麼事情就非常適合布隆過濾。

這段時間的研究發現演算法也挺有意思的,後續應該會繼續分享一些類似的內容。

如果對你有幫助那就分享一下吧。

本問的示例程式碼參考這裡:

https://github.com/crossoverJie/JCSprout

你的點贊與分享是對我最大的支援