Cuckoo Filter:設計與實現

發表於2016-11-15

Cuckoo Filter:設計與實現

對於海量資料處理業務,我們通常需要一個索引資料結構,用來幫助查詢,快速判斷資料記錄是否存在,這種資料結構通常又叫過濾器(filter)。考慮這樣一個場景,上網的時候需要在瀏覽器上輸入URL,這時瀏覽器需要去判斷這是否一個惡意的網站,它將對本地快取的成千上萬的URL索引進行過濾,如果不存在,就放行,如果(可能)存在,則向遠端服務端發起驗證請求,並回饋客戶端給出警告。

索引的儲存又分為有序和無序,前者使用關聯式容器,比如B樹,後者使用雜湊演算法。這兩類演算法各有優劣:比如,關聯式容器時間複雜度穩定O(logN),且支援範圍查詢;又比如雜湊演算法的查詢、增刪都比較快O(1),但這是在理想狀態下的情形,遇到碰撞嚴重的情況,雜湊演算法的時間複雜度會退化到O(n)。因此,選擇一個好的雜湊演算法是很重要的。

時下一個非常流行的雜湊索引結構就是bloom filter,它類似於bitmap這樣的hashset,所以空間利用率很高。其獨特的地方在於它使用多個雜湊函式來避免雜湊碰撞,如圖所示(來源wikipedia),bit陣列初始化為全0,插入x時,x被3個雜湊函式分別對映到3個不同的bit位上並置1,查詢x時,只有被這3個函式對映到的bit位全部是1才能說明x可能存在,但凡至少出現一個0表示x肯定不存在。

Bloom_filter

但是,bloom filter的這種點陣圖模式帶來兩個問題:一個是誤報(false positives),在查詢時能提供“一定不存在”,但只能提供“可能存在”,因為存在其它元素被對映到部分相同bit位上,導致該位置1,那麼一個不存在的元素可能會被誤報成存在;另一個是漏報(false nagatives),同樣道理,如果刪除了某個元素,導致該對映bit位被置0,那麼本來存在的元素會被漏報成不存在。由於後者問題嚴重得多,所以bloom filter必須確保“definitely no”從而容忍“probably yes”,不允許元素的刪除。

關於元素刪除的問題,一個改良方案是對bloom filter引入計數,但這樣一來,原來每個bit空間就要擴張成一個計數值,空間效率上又降低了。

Cuckoo Hashing

為了解決這一問題,本文引入了一種新的雜湊演算法——cuckoo filter,它既可以確保該元素存在的必然性,又可以在不違背此前提下刪除任意元素,僅僅比bitmap犧牲了微量空間效率。先說明一下,這個演算法的思想來源是一篇CMU論文,筆者按照其思路用C語言做了一個簡單實現(Github),附上對一段文字資料進行匯入匯出的正確性測試。

接下來我會結合自己的示例程式碼講解雜湊演算法的實現。我們先來看看cuckoo hashing有什麼特點,它的雜湊函式是成對的(具體的實現可以根據需求設計),每一個元素都是兩個,分別對映到兩個位置,一個是記錄的位置,另一個是備用位置。這個備用位置是處理碰撞時用的,這就要說到cuckoo這個名詞的典故了,中文名叫布穀鳥,這種鳥有一種即狡猾又貪婪的習性,它不肯自己築巢,而是把蛋下到別的鳥巢裡,而且它的幼鳥又會比別的鳥早出生,布穀幼鳥天生有一種殘忍的動作,幼鳥會拼命把未出生的其它鳥蛋擠出窩巢,今後以便獨享“養父母”的食物。藉助生物學上這一典故,cuckoo hashing處理碰撞的方法,就是把原來佔用位置的這個元素踢走,不過被踢出去的元素還要比鳥蛋幸運,因為它還有一個備用位置可以安置,如果備用位置上還有人,再把它踢走,如此往復。直到被踢的次數達到一個上限,才確認雜湊表已滿,並執行rehash操作。如下圖所示:

cuckoo_preview

我們不禁要問發生雜湊碰撞之前的空間利用率是多少呢?不幸地告訴你,一維陣列的雜湊表上跟其它雜湊函式沒什麼區別,也就50%而已。但如果是二維的呢?

一個改進的雜湊表如下圖所示,每個桶(bucket)有4路槽位(slot)。當雜湊函式對映到同一個bucket中,在其它三路slot未被填滿之前,是不會有元素被踢的,這大大緩衝了碰撞的機率。筆者自己的簡單實現上測過,採用二維雜湊表(4路slot)大約80%的佔用率(CMU論文資料據說達到90%以上,應該是擴大了slot關聯數目所致)。

cuckoo hashing

Cuckoo Filter 設計與實現

cuckoo hashing的原理介紹完了,下面就來演示一下筆者自己實現的一個cuckoo filter應用,簡單易用為主,不到500行C程式碼。應用場景是這樣的:假設有一段文字資料,我們把它通過cuckoo filter匯入到一個虛擬的flash中,再把它匯出到另一個文字檔案中。flash儲存的單元頁面是一個log_entry,裡面包含了一對key/value,value就是文字資料,key就是這段大小的資料的SHA1值(照理說SHA1是可以通過資料來源生成,沒必要儲存到flash,但這裡主要為了測試而故意設計的,萬一key和value之間沒有推導關係呢)。

順便說明一下DAT_LEN設定,之前我們設計了一個虛擬flash(用malloc模擬出來),由於flash的單位是按頁大小SECTOR_SIZE讀寫,這裡假設每個log_entry正好一個頁大小,當然可以根據實際情況調整。

以上是flash的儲存結構,至於雜湊表裡的slot有三個成員tag,status和offset,分別是雜湊值,狀態值和在flash的偏移位置。其中status有三個列舉值:AVAILIBLE,OCCUPIED,DELETED,分別表示這個slot是空閒的,佔用的還是被刪除的。至於tag,按理說應該有兩個雜湊值,對應兩個雜湊函式,但其中一個已經對應bucket的位置上了,所以我們只要儲存另一個備用bucket的位置就行了,這樣萬一被踢,只要用這個tag就可以找到它的另一個安身之所。

乍看之下size有點大是嗎?沒關係,你也可以根據情況調整資料型別大小,比如uint16_t,這裡僅僅為了測試正確性。

至於雜湊表以及bucket和slot的建立見初始化程式碼。buckets是一個二級指標,每個bucket指向4個slot大小的快取,即4路slot,那麼bucket_num也就是slot_num的1/4。這裡我們故意把slot_num調小了點,為的是測試rehash的發生。

下面是雜湊函式的設計,這裡有兩個,前面提到既然key是20位元組的SHA1值,我們就可以分別是對key的低32位和高32位進行位運算,只要bucket_num滿足2的冪次方,我們就可以將key的一部分同bucket_num – 1相與,就可以定位到相應的bucket位置上,注意bucket_num隨著rehash而增大,雜湊函式簡單的好處是求雜湊值十分快。

終於要講解cuckoo filter最重要的三個操作了——查詢、插入還有刪除。查詢操作是簡單的,我們對傳進來的引數key進行兩次雜湊求值tag[0]和tag[1],並先用tag[0]定位到bucket的位置,從4路slot中再去對比tag[1]。只有比中了tag後,由於只是key的一部分,我們再去從flash中驗證完整的key,並把資料在flash中的偏移值read_addr輸出返回。相應的,如果bucket[tag[0]]的4路slot都沒有比中,我們再去bucket[tag[1]]中比對(程式碼略),如果還比不中,可以肯定這個key不存在。這種設計的好處就是減少了不必要的flash讀操作,每次比對的是記憶體中的tag而不需要完整的key。

接下來先將簡單的刪除操作,之所以簡單是因為delete除了將相應slot的狀態值設定一下之外,其實什麼都沒有幹,也就是說它不會真正到flash裡面去把資料清除掉。為什麼?很簡單,沒有必要。還有一個原因,flash的寫操作之前需要擦除整個頁面,這種擦除是會折壽的,所以很多flash支援隨機讀,但必須保持順序寫。

瞭解了flash的讀寫特性,你就知道為啥插入操作在flash層面要設計成append。不過我們這裡不討論過多flash細節,雜湊表層面的插入邏輯其實跟查詢差不多,我就不貼程式碼了。這裡要貼的是如何判斷並處理碰撞,其實這裡也沒啥玄機,就是用old_tag和old_offset儲存一下臨時變數,以便一個元素被踢出去之後還能找到備用的安身之所。但這裡會有一個判斷,每次踢人都會計數,當alt_cnt大於512時候表示雜湊表真的快滿了,這時候需要rehash了。

rehash的邏輯也很簡單,無非就是把雜湊表中的buckets和slots重新realloc一下,空間擴充套件一倍,然後再從flash中的key重新插入到新的雜湊表裡去。這裡有個陷阱要注意,千萬不能有相同的key混進來!雖然cuckoo hashing不像開鏈法那樣會退化成O(n),但由於每個元素有兩個雜湊值,而且每次計算的雜湊值隨著雜湊表rehash的規模而不同,相同的key並不能立即檢測到衝突,但當相同的key達到一定規模後,噩夢就開始了,由於rehash裡面有插入操作,一旦在這裡觸發碰撞,又會觸發rehash,這時就是一個rehash不斷遞迴的過程,由於其中老的記憶體沒釋放,新的記憶體不斷重新分配,整個程式就如同陷入DoS攻擊一般癱瘓了。所以每次插入操作前一定要判斷一下key是否已經存在過,並且對rehash裡的插入使用碰撞斷言防止此類情況發生。筆者在測試中不幸中了這樣的彩蛋,除錯了大半天才搞清楚原因,搞IT的同學們記住一定要防小人啊~

到此為止程式碼的邏輯還是比較簡單,使用效果如何呢?我來幫你找個大檔案unqlite.c測試一下,這是一個嵌入式資料庫原始碼,共59959行程式碼。作為需要匯入的檔案,編譯我們的cuckoo filter,然後執行:

你會發現生成output.c正好也是59959行程式碼,一分不差,probably yes終於變成了definitely yes。同時也可以看到,cuckoo filter真的很快!如果你想看hashing的整個過程,可以參照README裡把除錯巨集開啟。最後,歡迎給這個小玩意提交PR!

相關文章