Redis中的布隆過濾器與布谷鳥過濾器,你了解多少?
背景
大家都知道,在計(jì)算機(jī)中,IO一直是一個瓶頸,很多框架以及技術(shù)甚至硬件都是為了降低IO操作而生,今天聊一聊過濾器,先說一個場景:
業(yè)務(wù)后端涉及數(shù)據(jù)庫,當(dāng)請求消息查詢某些信息時(shí),可能先檢查緩存中是否有相關(guān)信息,有的話返回;如果沒有的話可能就要去數(shù)據(jù)庫里面查詢,這時(shí)候有一個問題,如果很多請求是在請求數(shù)據(jù)庫根本不存在的數(shù)據(jù),那么數(shù)據(jù)庫就要頻繁響應(yīng)這種不必要的IO查詢。如果再多一些,數(shù)據(jù)庫大多數(shù)IO都在響應(yīng)這種毫無意義的請求操作,那么如何將這些請求阻擋在外呢?過濾器由此誕生。
布隆過濾器
布隆過濾器(Bloom Filter)大概的思路就是,當(dāng)請求信息的時(shí)候,先檢查一下查詢的數(shù)據(jù)我這有沒有,有的話將請求壓給數(shù)據(jù)庫,沒有的話直接返回,是如何做到的呢?

如圖,一個bitmap用于記錄,bitmap原始數(shù)值全都是0,當(dāng)一個數(shù)據(jù)存進(jìn)來的時(shí)候,用三個Hash函數(shù)分別計(jì)算三次Hash值,并且將bitmap對應(yīng)的位置設(shè)置為1。上圖中,bitmap的1,3,6位置被標(biāo)記為1。
一個數(shù)據(jù)請求過來,依然用之前的三個Hash函數(shù)計(jì)算Hash值,如果是同一個數(shù)據(jù)的話,勢必依舊是映射到1,3,6位,那么就可以判斷這個數(shù)據(jù)之前存儲過。
如果新的數(shù)據(jù)映射的三個位置,有一個匹配不上,假如映射到1,3,7位,由于7位是0,也就是這個數(shù)據(jù)之前并沒有加入進(jìn)數(shù)據(jù)庫,所以直接返回。
布隆過濾器的問題
上面這種方式,應(yīng)該你已經(jīng)發(fā)現(xiàn)了,布隆過濾器存在一些問題:
第一方面,布隆過濾器可能誤判。
假如有這么一個情景,放入數(shù)據(jù)包1時(shí),將bitmap的1,3,6位設(shè)置為了1,放入數(shù)據(jù)包2時(shí)將bitmap的3,6,7位設(shè)置為了1,此時(shí)一個并沒有存過的數(shù)據(jù)包請求3,做三次哈希之后,對應(yīng)的bitmap位點(diǎn)分別是1,6,7,這個數(shù)據(jù)之前并沒有存進(jìn)去過,但是由于數(shù)據(jù)包1和2存入時(shí)將對應(yīng)的點(diǎn)設(shè)置為了1,所以請求3也會壓倒數(shù)據(jù)庫上,這種情況,會隨著存入的數(shù)據(jù)增加而增加。

第二方面,布隆過濾器沒法刪除數(shù)據(jù),刪除數(shù)據(jù)存在以下兩種困境:
一是,由于有誤判的可能,并不確定數(shù)據(jù)是否存在數(shù)據(jù)庫里,例如數(shù)據(jù)包3。
二是,當(dāng)你刪除某一個數(shù)據(jù)包對應(yīng)位圖上的標(biāo)志后,可能影響其他的數(shù)據(jù)包,例如上面例子中,如果刪除數(shù)據(jù)包1,也就意味著會將bitmap1,3,6位設(shè)置為0,此時(shí)數(shù)據(jù)包2來請求時(shí),會顯示不存在,因?yàn)?,6兩位已經(jīng)被設(shè)置為0。
布隆過濾器增強(qiáng)版
為了解決上面布隆過濾器的問題,出現(xiàn)了一個增強(qiáng)版的布隆過濾器(Counting Bloom Filter),這個過濾器的思路是將布隆過濾器的bitmap更換成數(shù)組,當(dāng)數(shù)組某位置被映射一次時(shí)就+1,當(dāng)刪除時(shí)就-1,這樣就避免了普通布隆過濾器刪除數(shù)據(jù)后需要重新計(jì)算其余數(shù)據(jù)包Hash的問題,但是依舊沒法避免誤判。

布谷鳥過濾器
為了解決布隆過濾器不能刪除元素的問題, 論文《Cuckoo Filter:Better Than Bloom》作者提出了布谷鳥過濾器。相比布谷鳥過濾器,布隆過濾器有以下不足:查詢性能弱、空間利用效率低、不支持反向操作(刪除)以及不支持計(jì)數(shù)。
查詢性能弱是因?yàn)椴悸∵^濾器需要使用多個 hash 函數(shù)探測位圖中多個不同的位點(diǎn),這些位點(diǎn)在內(nèi)存上跨度很大,會導(dǎo)致CPU緩存行命中率低。
空間效率低是因?yàn)樵谙嗤恼`判率下,布谷鳥過濾器的空間利用率要明顯高于布隆,空間上大概能節(jié)省40%多。不過布隆過濾器并沒有要求位圖的長度必須是2的指數(shù),而布谷鳥過濾器必須有這個要求。從這一點(diǎn)出發(fā),似乎布隆過濾器的空間伸縮性更強(qiáng)一些。
不支持反向刪除操作這個問題著實(shí)是擊中了布隆過濾器的軟肋。在一個動態(tài)的系統(tǒng)里面元素總是不斷的來也是不斷的走。布隆過濾器就好比是印跡,來過就會有痕跡,就算走了也無法清理干凈。比如你的系統(tǒng)里本來只留下 1kw 個元素,但是整體上來過了上億的流水元素,布隆過濾器很無奈,它會將這些流失的元素的印跡也會永遠(yuǎn)存放在那里。隨著時(shí)間的流失,這個過濾器會越來越擁擠,直到有一天你發(fā)現(xiàn)它的誤判率太高了,不得不進(jìn)行重建。
布谷鳥過濾器在論文里聲稱自己解決了這個問題,它可以有效支持反向刪除操作。而且將它作為一個重要的賣點(diǎn),誘惑你們放棄布隆過濾器改用布谷鳥過濾器。
為啥要取名布谷鳥呢?
有個成語,「鳩占鵲巢」,布谷鳥也是,布谷鳥從來不自己筑巢。它將自己的蛋產(chǎn)在別人的巢里,讓別人來幫忙孵化。待小布谷鳥破殼而出之后,因?yàn)椴脊萨B的體型相對較大,它又將養(yǎng)母的其它孩子(還是蛋)從巢里擠走 —— 從高空摔下夭折了。
布谷鳥哈希
最簡單的布谷鳥哈希結(jié)構(gòu)是一維數(shù)組結(jié)構(gòu),會有兩個 hash 算法將新來的元素映射到數(shù)組的兩個位置。如果兩個位置中有一個位置為空,那么就可以將元素直接放進(jìn)去。但是如果這兩個位置都滿了,它就不得不「鳩占鵲巢」,隨機(jī)踢走一個,然后自己霸占了這個位置。
p1 = hash1(x) % l
p2 = hash2(x) % l
不同于布谷鳥的是,布谷鳥哈希算法會幫這些受害者(被擠走的蛋)尋找其它的窩。因?yàn)槊恳粋€元素都可以放在兩個位置,只要任意一個有空位置,就可以塞進(jìn)去。所以這個傷心的被擠走的蛋會看看自己的另一個位置有沒有空,如果空了,自己挪過去也就皆大歡喜了。但是如果這個位置也被別人占了呢?好,那么它會再來一次「鳩占鵲巢」,將受害者的角色轉(zhuǎn)嫁給別人。然后這個新的受害者還會重復(fù)這個過程直到所有的蛋都找到了自己的巢為止。
布谷鳥哈希的問題
但是會遇到一個問題,那就是如果數(shù)組太擁擠了,連續(xù)踢來踢去幾百次還沒有停下來,這時(shí)候會嚴(yán)重影響插入效率。這時(shí)候布谷鳥哈希會設(shè)置一個閾值,當(dāng)連續(xù)占巢行為超出了某個閾值,就認(rèn)為這個數(shù)組已經(jīng)幾乎滿了。這時(shí)候就需要對它進(jìn)行擴(kuò)容,重新放置所有元素。
還會有另一個問題,那就是可能會存在擠兌循環(huán)。比如兩個不同的元素,hash 之后的兩個位置正好相同,這時(shí)候它們一人一個位置沒有問題。但是來了第三個元素,它hash之后的位置也和它們一樣,很明顯,會出現(xiàn)擠兌的循環(huán)。不過讓三個不同的元素經(jīng)過兩次 hash 后位置還一樣,這樣的概率并不是很高,除非你的 hash 算法太挫了。
布谷鳥哈希算法對待這種擠兌循環(huán)的態(tài)度就是認(rèn)為數(shù)組太擁擠了,需要擴(kuò)容(實(shí)際上并不是這樣)。
優(yōu)化
上面的布谷鳥哈希算法的平均空間利用率并不高,大概只有50%。到了這個百分比,就會很快出現(xiàn)連續(xù)擠兌次數(shù)超出閾值。這樣的哈希算法價(jià)值并不明顯,所以需要對它進(jìn)行改良。
改良的方案之一是增加hash函數(shù),讓每個元素不止有兩個巢,而是三個巢、四個巢。這樣可以大大降低碰撞的概率,將空間利用率提高到95%左右。
另一個改良方案是在數(shù)組的每個位置上掛上多個座位,這樣即使兩個元素被hash在了同一個位置,也不必立即「鳩占鵲巢」,因?yàn)檫@里有多個座位,你可以隨意坐一個。除非這多個座位都被占了,才需要進(jìn)行擠兌。很明顯這也會顯著降低擠兌次數(shù)。這種方案的空間利用率只有85%左右,但是查詢效率會很高,同一個位置上的多個座位在內(nèi)存空間上是連續(xù)的,可以有效利用CPU高速緩存。
所以更加高效的方案是將上面的兩個改良方案融合起來,比如使用4個hash函數(shù),每個位置上放2個座位。這樣既可以得到時(shí)間效率,又可以得到空間效率。這樣的組合甚至可以將空間利用率提到高99%,這是非常了不起的空間效率。
布谷鳥過濾器
布谷鳥過濾器和布谷鳥哈希結(jié)構(gòu)一樣,它也是一維數(shù)組,但是不同于布谷鳥哈希的是,布谷鳥哈希會存儲整個元素,而布谷鳥過濾器中只會存儲元素的指紋信息(幾個bit,類似于布隆過濾器)。這里過濾器犧牲了數(shù)據(jù)的精確性換取了空間效率。正是因?yàn)榇鎯Φ氖窃氐闹讣y信息,所以會存在誤判率,這點(diǎn)和布隆過濾器如出一轍。
首先布谷鳥過濾器還是只會選用兩個hash函數(shù),但是每個位置可以放置多個座位。這兩個hash函數(shù)選擇的比較特殊,因?yàn)檫^濾器中只能存儲指紋信息。當(dāng)這個位置上的指紋被擠兌之后,它需要計(jì)算出另一個對偶位置。而計(jì)算這個對偶位置是需要元素本身的,我們來回憶一下前面的哈希位置計(jì)算公式。
fp = fingerprint(x)
p1 = hash1(x) % l
p2 = hash2(x) % l
我們知道了p1和x的指紋,是沒辦法直接計(jì)算出p2的。
特殊的 hash 函數(shù)
布谷鳥過濾器巧妙的地方就在于設(shè)計(jì)了一個獨(dú)特的hash函數(shù),使得可以根據(jù)p1和元素指紋直接計(jì)算出p2,而不需要完整的x元素。
fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp) // 異或
從上面的公式中可以看出,當(dāng)我們知道 fp 和 p1,就可以直接算出p2。同樣如果我們知道p2和fp,也可以直接算出p1 —— 對偶性。
p1 = p2 ^ hash(fp)
所以我們根本不需要知道當(dāng)前的位置是p1 還是p2,只需要將當(dāng)前的位置和hash(fp) 進(jìn)行異或計(jì)算就可以得到對偶位置。而且只需要確保 hash(fp) != 0 就可以確保 p1 != p2,如此就不會出現(xiàn)自己踢自己導(dǎo)致死循環(huán)的問題。
也許你會問為什么這里的hash函數(shù)不需要對數(shù)組的長度取模呢?實(shí)際上是需要的,但是布谷鳥過濾器強(qiáng)制數(shù)組的長度必須是2的指數(shù),所以對數(shù)組的長度取模等價(jià)于取hash值的最后n位。在進(jìn)行異或運(yùn)算時(shí),忽略掉低n位之外的其它位就行。將計(jì)算出來的位置p保留低n位就是最終的對偶位置。
來源:cnblogs.com/Courage129/p/14337466.html
2022-07-29
2022-07-28
2022-07-27
2022-07-26
2022-07-25
2022-07-24
如果你覺得這篇文章不錯,那么,下篇通常會更好。備注“公眾號”添加微信好友(微信號:zhuan2quan)。
