什麼是布隆過濾器
本質上布隆過濾器是一種數據結構,比較巧妙的概率型數據結構(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()方法爲例講解一下流程吧。
- 使用MurmurHash算法對funnel的輸入數據進行散列,得到128bit(16B)的字節數組。
- 取低8字節作爲第一個哈希值hash1,取高8字節作爲第二個哈希值hash2。
- 進行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 上。
轉載自原文鏈接
本人在此基礎上又增加了一些概念等描述。