布隆過濾器詳解

原文鏈接:https://www.jianshu.com/p/2104d11ee0a2

什麼是布隆過濾器

本質上布隆過濾器是一種數據結構,比較巧妙的概率型數據結構(probabilistic data structure),特點是高效地插入和查詢,可以用來告訴你 “某樣東西一定不存在或者可能存在”。

相比於傳統的 List、Set、Map 等數據結構,它更高效、佔用空間更少,但是缺點是其返回的結果是概率性的,而不是確切的。

實現原理
HashMap 的問題
講述布隆過濾器的原理之前,我們先思考一下,通常你判斷某個元素是否存在用的是什麼?應該蠻多人回答 HashMap 吧,確實可以將值映射到 HashMap 的 Key,然後可以在 O(1) 的時間複雜度內返回結果,效率奇高。但是 HashMap 的實現也有缺點,例如存儲容量佔比高,考慮到負載因子的存在,通常空間是不能被用滿的,而一旦你的值很多例如上億的時候,那 HashMap 佔據的內存大小就變得很可觀了。

還比如說你的數據集存儲在遠程服務器上,本地服務接受輸入,而數據集非常大不可能一次性讀進內存構建 HashMap 的時候,也會存在問題。

布隆過濾器數據結構

BF是由一個長度爲m比特的位數組(bit array)與k個哈希函數(hash function)組成的數據結構。位數組均初始化爲0,所有哈希函數都可以分別把輸入數據儘量均勻地散列。可以認爲是這個這樣:
在這裏插入圖片描述如果我們要映射一個值到布隆過濾器中,我們需要使用多個不同的哈希函數生成多個哈希值,並對每個生成的哈希值指向的 bit 位置 1,例如針對值 “baidu” 和三個不同的哈希函數分別生成了哈希值 1、4、7,則上圖轉變爲:
在這裏插入圖片描述
Ok,我們現在再存一個值 “tencent”,如果哈希函數返回 3、4、8 的話,圖繼續變爲:
在這裏插入圖片描述
值得注意的是,4 這個 bit 位由於兩個值的哈希函數都返回了這個 bit 位,因此它被覆蓋了。現在我們如果想查詢 “dianping” 這個值是否存在,哈希函數返回了 1、5、8三個值,結果我們發現 5 這個 bit 位上的值爲 0,說明沒有任何一個值映射到這個 bit 位上,因此我們可以很確定地說 “dianping” 這個值不存在。而當我們需要查詢 “baidu” 這個值是否存在的話,那麼哈希函數必然會返回 1、4、7,然後我們檢查發現這三個 bit 位上的值均爲 1,那麼我們可以說 “baidu” 存在了麼?答案是不可以,只能是 “baidu” 這個值可能存在。

這是爲什麼呢?答案跟簡單,因爲隨着增加的值越來越多,被置爲 1 的 bit 位也會越來越多,這樣某個值 “taobao” 即使沒有被存儲過,但是萬一哈希函數返回的三個 bit 位都被其他值置位了 1 ,那麼程序還是會判斷 “taobao” 這個值存在。

優缺點與用途

BF的優點是顯而易見的:

  • 不需要存儲數據本身,只用比特表示,因此空間佔用相對於傳統方式有巨大的優勢,並且能夠保密數據;

  • 時間效率也較高,插入和查詢的時間複雜度均爲O(k); 哈希函數之間相互獨立,可以在硬件指令層面並行計算。

它的缺點也同樣明顯:

  • 存在假陽性的概率,不適用於任何要求100%準確率的情境;
  • 只能插入和查詢元素,不能刪除元素,這與產生假陽性的原因是相同的。我們可以簡單地想到通過計數(即將一個比特擴展爲計數值)來記錄元素數,但仍然無法保證刪除的元素一定在集合中。

不能刪除

目前我們知道布隆過濾器可以支持 add 和 isExist 操作,那麼 delete 操作可以麼,答案是不可以,例如上圖中的 bit 位 4 被兩個值共同覆蓋的話,一旦你刪除其中一個值例如 “tencent” 而將其置位 0,那麼下次判斷另一個值例如 “baidu” 是否存在的話,會直接返回 false,而實際上你並沒有刪除它。

如何解決這個問題,答案是計數刪除。但是計數刪除需要存儲一個數值,而不是原先的 bit 位,會增大佔用的內存大小。這樣的話,增加一個值就是將對應索引槽上存儲的值加一,刪除則是減一,判斷是否存在則是看值是否大於0。

如何選擇哈希函數個數和布隆過濾器長度

很顯然,過小的布隆過濾器很快所有的 bit 位均爲 1,那麼查詢任何值都會返回“可能存在”,起不到過濾的目的了。布隆過濾器的長度會直接影響誤報率,布隆過濾器越長其誤報率越小。

另外,哈希函數的個數也需要權衡,個數越多則布隆過濾器 bit 位置位 1 的速度越快,且布隆過濾器的效率越低;但是如果太少的話,那我們的誤報率會變高。

在這裏插入圖片描述

k 爲哈希函數個數,m 爲布隆過濾器長度,n 爲插入的元素個數,p 爲誤報率。
假陽性率的最終公式位:
在這裏插入圖片描述
所以,在哈希函數的個數k一定的情況下:

  • 位數組長度m越大,假陽性率越低;
  • 已插入元素的個數n越大,假陽性率越高。

哈希策略

在BloomFilterStrategies枚舉中定義了兩種哈希策略,都基於著名的MurmurHash算法,分別是MURMUR128_MITZ_32和MURMUR128_MITZ_64。前者是一個簡化版,所以我們來看看後者的實現方法。

MURMUR128_MITZ_64() {
    @Override
    public <T> boolean put(
        T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
      long bitSize = bits.bitSize();
      byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
      long hash1 = lowerEight(bytes);
      long hash2 = upperEight(bytes);

      boolean bitsChanged = false;
      long combinedHash = hash1;
      for (int i = 0; i < numHashFunctions; i++) {
        // Make the combined hash positive and indexable
        bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);
        combinedHash += hash2;
      }
      return bitsChanged;
    }

    @Override
    public <T> boolean mightContain(
        T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
      long bitSize = bits.bitSize();
      byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
      long hash1 = lowerEight(bytes);
      long hash2 = upperEight(bytes);

      long combinedHash = hash1;
      for (int i = 0; i < numHashFunctions; i++) {
        // Make the combined hash positive and indexable
        if (!bits.get((combinedHash & Long.MAX_VALUE) % bitSize)) {
          return false;
        }
        combinedHash += hash2;
      }
      return true;
    }

    private /* static */ long lowerEight(byte[] bytes) {
      return Longs.fromBytes(
          bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2], bytes[1], bytes[0]);
    }

    private /* static */ long upperEight(byte[] bytes) {
      return Longs.fromBytes(
          bytes[15], bytes[14], bytes[13], bytes[12], bytes[11], bytes[10], bytes[9], bytes[8]);
    }
  };

其中put()方法負責向布隆過濾器中插入元素,mightContain()方法負責判斷元素是否存在。以put()方法爲例講解一下流程吧。

  1. 使用MurmurHash算法對funnel的輸入數據進行散列,得到128bit(16B)的字節數組。
  2. 取低8字節作爲第一個哈希值hash1,取高8字節作爲第二個哈希值hash2。
  3. 進行k次循環,每次循環都用hash1與hash2的複合哈希做散列,然後對m取模,將位數組中的對應比特設爲1。

這裏需要注意兩點:

  • 在循環中實際上應用了雙重哈希(double hashing)的思想,即可以用兩個哈希函數來模擬k個,其中i爲步長:

在這裏插入圖片描述

  • 哈希值有可能爲負數,而負數是不能在位數組中定位的。所以哈希值需要與Long.MAX_VALUE做bitwise AND,直接將其最高位(符號位)置爲0,就變成正數了。

位數組具體實現

static final class LockFreeBitArray {
    private static final int LONG_ADDRESSABLE_BITS = 6;
    final AtomicLongArray data;
    private final LongAddable bitCount;

    LockFreeBitArray(long bits) {
      this(new long[Ints.checkedCast(LongMath.divide(bits, 64, RoundingMode.CEILING))]);
    }

    // Used by serialization
    LockFreeBitArray(long[] data) {
      checkArgument(data.length > 0, "data length is zero!");
      this.data = new AtomicLongArray(data);
      this.bitCount = LongAddables.create();
      long bitCount = 0;
      for (long value : data) {
        bitCount += Long.bitCount(value);
      }
      this.bitCount.add(bitCount);
    }

    /** Returns true if the bit changed value. */
    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;
      do {
        oldValue = data.get(longIndex);
        newValue = oldValue | mask;
        if (oldValue == newValue) {
          return false;
        }
      } while (!data.compareAndSet(longIndex, oldValue, newValue));

      // We turned the bit on, so increment bitCount.
      bitCount.increment();
      return true;
    }

    boolean get(long bitIndex) {
      return (data.get((int) (bitIndex >>> 6)) & (1L << bitIndex)) != 0;
    }
    // ....
}

看官應該能明白爲什麼它要叫做“LockFree”BitArray了,因爲它是採用原子類型AtomicLongArray作爲位數組的存儲的。另外還有一個Guava中特有的LongAddable類型的計數器,用來統計置爲1的比特數。作者:LittleMagic

大Value的拆分

Redis 因其支持 setbit 和 getbit 操作,且純內存性能高等特點,因此天然就可以作爲布隆過濾器來使用。但是布隆過濾器的不當使用極易產生大 Value,增加 Redis 阻塞風險,因此生成環境中建議對體積龐大的布隆過濾器進行拆分。
拆分的形式方法多種多樣,但是本質是不要將 Hash(Key) 之後的請求分散在多個節點的多個小 bitmap 上,而是應該拆分成多個小 bitmap 之後,對一個 Key 的所有哈希函數都落在這一個小 bitmap 上。

轉載自原文鏈接
本人在此基礎上又增加了一些概念等描述。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章