爲了解決布隆過濾器不能刪除元素的問題,布穀鳥過濾器橫空出世。論文《Cuckoo Filter:Better Than Bloom》做者將布穀鳥過濾器和布隆過濾器進行了深刻的對比。相比布穀鳥過濾器而言布隆過濾器有如下不足:查詢性能弱、空間利用效率低、不支持反向操做(刪除)以及不支持計數。c++
查詢性能弱是由於布隆過濾器須要使用多個 hash 函數探測位圖中多個不一樣的位點,這些位點在內存上跨度很大,會致使 CPU 緩存行命中率低。git
空間效率低是由於在相同的誤判率下,布穀鳥過濾器的空間利用率要明顯高於布隆,空間上大概能節省 40% 多。不過布隆過濾器並無要求位圖的長度必須是 2 的指數,而布穀鳥過濾器必須有這個要求。從這一點出發,彷佛布隆過濾器的空間伸縮性更強一些。github
不支持反向刪除操做這個問題着實是擊中了布隆過濾器的軟肋。在一個動態的系統裏面元素老是不斷的來也是不斷的走。布隆過濾器就比如是印跡,來過來就會有痕跡,就算走了也沒法清理乾淨。好比你的系統裏原本只留下 1kw 個元素,可是總體上來過了上億的流水元素,布隆過濾器很無奈,它會將這些流失的元素的印跡也會永遠存放在那裏。隨着時間的流失,這個過濾器會愈來愈擁擠,直到有一天你發現它的誤判率過高了,不得不進行重建。redis
布穀鳥過濾器在論文裏聲稱本身解決了這個問題,它能夠有效支持反向刪除操做。並且將它做爲一個重要的賣點,誘惑大家放棄布隆過濾器改用布穀鳥過濾器。算法
可是通過我一段時間的調查研究發現,布穀鳥過濾器並無它聲稱的那麼美好。它支持的反向刪除操做很是雞肋,以致於你根本沒辦法使用這個功能。在向讀者具體說明這個問題以前,仍是先給讀者仔細講解一下布穀鳥過濾器的原理。數組
布穀鳥哈希緩存
布穀鳥過濾器源於布穀鳥哈希算法,布穀鳥哈希算法源於生活 —— 那個熱愛「鳩佔鵲巢」的布穀鳥。布穀鳥喜歡濫交(自由),歷來不本身築巢。它將本身的蛋產在別人的巢裏,讓別人來幫忙孵化。待小布谷鳥破殼而出以後,由於布穀鳥的體型相對較大,它又將養母的其它孩子(仍是蛋)從巢裏擠走 —— 從高空摔下夭折了。bash
最簡單的布穀鳥哈希結構是一維數組結構,會有兩個 hash 算法將新來的元素映射到數組的兩個位置。若是兩個位置中有一個位置爲空,那麼就能夠將元素直接放進去。可是若是這兩個位置都滿了,它就不得不「鳩佔鵲巢」,隨機踢走一個,而後本身霸佔了這個位置。數據結構
p1 = hash1(x) % l
p2 = hash2(x) % l
複製代碼
不一樣於布穀鳥的是,布穀鳥哈希算法會幫這些受害者(被擠走的蛋)尋找其它的窩。由於每個元素均可以放在兩個位置,只要任意一個有空位置,就能夠塞進去。因此這個傷心的被擠走的蛋會看看本身的另外一個位置有沒有空,若是空了,本身挪過去也就皆大歡喜了。可是若是這個位置也被別人佔了呢?好,那麼它會再來一次「鳩佔鵲巢」,將受害者的角色轉嫁給別人。而後這個新的受害者還會重複這個過程直到全部的蛋都找到了本身的巢爲止。函數
正如魯迅的那句名言「佔本身的巢,讓別人滾蛋去吧!」
可是會遇到一個問題,那就是若是數組太擁擠了,連續踢來踢去幾百次尚未停下來,這時候會嚴重影響插入效率。這時候布穀鳥哈希會設置一個閾值,當連續佔巢行爲超出了某個閾值,就認爲這個數組已經幾乎滿了。這時候就須要對它進行擴容,從新放置全部元素。
還會有另外一個問題,那就是可能會存在擠兌循環。好比兩個不一樣的元素,hash 以後的兩個位置正好相同,這時候它們一人一個位置沒有問題。可是這時候來了第三個元素,它 hash 以後的位置也和它們同樣,很明顯,這時候會出現擠兌的循環。不過讓三個不一樣的元素通過兩次 hash 後位置還同樣,這樣的機率並非很高,除非你的 hash 算法太挫了。
布穀鳥哈希算法對待這種擠兌循環的態度就是認爲數組太擁擠了,須要擴容(實際上並非這樣)。
優化
上面的布穀鳥哈希算法的平均空間利用率並不高,大概只有 50%。到了這個百分比,就會很快出現連續擠兌次數超出閾值。這樣的哈希算法價值並不明顯,因此須要對它進行改良。
改良的方案之一是增長 hash 函數,讓每一個元素不止有兩個巢,而是三個巢、四個巢。這樣能夠大大下降碰撞的機率,將空間利用率提升到 95%左右。
另外一個改良方案是在數組的每一個位置上掛上多個座位,這樣即便兩個元素被 hash 在了同一個位置,也沒必要當即「鳩佔鵲巢」,由於這裏有多個座位,你能夠隨意坐一個。除非這多個座位都被佔了,才須要進行擠兌。很明顯這也會顯著下降擠兌次數。這種方案的空間利用率只有 85%左右,可是查詢效率會很高,同一個位置上的多個座位在內存空間上是連續的,能夠有效利用 CPU 高速緩存。
因此更加高效的方案是將上面的兩個改良方案融合起來,好比使用 4 個 hash 函數,每一個位置上放 2 個座位。這樣既能夠獲得時間效率,又能夠獲得空間效率。這樣的組合甚至能夠將空間利用率提到高 99%,這是很是了不得的空間效率。
布穀鳥過濾器
布穀鳥過濾器和布穀鳥哈希結構同樣,它也是一維數組,可是不一樣於布穀鳥哈希的是,布穀鳥哈希會存儲整個元素,而布穀鳥過濾器中只會存儲元素的指紋信息(幾個bit,相似於布隆過濾器)。這裏過濾器犧牲了數據的精確性換取了空間效率。正是由於存儲的是元素的指紋信息,因此會存在誤判率,這點和布隆過濾器一模一樣。
首先布穀鳥過濾器仍是隻會選用兩個 hash 函數,可是每一個位置能夠放置多個座位。這兩個 hash 函數選擇的比較特殊,由於過濾器中只能存儲指紋信息。當這個位置上的指紋被擠兌以後,它須要計算出另外一個對偶位置。而計算這個對偶位置是須要元素自己的,咱們來回憶一下前面的哈希位置計算公式。
fp = fingerprint(x)
p1 = hash1(x) % l
p2 = hash2(x) % l
複製代碼
咱們知道了 p1 和 x 的指紋,是沒辦法直接計算出 p2 的。
特殊的 hash 函數
布穀鳥過濾器巧妙的地方就在於設計了一個獨特的 hash 函數,使得能夠根據 p1 和 元素指紋 直接計算出 p2,而不須要完整的 x 元素。
fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp) // 異或
複製代碼
從上面的公式中能夠看出,當咱們知道 fp 和 p1,就能夠直接算出 p2。一樣若是咱們知道 p2 和 fp,也能夠直接算出 p1 —— 對偶性。
p1 = p2 ^ hash(fp)
複製代碼
因此咱們根本不須要知道當前的位置是 p1 仍是 p2,只須要將當前的位置和 hash(fp) 進行異或計算就能夠獲得對偶位置。並且只須要確保 hash(fp) != 0 就能夠確保 p1 != p2,如此就不會出現本身踢本身致使死循環的問題。
也許你會問爲何這裏的 hash 函數不須要對數組的長度取模呢?其實是須要的,可是布穀鳥過濾器強制數組的長度必須是 2 的指數,因此對數組的長度取模等價於取 hash 值的最後 n 位。在進行異或運算時,忽略掉低 n 位 以外的其它位就行。將計算出來的位置 p 保留低 n 位就是最終的對偶位置。
// l = power(2, 8)
p_ = p & 0xff
複製代碼
數據結構
簡單起見,咱們假定指紋佔用一個字節,每一個位置有 4 個 座位。
type bucket [4]byte // 一個桶,4個座位
type cuckoo_filter struct {
buckets [size]bucket // 一維數組
nums int // 容納的元素的個數
kick_max // 最大擠兌次數
}
複製代碼
插入算法
插入須要考慮到最壞的狀況,那就是擠兌循環。因此須要設置一個最大的擠兌上限
def insert(x):
fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp)
// 嘗試加入第一個位置
if !buckets[p1].full():
buckets[p1].add(fp)
nums++
return true
// 嘗試加入第二個位置
if !buckets[p2].full():
buckets[p2].add(fp)
nums++
return true
// 隨機擠兌一個位置
p = rand(p1, p2)
c = 0
while c < kick_max:
// 擠兌
old_fp = buckets[p].replace_with(fp)
fp = old_fp
// 計算對偶位置
p = p ^ hash(fp)
// 嘗試加入對偶位置
if !buckets[p].full():
buckets[p].add(fp)
nums++
return true
c++
return false
複製代碼
查找算法
查找很是簡單,在兩個 hash 位置的桶裏找一找有沒有本身的指紋就 ok 了。
def contains(x):
fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp)
return buckets[p1].contains(fp) || buckets[p2].contains(fp)
複製代碼
刪除算法
刪除算法和查找算法差很少,也很簡單,在兩個桶裏把本身的指紋抹去就 ok 了。
def delete(x):
fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp)
ok = buckets[p1].delete(fp) || buckets[p2].delete(fp)
if ok:
nums--
return ok
複製代碼
一個明顯的弱點
so far so good!布穀鳥過濾器看起來很完美啊!刪除功能和獲取元素個數的功能都具有,比布隆過濾器強大多了,並且彷佛邏輯也很是簡單,上面寥寥數行代碼就完事了。若是插入操做返回了 false,那就意味着須要擴容了,這也很是顯而易見。
but! 考慮一下,若是布穀鳥過濾器對同一個元素進行屢次連續的插入會怎樣?
根據上面的邏輯,毫無疑問,這個元素的指紋會霸佔兩個位置上的全部座位 —— 8個座位。這 8 個座位上的值都是同樣的,都是這個元素的指紋。若是繼續插入,則會當即出現擠兌循環。從 p1 槽擠向 p2 槽,又從 p2 槽擠向 p1 槽。
也許你會想到,能不能在插入以前作一次檢查,詢問一下過濾器中是否已經存在這個元素了?這樣確實能夠解決問題,插入一樣的元素也不會出現擠兌循環了。可是刪除的時候會出現高几率的誤刪。由於不一樣的元素被 hash 到同一個位置的可能性仍是很大的,並且指紋只有一個字節,256 種可能,同一個位置出現相同的指紋可能性也很大。若是兩個元素的 hash 位置相同,指紋相同,那麼這個插入檢查會認爲它們是相等的。
插入 x,檢查時會認爲包含 y。由於這個檢查機制會致使只會存儲一份指紋(x 的指紋)。那麼刪除 y 也等價於刪除 x。這就會致使較高的誤判率。
論文沒有欺騙咱們,它也提到了這個問題。(讀者沒必要理解後半句)
這句話明確告訴咱們若是想要讓布穀鳥過濾器支持刪除操做,那麼就必須不能容許插入操做屢次插入同一個元素,確保每個元素不會被插入屢次(kb+1)。這裏的 k 是指 hash 函數的個數 2,b 是指單個位置上的座位數,這裏咱們是 4。在現實世界的應用中,確保一個元素不被插入指定的次數那幾乎是不可能作到的。若是你以爲能夠作到,請思考一下要如何作!你是否是還得維護一個外部的字典來記錄每一個元素的插入次數呢?這個外部字典的存儲空間怎麼辦?
由於不能完美的支持刪除操做,因此也就沒法較爲準確地估計內部的元素數量。
證實
下面咱們使用開源的布穀鳥過濾器庫來證實一下上面的推論
go get github.com/seiflotfy/cuckoofilter
複製代碼
這個布穀鳥過濾器對每一個元素存儲的指紋信息爲一個字節,同一個位置會有 4 個座位。咱們嘗試向裏面插入 15 次同一個元素。
package main
import (
"fmt"
"github.com/seiflotfy/cuckoofilter"
)
func main() {
cf := cuckoo.NewFilter(100000)
for i := 0; i < 15; i++ {
var ok = cf.Insert([]byte("geeky ogre"))
fmt.Println(ok)
}
}
-------
true
true
true
true
true
true
true
true
false
false
false
false
false
false
false
複製代碼
咱們發現插入它最多隻能插入 8 次同一個元素。後面每一次返回 false 都會通過上百次的擠兌循環直到觸碰了最大擠兌次數。
若是兩個位置的 8 個座位 都存儲了同一個元素,那麼空間浪費也是很嚴重的,空間效率直接被砍得只剩下 1/8,這樣的空間效率根本沒法與布隆過濾器抗衡了。
若是不支持刪除操做,那麼布穀鳥過濾器單純從空間效率上來講仍是有必定的可比性的。這確實比布隆過濾器作的要好一點,可是布穀鳥過濾器這必須的 2 的指數的空間需求又再次讓空間效率打了個折扣。
相關項目
布穀鳥過濾器論文:Cuckoo Filter: Practically Better Than Bloom
Redis 布穀鳥過濾器模塊: github.com/kristoff-it…
最有影響力的布穀鳥過濾器 C 庫:github.com/efficient/c…