在進入正文之前,之前看到的有句話我覺得說得很好:
Data structures are nothing different. They are like the bookshelves of your application where you can organize your data. Different data structures will give you different facility and benefits. To properly use the power and accessibility of the data structures you need to know the trade-offs of using one. 大意是不同的數據結構有不同的適用場景和優缺點,你需要仔細權衡自己的需求之后妥善適用它們, 布隆過濾器就是踐行這句話的代表。
什么情況下需要布隆過濾器?
先來看幾個比較常見的例子
- 字處理軟件中,需要檢查一個英語單詞是否拼寫正確
- 在 FBI,一個嫌疑人的名字是否已經在嫌疑名單上
- 在網絡爬蟲里,一個網址是否被訪問過
- yahoo, gmail等郵箱垃圾郵件過濾功能
這幾個例子有一個共同的特點: 如何判斷一個元素是否存在一個集合中?
常規思路
- 數組
- 鏈表
- 樹、平衡二叉樹、Trie
- Map (紅黑樹)
- 哈希表
雖然上面描述的這幾種數據結構配合常見的排序、二分搜索可以快速高效的處理絕大部分判斷元素是否存在集合中的需求。但是當集合里面的元素數量足夠大,如果有500萬條記錄甚至1億條記錄呢?這個時候常規的數據結構的問題就凸顯出來了。數組、鏈表、樹等數據結構會存儲元素的內容,一旦數據量過大,消耗的內存也會呈現線性增長,最終達到瓶頸。有的同學可能會問,哈希表不是效率很高嗎?查詢效率可以達到O(1)。但是哈希表需要消耗的內存依然很高。使用哈希表存儲一億 個垃圾 email 地址的消耗?哈希表的做法:首先,哈希函數將一個email地址映射成8字節信息指紋;考慮到哈希表存儲效率通常小於50%(哈希沖突);因此消耗的內存:8 * 2 * 1億 字節 = 1.6G 內存,普通計算機是無法提供如此大的內存。這個時候,布隆過濾器(Bloom Filter)就應運而生。在繼續介紹布隆過濾器的原理時,先講解下關於哈希函數的預備知識。
哈希函數
哈希函數的概念是:將任意大小的數據轉換成特定大小的數據的函數,轉換后的數據稱為哈希值或哈希編碼。下面是一幅示意圖:
可以明顯的看到,原始數據經過哈希函數的映射后稱為了一個個的哈希編碼,數據得到壓縮。哈希函數是實現哈希表和布隆過濾器的基礎。
布隆過濾器介紹
- 巴頓.布隆於一九七零年提出
- 一個很長的二進制向量 (位數組)
- 一系列隨機函數 (哈希)
- 空間效率和查詢效率高
- 有一定的誤判率(哈希表是精確匹配)
數組長度越長/哈希次數越多,誤判率越低。哈希次數越多,效率越低。
什么是布隆過濾器
本質上布隆過濾器是一種數據結構,比較巧妙的概率型數據結構(probabilistic data structure),特點是高效地插入和查詢,可以用來告訴你 “某樣東西一定不存在或者可能存在”。
相比於傳統的 List、Set、Map 等數據結構,它更高效、占用空間更少,但是缺點是其返回的結果是概率性的,而不是確切的。
布隆過濾器不支持刪除,刪除一個數據會影響到很多數據。
實現原理
HashMap 的問題
講述布隆過濾器的原理之前,我們先思考一下,通常你判斷某個元素是否存在用的是什么?應該蠻多人回答 HashMap 吧,確實可以將值映射到 HashMap 的 Key,然后可以在 O(1) 的時間復雜度內返回結果,效率奇高。但是 HashMap 的實現也有缺點,例如存儲容量占比高,考慮到負載因子的存在,通常空間是不能被用滿的,而一旦你的值很多例如上億的時候,那 HashMap 占據的內存大小就變得很可觀了。
還比如說你的數據集存儲在遠程服務器上,本地服務接受輸入,而數據集非常大不可能一次性讀進內存構建 HashMap 的時候,也會存在問題。
布隆過濾器數據結構
布隆過濾器是一個 bit 向量或者說 bit 數組,長這樣:
如果我們要映射一個值到布隆過濾器中,我們需要使用多個不同的哈希函數生成多個哈希值,並對每個生成的哈希值指向的 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” 這個值存在。
假設某個元素通過映射對應下標為4,5,6這3個點。雖然這3個點都為1,但是很明顯這3個點是不同元素經過哈希得到的位置,因此這種情況說明元素雖然不在集合中,也可能對應的都是1,這是誤判率存在的原因。假設某個元素通過映射對應下標為4,5,6這3個點。雖然這3個點都為1,但是很明顯這3個點是不同元素經過哈希得到的位置,因此這種情況說明元素雖然不在集合中,也可能對應的都是1,這是誤判率存在的原因。
布隆過濾器添加元素
- 將要添加的元素給k個哈希函數
- 得到對應於位數組上的k個位置
- 將這k個位置設為1
布隆過濾器查詢元素
- 將要查詢的元素給k個哈希函數
- 得到對應於位數組上的k個位置
- 如果k個位置有一個為0,則肯定不在集合中
- 如果k個位置全部為1,則可能在集合中
支持刪除么
感謝評論區提醒,傳統的布隆過濾器並不支持刪除操作。但是名為 Counting Bloom filter 的變種可以用來測試元素計數個數是否絕對小於某個閾值,它支持元素刪除。可以參考文章 Counting Bloom Filter 的原理和實現
如何選擇哈希函數個數和布隆過濾器長度
很顯然,過小的布隆過濾器很快所有的 bit 位均為 1,那么查詢任何值都會返回“可能存在”,起不到過濾的目的了。布隆過濾器的長度會直接影響誤報率,布隆過濾器越長其誤報率越小。
另外,哈希函數的個數也需要權衡,個數越多則布隆過濾器 bit 位置位 1 的速度越快,且布隆過濾器的效率越低;但是如果太少的話,那我們的誤報率會變高。
k 為哈希函數個數,m 為布隆過濾器長度,n 為插入的元素個數,p 為誤報率
如何選擇適合業務的 k 和 m 值呢,這里直接貼一個公式:
如何推導這個公式這里只是提一句,因為對於使用來說並沒有太大的意義,你讓一個高中生來推會推得很快。k 次哈希函數某一 bit 位未被置為 1 的概率為:
插入n個元素后依舊為 0 的概率和為 1 的概率分別是:
標明某個元素是否在集合中所需的 k 個位置都按照如上的方法設置為 1,但是該方法可能會使算法錯誤的認為某一原本不在集合中的元素卻被檢測為在該集合中(False Positives),該概率由以下公式確定
最佳實踐
常見的適用常見有,利用布隆過濾器減少磁盤 IO 或者網絡請求,因為一旦一個值必定不存在的話,我們可以不用進行后續昂貴的查詢請求。
另外,既然你使用布隆過濾器來加速查找和判斷是否存在,那么性能很低的哈希函數不是個好選擇,推薦 MurmurHash、Fnv 這些。
大Value拆分
Redis 因其支持 setbit 和 getbit 操作,且純內存性能高等特點,因此天然就可以作為布隆過濾器來使用。但是布隆過濾器的不當使用極易產生大 Value,增加 Redis 阻塞風險,因此生成環境中建議對體積龐大的布隆過濾器進行拆分。
拆分的形式方法多種多樣,但是本質是不要將 Hash(Key) 之后的請求分散在多個節點的多個小 bitmap 上,而是應該拆分成多個小 bitmap 之后,對一個 Key 的所有哈希函數都落在這一個小 bitmap 上。