大資料量下的集合過濾—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 的大小就能解決同樣的問題。
參考文章