1. 程式人生 > >大資料量下的集合過濾—Bloom Filter

大資料量下的集合過濾—Bloom Filter

演算法背景

如果想判斷一個元素是不是在一個集合裡,一般想到的是將集合中所有元素儲存起來,然後通過比較確定。連結串列散列表(又叫雜湊表,Hash table)等等資料結構都是這種思路,儲存位置要麼是磁碟,要麼是記憶體。很多時候要麼是以時間換空間,要麼是以空間換時間。

在響應時間要求比較嚴格的情況下,如果我們存在內裡,那麼隨著集合中元素的增加,我們需要的儲存空間越來越大,以及檢索的時間越來越長,導致記憶體開銷太大、時間效率變低。

此時需要考慮解決的問題就是,在資料量比較大的情況下,既滿足時間要求,又滿足空間的要求。即我們需要一個時間和空間消耗都比較小的資料結構和演算法。Bloom Filter就是一種解決方案。

Bloom Filter 概念

布隆過濾器英語:Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進位制向量和一系列隨機對映函式。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的演算法,缺點是有一定的誤識別率和刪除困難。

Bloom Filter 原理

布隆過濾器的原理是,當一個元素被加入集合時,通過K個雜湊函式將這個元素對映成一個位陣列中的K個點,把它們置為1。檢索時,我們只要看看這些點是不是都是1就(大約)知道集合中有沒有它了:如果這些點有任何一個0,則被檢元素一定不在;如果都是1,則被檢元素很可能在。這就是布隆過濾器的基本思想。

Bloom Filter跟單雜湊函式Bit-Map不同之處在於:Bloom Filter使用了k個雜湊函式,每個字串跟k個bit對應。從而降低了衝突的概率。

Bloom Filter的缺點

bloom filter之所以能做到在時間和空間上的效率比較高,是因為犧牲了判斷的準確率、刪除的便利性

  • 存在誤判,可能要查到的元素並沒有在容器中,但是hash之後得到的k個位置上值都是1。如果bloom filter中儲存的是黑名單,那麼可以通過建立一個白名單來儲存可能會誤判的元素。
  • 刪除困難。一個放入容器的元素對映到bit陣列的k個位置上是1,刪除的時候不能簡單的直接置為0,可能會影響其他元素的判斷。可以採用Counting Bloom Filter

Bloom Filter 實現

布隆過濾器有許多實現與優化,Guava中就提供了一種Bloom Filter的實現。

在使用bloom filter時,繞不過的兩點是預估資料量n以及期望的誤判率fpp,

在實現bloom filter時,繞不過的兩點就是hash函式的選取以及bit陣列的大小。

對於一個確定的場景,我們預估要存的資料量為n,期望的誤判率為fpp,然後需要計算我們需要的Bit陣列的大小m,以及hash函式的個數k,並選擇hash函式

(1)Bit陣列大小選擇 

     根據預估資料量n以及誤判率fpp,bit陣列大小的m的計算方式:

(2)雜湊函式選擇

           由預估資料量n以及bit陣列長度m,可以得到一個hash函式的個數k:

           雜湊函式的選擇對效能的影響應該是很大的,一個好的雜湊函式要能近似等概率的將字串對映到各個Bit。選擇k個不同的雜湊函式比較麻煩,一種簡單的方法是選擇一個雜湊函式,然後送入k個不同的引數

看看Guava中BloomFilter中對於m和k值計算的實現,在com.google.common.hash.BloomFilter類中:

/**
 * 計算 Bloom Filter的bit位數m
 *
 * <p>See http://en.wikipedia.org/wiki/Bloom_filter#Probability_of_false_positives for the
 * formula.
 *
 * @param n 預期資料量
 * @param p 誤判率 (must be 0 < p < 1)
 */
@VisibleForTesting
static long optimalNumOfBits(long n, double p) {
  if (p == 0) {
    p = Double.MIN_VALUE;
  }
  return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
 
 
 
 
/**
 * 計算最佳k值,即在Bloom過濾器中插入的每個元素的雜湊數
 *
 * <p>See http://en.wikipedia.org/wiki/File:Bloom_filter_fp_probability.svg for the formula.
 *
 * @param n 預期資料量
 * @param m bloom filter中總的bit位數 (must be positive)
 */
@VisibleForTesting
static int optimalNumOfHashFunctions(long n, long m) {
  // (m / n) * log(2), but avoid truncation due to division!
  return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}

BloomFilter實現的另一個重點就是怎麼利用hash函式把資料對映到bit陣列中。Guava的實現是對元素通過MurmurHash3計算hash值,將得到的hash值取高8個位元組以及低8個位元組進行計算,以得當前元素在bit陣列中對應的多個位置。MurmurHash3演算法詳見:Murmur雜湊於2008年被髮明。這個演算法hbase,redis,kafka都在使用。

這個過程的實現在兩個地方:

  • 將資料放入bloom filter中
  • 判斷資料是否已在bloom filter中

這兩個地方的實現大同小異,區別只是,前者是put資料,後者是查資料。

這裡看一下put的過程,hash策略以MURMUR128_MITZ_64為例:

public <T> boolean put(
    T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
  long bitSize = bits.bitSize();
 
  //利用MurmurHash3得到資料的hash值對應的位元組陣列
  byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
 
 
  //取低8個位元組、高8個位元組,轉成long型別
  long hash1 = lowerEight(bytes);
  long hash2 = upperEight(bytes);
 
  boolean bitsChanged = false;
 
 
  //這裡的combinedHash = hash1 + i * hash2
  long combinedHash = hash1;
 
 
  //根據combinedHash,得到放入的元素在bit陣列中的k個位置,將其置1
  for (int i = 0; i < numHashFunctions; i++) {
    bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);
    combinedHash += hash2;
  }
  return bitsChanged;
}

判斷元素是否在bloom filter中的方法mightContain與上面的實現基本一致,不再贅述。

Bloom Filter的使用

簡單寫個demo,用法很簡單,類似HashMap

package com.qunar.sage.wang.common.bloom.filter;
 
import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnel;
import com.google.common.hash.Funnels;
import com.google.common.hash.PrimitiveSink;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.ToString;
 
/**
 * BloomFilterTest
 *
 * @author sage.wang
 * @date 18-5-14 下午5:02
 */
public class BloomFilterTest {
     
    public static void main(String[] args) {
        long expectedInsertions = 10000000;
        double fpp = 0.00001;
 
        BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), expectedInsertions, fpp);
 
        bloomFilter.put("aaa");
        bloomFilter.put("bbb");
        boolean containsString = bloomFilter.mightContain("aaa");
        System.out.println(containsString);
 
        BloomFilter<Email> emailBloomFilter = BloomFilter
                .create((Funnel<Email>) (from, into) -> into.putString(from.getDomain(), Charsets.UTF_8),
                        expectedInsertions, fpp);
 
        emailBloomFilter.put(new Email("sage.wang", "quanr.com"));
        boolean containsEmail = emailBloomFilter.mightContain(new Email("sage.wangaaa", "quanr.com"));
        System.out.println(containsEmail);
    }
 
    @Data
    @Builder
    @ToString
    @AllArgsConstructor
    public static class Email {
        private String userName;
        private String domain;
    }
 
}

Bloom Filter的應用

常見的幾個應用場景:

  • cerberus在收集監控資料的時候, 有的系統的監控項量會很大, 需要檢查一個監控項的名字是否已經被記錄到db過了, 如果沒有的話就需要寫入db.
  • 爬蟲過濾已抓到的url就不再抓,可用bloom filter過濾
  • 垃圾郵件過濾。如果用雜湊表,每儲存一億個 email地址,就需要 1.6GB的記憶體(用雜湊表實現的具體辦法是將每一個 email地址對應成一個八位元組的資訊指紋,然後將這些資訊指紋存入雜湊表,由於雜湊表的儲存效率一般只有 50%,因此一個 email地址需要佔用十六個位元組。一億個地址大約要 1.6GB,即十六億位元組的記憶體)。因此存貯幾十億個郵件地址可能需要上百 GB的記憶體。而Bloom Filter只需要雜湊表 1/8到 1/4 的大小就能解決同樣的問題。

參考文章