上一節我們學會了使用 HyperLogLog 數據結構來進行估數,它非常有價值,可以解決多精確度不高的統計需求。
但是如果我們想知道某一個值是不是已經在 HyperLogLog 結構里面了,它就無能為力了,它只提供了 pfadd 和 pfcount 方法,沒有提供 pfcontains 這種方法。
講個使用場景,比如我們在使用新聞客戶端看新聞時,它會給我們不停地推薦新的內容,它每次推薦時要去重,去掉那些已經看過的內容。問題來了,新聞客戶端推薦系統如何實現推送去重?
你會想到服務器記錄了用戶看過的所有歷史記錄,當推薦系統推薦新聞時會從每個用戶的歷史記錄里進行篩選,過濾掉那些已經存在的記錄。問題是當用戶量很大,每個用戶看過的新聞又很多的情況下,這種方式,推薦系統的去重工作在性能上跟的上么?
實際上,如果歷史記錄存儲在關系數據庫里,去重就需要頻繁地對數據庫進行 exists 查詢,當系統并發量很高時,數據庫是很難扛住壓力的。
你可能又想到了緩存,但是如此多的歷史記錄全部緩存起來,那得浪費多大存儲空間啊?而且這個存儲空間是隨著時間線性增長,你撐得住一個月,你能撐得住幾年么?但是不緩存的話,性能又跟不上,這該怎么辦?
這時,布隆過濾器 (Bloom Filter) 閃亮登場了,它就是專門用來解決這種去重問題的。它在起到去重的同時,在空間上還能節省 90% 以上,只是稍微有那么點不精確,也就是有一定的誤判概率。
布隆過濾器是什么 ?
布隆過濾器可以理解為一個不怎么精確的 set 結構,當你使用它的 contains 方法判斷某個對象是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要參數設置的合理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。
當布隆過濾器說某個值存在時,這個值可能不存在;當它說不存在時,那就肯定不存在。打個比方,當它說不認識你時,肯定就不認識;當它說見過你時,可能根本就沒見過面,不過因為你的臉跟它認識的人中某臉比較相似 (某些熟臉的系數組合),所以誤判以前見過你。
套在上面的使用場景中,布隆過濾器能準確過濾掉那些已經看過的內容,那些沒有看過的新內容,它也會過濾掉極小一部分 (誤判),但是絕大多數新內容它都能準確識別。這樣就可以完全保證推薦給用戶的內容都是無重復的。
布隆過濾器基本使用
布隆過濾器有二個基本指令,bf.add 添加元素,bf.exists 查詢元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一個元素,如果想要一次添加多個,就需要用到 bf.madd 指令。同樣如果需要一次查詢多個元素是否存在,就需要用到bf.mexists 指令。
使用的布隆過濾器只是默認參數的布隆過濾器,它在我們第一次 add 的時候自動創建。Redis 其實還提供了自定義參數的布隆過濾器,需要我們在 add 之前使用 bf.reserve指令顯式創建。如果對應的 key 已經存在,bf.reserve 會報錯。bf.reserve 有三個參數,分別是 key, error_rate 和 initial_size。錯誤率越低,需要的空間越大。initial_size 參數表示預計放入的元素數量,當實際數量超出這個數值時,誤判率會上升。
所以需要提前設置一個較大的數值避免超出導致誤判率升高。如果不使用 bf.reserve,默認的 error_rate 是 0.01,默認的 initial_size 是 100。
注意事項
布隆過濾器的 initial_size 估計的過大,會浪費存儲空間,估計的過小,就會影響準確率,用戶在使用之前一定要盡可能地精確估計好元素數量,還需要加上一定的冗余空間以避免實際元素可能會意外高出估計值很多。
布隆過濾器的 error_rate 越小,需要的存儲空間就越大,對于不需要過于精確的場合,error_rate 設置稍大一點也無傷大雅。比如在新聞去重上而言,誤判率高一點只會讓小部分文章不能讓合適的人看到,文章的整體閱讀量不會因為這點誤判率就帶來巨大的改變。
布隆過濾器基本使用
布隆過濾器有二個基本指令,bf.add 添加元素,bf.exists 查詢元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一個元素,如果想要一次添加多個,就需要用到 bf.madd 指令。同樣如果需要一次查詢多個元素是否存在,就需要用到 bf.mexists 指令。
127.0.0.1:6379> bf.add codehole user1
(integer) 1
127.0.0.1:6379> bf.add codehole user2
(integer) 1
127.0.0.1:6379> bf.add codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user1
(integer) 1
127.0.0.1:6379> bf.exists codehole user2
(integer) 1
布隆過濾器的原理
學會了布隆過濾器的使用,下面有必要把原理解釋一下,不然讀者還會繼續蒙在鼓里。
每個布隆過濾器對應到 Redis 的數據結構里面就是一個大型的位數組和幾個不一樣的無偏 hash 函數。所謂無偏就是能夠把元素的 hash 值算得比較均勻。向布隆過濾器中添加 key 時,會使用多個 hash 函數對 key 進行 hash 算得一個整數索引值然后對位數組長度進行取模運算得到一個位置,每個 hash 函數都會算得一個不同的位置。再把位數組的這幾個位置都置為 1 就完成了 add 操作。
向布隆過濾器詢問 key 是否存在時,跟 add 一樣,也會把 hash 的幾個位置都算出來,看看位數組中這幾個位置是否都位 1,只要有一個位為 0,那么說明布隆過濾器中這個key 不存在。如果都是 1,這并不能說明這個 key 就一定存在,只是極有可能存在,因為這些位被置為 1 可能是因為其它的 key 存在所致。如果這個位數組比較稀疏,這個概率就會很大,如果這個位數組比較擁擠,這個概率就會降低。
空間占用估計
布隆過濾器的空間占用有一個簡單的計算公式。布隆過濾器有兩個參數,第一個是預計元素的數量 n,第二個是錯誤率 f。公式根據這兩個輸入得到兩個輸出,第一個輸出是位數組的長度 l,也就是需要的存儲空間大小 (bit),第二個輸出是 hash 函數的最佳數量 k。hash 函數的數量也會直接影響到錯誤率,最佳的數量會有最低的錯誤率。
k=0.7*(l/n) # 約等于
f=0.6185^(l/n) # ^ 表示次方計算,也就是 math.pow
從公式中可以看出
1、位數組相對越長 (l/n),錯誤率 f 越低,這個和直觀上理解是一致的
2、位數組相對越長 (l/n),hash 函數需要的最佳數量也越多,影響計算效率
3、當一個元素平均需要 1 個字節 (8bit) 的指紋空間時 (l/n=8),錯誤率大約為 2%
4、錯誤率為 10%,一個元素需要的平均指紋空間為 4.792 個 bit,大約為 5bit
5、錯誤率為 1%,一個元素需要的平均指紋空間為 9.585 個 bit,大約為 10bit
6、錯誤率為 0.1%,一個元素需要的平均指紋空間為 14.377 個 bit,大約為 15bit
你也許會想,如果一個元素需要占據 15 個 bit,那相對 set 集合的空間優勢是不是就沒有那么明顯了?這里需要明確的是,set 中會存儲每個元素的內容,而布隆過濾器僅僅存儲元素的指紋。元素的內容大小就是字符串的長度,它一般會有多個字節,甚至是幾十個上百個字節,每個元素本身還需要一個指針被 set 集合來引用,這個指針又會占去 4 個字節或 8 個字節,取決于系統是 32bit 還是 64bit。而指紋空間只有接近 2 個字節,所以布隆過濾器的空間優勢還是非常明顯的。
實際元素超出時 ,誤判率會怎樣變化
當實際元素超出預計元素時,錯誤率會有多大變化,它會急劇上升么,還是平緩地上升,這就需要另外一個公式,引入參數 t 表示實際元素和預計元素的倍數 t
f=(1-0.5t)k # 極限近似,k 是 hash 函數的最佳數量
當 t 增大時,錯誤率,f 也會跟著增大,分別選擇錯誤率為 10%,1%,0.1% 的 k 值,畫出它的曲線進行直觀觀察。
從這個圖中可以看出曲線還是比較陡峭的
1、錯誤率為 10% 時,倍數比為 2 時,錯誤率就會升至接近 40%,這個就比較危險了
2、錯誤率為 1% 時,倍數比為 2 時,錯誤率升至 15%,也挺可怕的
3、錯誤率為 0.1%,倍數比為 2 時,錯誤率升至 5%,也比較懸了
布隆過濾器的其它應用
在爬蟲系統中,我們需要對 URL 進行去重,已經爬過的網頁就可以不用爬了。但是URL 太多了,幾千萬幾個億,如果用一個集合裝下這些 URL 地址那是非常浪費空間的。這時候就可以考慮使用布隆過濾器。它可以大幅降低去重存儲消耗,只不過也會使得爬蟲系統錯過少量的頁面。
布隆過濾器在 NoSQL 數據庫領域使用非常廣泛,我們平時用到的 HBase、Cassandra還有 LevelDB、RocksDB 內部都有布隆過濾器結構,布隆過濾器可以顯著降低數據庫的 IO請求數量。當用戶來查詢某個 row 時,可以先通過內存中的布隆過濾器過濾掉大量不存在的row 請求,然后再去磁盤進行查詢。
郵箱系統的垃圾郵件過濾功能也普遍用到了布隆過濾器,因為用了這個過濾器,所以平時也會遇到某些正常的郵件被放進了垃圾郵件目錄中,這個就是誤判所致,概率很低。