對於海量資料處理業務,我們通常需要一個索引資料結構,用來幫助查詢,快速判斷資料記錄是否存在,這種資料結構通常又叫過濾器(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的這種點陣圖模式帶來兩個問題:一個是誤報(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操作。如下圖所示:
我們不禁要問發生雜湊碰撞之前的空間利用率是多少呢?不幸地告訴你,一維陣列的雜湊表上跟其它雜湊函式沒什麼區別,也就50%而已。但如果是二維的呢?
一個改進的雜湊表如下圖所示,每個桶(bucket)有4路槽位(slot)。當雜湊函式對映到同一個bucket中,在其它三路slot未被填滿之前,是不會有元素被踢的,這大大緩衝了碰撞的機率。筆者自己的簡單實現上測過,採用二維雜湊表(4路slot)大約80%的佔用率(CMU論文資料據說達到90%以上,應該是擴大了slot關聯數目所致)。
Cuckoo Filter 設計與實現
cuckoo hashing的原理介紹完了,下面就來演示一下筆者自己實現的一個cuckoo filter應用,簡單易用為主,不到500行C程式碼。應用場景是這樣的:假設有一段文字資料,我們把它通過cuckoo filter匯入到一個虛擬的flash中,再把它匯出到另一個文字檔案中。flash儲存的單元頁面是一個log_entry,裡面包含了一對key/value,value就是文字資料,key就是這段大小的資料的SHA1值(照理說SHA1是可以通過資料來源生成,沒必要儲存到flash,但這裡主要為了測試而故意設計的,萬一key和value之間沒有推導關係呢)。
1 2 3 4 5 6 7 8 9 10 |
#define SECTOR_SIZE (1 << 10) #define DAT_LEN (SECTOR_SIZE - 20) /* minus sha1 size */ /* The log entries store key-value pairs on flash and the * size of each entry is assumed just one sector fit. */ struct log_entry { uint8_t sha1[20]; uint8_t data[DAT_LEN]; }; |
順便說明一下DAT_LEN設定,之前我們設計了一個虛擬flash(用malloc模擬出來),由於flash的單位是按頁大小SECTOR_SIZE讀寫,這裡假設每個log_entry正好一個頁大小,當然可以根據實際情況調整。
以上是flash的儲存結構,至於雜湊表裡的slot有三個成員tag,status和offset,分別是雜湊值,狀態值和在flash的偏移位置。其中status有三個列舉值:AVAILIBLE,OCCUPIED,DELETED,分別表示這個slot是空閒的,佔用的還是被刪除的。至於tag,按理說應該有兩個雜湊值,對應兩個雜湊函式,但其中一個已經對應bucket的位置上了,所以我們只要儲存另一個備用bucket的位置就行了,這樣萬一被踢,只要用這個tag就可以找到它的另一個安身之所。
1 2 3 4 5 6 7 8 9 10 |
enum { AVAILIBLE, OCCUPIED, DELETED, }; /* The in-memory hash bucket cache is to filter keys (which is assumed SHA1) via * cuckoo hashing function and map keys to log entries stored on flash. */ struct hash_slot_cache { uint32_t tag : 30; /* summary of key */ uint32_t status : 2; /* FSM */ uint32_t offset; /* offset on flash memory */ }; |
乍看之下size有點大是嗎?沒關係,你也可以根據情況調整資料型別大小,比如uint16_t,這裡僅僅為了測試正確性。
至於雜湊表以及bucket和slot的建立見初始化程式碼。buckets是一個二級指標,每個bucket指向4個slot大小的快取,即4路slot,那麼bucket_num也就是slot_num的1/4。這裡我們故意把slot_num調小了點,為的是測試rehash的發生。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#define ASSOC_WAY (4) /* 4-way association */ struct hash_table { struct hash_slot_cache **buckets; struct hash_slot_cache *slots; uint32_t slot_num; uint32_t bucket_num; }; int cuckoo_filter_init(size_t size) { ... /* Allocate hash slots */ hash_table.slot_num = nvrom_size / SECTOR_SIZE; /* Make rehashing happen */ hash_table.slot_num /= 4; hash_table.slots = calloc(hash_table.slot_num, sizeof(struct hash_slot_cache)); if (hash_table.slots == NULL) { return -1; } /* Allocate hash buckets associated with slots */ hash_table.bucket_num = hash_table.slot_num / ASSOC_WAY; hash_table.buckets = malloc(hash_table.bucket_num * sizeof(struct hash_slot_cache *)); if (hash_table.buckets == NULL) { free(hash_table.slots); return -1; } for (i = 0; i < hash_table.bucket_num; i++) { hash_table.buckets[i] = &hash_table.slots[i * ASSOC_WAY]; } } |
下面是雜湊函式的設計,這裡有兩個,前面提到既然key是20位元組的SHA1值,我們就可以分別是對key的低32位和高32位進行位運算,只要bucket_num滿足2的冪次方,我們就可以將key的一部分同bucket_num – 1相與,就可以定位到相應的bucket位置上,注意bucket_num隨著rehash而增大,雜湊函式簡單的好處是求雜湊值十分快。
1 2 |
#define cuckoo_hash_lsb(key, count) (((size_t *)(key))[0] & (count - 1)) #define cuckoo_hash_msb(key, count) (((size_t *)(key))[1] & (count - 1)) |
終於要講解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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
static int cuckoo_hash_get(struct hash_table *table, uint8_t *key, uint8_t **read_addr) { int i, j; uint8_t *addr; uint32_t tag[2], offset; struct hash_slot_cache *slot; tag[0] = cuckoo_hash_lsb(key, table->bucket_num); tag[1] = cuckoo_hash_msb(key, table->bucket_num); /* Filter the key and verify if it exists. */ slot = table->buckets[tag[0]]; for (i = 0; i bucket_num) == slot[i].tag) { if (slot[i].status == OCCUPIED) { offset = slot[i].offset; addr = key_verify(key, offset); if (addr != NULL) { if (read_addr != NULL) { *read_addr = addr; } break; } } else if (slot[i].status == DELETED) { return DELETED; } } ... } |
接下來先將簡單的刪除操作,之所以簡單是因為delete除了將相應slot的狀態值設定一下之外,其實什麼都沒有幹,也就是說它不會真正到flash裡面去把資料清除掉。為什麼?很簡單,沒有必要。還有一個原因,flash的寫操作之前需要擦除整個頁面,這種擦除是會折壽的,所以很多flash支援隨機讀,但必須保持順序寫。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static void cuckoo_hash_delete(struct hash_table *table, uint8_t *key) { uint32_t i, j, tag[2]; struct hash_slot_cache *slot; tag[0] = cuckoo_hash_lsb(key, table->bucket_num); tag[1] = cuckoo_hash_msb(key, table->bucket_num); slot = table->buckets[tag[0]]; for (i = 0; i bucket_num) == slot[i].tag) { slot[i].status = DELETED; return; } ... } |
瞭解了flash的讀寫特性,你就知道為啥插入操作在flash層面要設計成append。不過我們這裡不討論過多flash細節,雜湊表層面的插入邏輯其實跟查詢差不多,我就不貼程式碼了。這裡要貼的是如何判斷並處理碰撞,其實這裡也沒啥玄機,就是用old_tag和old_offset儲存一下臨時變數,以便一個元素被踢出去之後還能找到備用的安身之所。但這裡會有一個判斷,每次踢人都會計數,當alt_cnt大於512時候表示雜湊表真的快滿了,這時候需要rehash了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
static int cuckoo_hash_collide(struct hash_table *table, uint32_t *tag, uint32_t *p_offset) { int i, j, k, alt_cnt; uint32_t old_tag[2], offset, old_offset; struct hash_slot_cache *slot; /* Kick out the old bucket and move it to the alternative bucket. */ offset = *p_offset; slot = table->buckets[tag[0]]; old_tag[0] = tag[0]; old_tag[1] = slot[0].tag; old_offset = slot[0].offset; slot[0].tag = tag[1]; slot[0].offset = offset; i = 0 ^ 1; k = 0; alt_cnt = 0; KICK_OUT: slot = table->buckets[old_tag[i]]; for (j = 0; j < ASSOC_WAY; j++) { if (offset == INVALID_OFFSET && slot[j].status == DELETED) { slot[j].status = OCCUPIED; slot[j].tag = old_tag[i ^ 1]; *p_offset = offset = slot[j].offset; break; } else if (slot[j].status == AVAILIBLE) { slot[j].status = OCCUPIED; slot[j].tag = old_tag[i ^ 1]; slot[j].offset = old_offset; break; } } if (j == ASSOC_WAY) { if (++alt_cnt > 512) { if (k == ASSOC_WAY - 1) { /* Hash table is almost full and needs to be resized */ return 1; } else { k++; } } uint32_t tmp_tag = slot[k].tag; uint32_t tmp_offset = slot[k].offset; slot[k].tag = old_tag[i ^ 1]; slot[k].offset = old_offset; old_tag[i ^ 1] = tmp_tag; old_offset = tmp_offset; i ^= 1; goto KICK_OUT; } return 0; } |
rehash的邏輯也很簡單,無非就是把雜湊表中的buckets和slots重新realloc一下,空間擴充套件一倍,然後再從flash中的key重新插入到新的雜湊表裡去。這裡有個陷阱要注意,千萬不能有相同的key混進來!雖然cuckoo hashing不像開鏈法那樣會退化成O(n),但由於每個元素有兩個雜湊值,而且每次計算的雜湊值隨著雜湊表rehash的規模而不同,相同的key並不能立即檢測到衝突,但當相同的key達到一定規模後,噩夢就開始了,由於rehash裡面有插入操作,一旦在這裡觸發碰撞,又會觸發rehash,這時就是一個rehash不斷遞迴的過程,由於其中老的記憶體沒釋放,新的記憶體不斷重新分配,整個程式就如同陷入DoS攻擊一般癱瘓了。所以每次插入操作前一定要判斷一下key是否已經存在過,並且對rehash裡的插入使用碰撞斷言防止此類情況發生。筆者在測試中不幸中了這樣的彩蛋,除錯了大半天才搞清楚原因,搞IT的同學們記住一定要防小人啊~
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
static void cuckoo_rehash(struct hash_table *table) { ... uint8_t *read_addr = nvrom_base_addr; uint32_t entries = log_entries; while (entries--) { uint8_t key[20]; uint32_t offset = read_addr - nvrom_base_addr; for (i = 0; i < 20; i++) { key[i] = flash_read(read_addr); read_addr++; } /* Duplicated keys in hash table which can cause eternal * hashing collision! Be careful of that! */ assert(!cuckoo_hash_put(table, key, &offset)); if (cuckoo_hash_get(&old_table, key, NULL) == DELETED) { cuckoo_hash_delete(table, key); } read_addr += DAT_LEN; } ... } |
到此為止程式碼的邏輯還是比較簡單,使用效果如何呢?我來幫你找個大檔案unqlite.c測試一下,這是一個嵌入式資料庫原始碼,共59959行程式碼。作為需要匯入的檔案,編譯我們的cuckoo filter,然後執行:
1 |
./cuckoo_db unqlite.c output.c |
你會發現生成output.c正好也是59959行程式碼,一分不差,probably yes終於變成了definitely yes。同時也可以看到,cuckoo filter真的很快!如果你想看hashing的整個過程,可以參照README裡把除錯巨集開啟。最後,歡迎給這個小玩意提交PR!