Guava的布隆過濾器

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

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

你是否真的存在

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

 咱們以網絡爬蟲爲例。網絡間的連接錯綜複雜,爬蟲程序在網絡間「爬行」極可能會造成「環」。爲了不造成「環」,程序須要知道已經訪問過網站的URL。當程序又遇到一個網站,根據它的URL,怎麼判斷是否已經訪問過呢?算法

 第一個想法就是將已有URL放置在HashSet中,而後利用HashSet的特性進行判斷。它只花費O(1)的時間。可是,該方法消耗的內存空間很大,就算只有1億個URL,每一個URL只算50個字符,就須要大約5GB內存。sql

 如何減小內存佔用呢?URL可能太長,咱們使用MD5等單向哈希處理後再存到HashSet中吧,處理後的字段只有128Bit,這樣能夠節省大量的空間。咱們的網絡爬蟲程序又能夠繼續執行了。數組

 可是好景不長,網絡世界浩瀚如海,URL的數量急速增長,以128bit的大小進行存儲也要佔據大量的內存。緩存

 這種狀況下,咱們還可使用BitSet,使用哈希函數將URL處理爲1bit,存儲在BitSet中。可是,哈希函數發生衝突的機率比較高,若要下降衝突機率到1%,就要將BitSet的長度設置爲URL個數的100倍。bash

 可是衝突沒法避免,這就帶來了誤判。理想中的算法老是又準確又快捷,可是現實中每每是「一地雞毛」。咱們真的須要100%的正確率嗎?若是須要,時間和空間的開銷沒法避免;若是可以忍受低機率的錯誤,就有極大地下降時間和空間的開銷的方法。網絡

 因此,一切都要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

參考

相關文章
相關標籤/搜索