布穀鳥過濾器

基本概念

布穀鳥過濾器 是一種節省內存空間的概率數據結構,基於 布穀鳥哈希算法 實現的過濾器,和 布隆過濾器 一樣,用於檢測指定元素是否存在於某個集合中,返回結果語義是 “元素一定不存在” 或 “有較大可能存在”。

和布隆過濾器比較

優點

缺點

PS: 如果只需要保證 一定不存在 語義,那麼刪除時不論是否存在重複元素,直接刪除即可。

布穀鳥哈希算法描述

算法示例

圖 (a) 算法過程描述:

圖 (b) 算法過程描述:

僞代碼

按照算法描述,翻譯成僞代碼如下 (不考慮併發情況):

package main

import (
 "math/rand"
 "time"
)

const (
 TSize = 1024 // 假設表的大小爲 1024
)

var (
 T1 = make(map[int]any, TSize) // 哈希表 1
 T2 = make(map[int]any, TSize) // 哈希表 2
)

// 哈希函數 1
// 返回隨機數作爲哈希值
func H1(x any) int {
 rand.Seed(time.Now().UnixNano())
 return rand.Intn(TSize)
}

// 哈希函數 2
// 返回隨機數作爲哈希值
func H2(x any) int {
 rand.Seed(time.Now().UnixNano())
 return rand.Intn(TSize)
}

// 擴容操作,這裏省略具體實現
func ReHash() {
 // do something
}

// 隨機踢出一個元素
// 返回踢出的哈希桶編號以及元素索引
func KicksOut(x any) any {
 // 哈希桶已滿
 if len(T1) == TSize && len(T2) == TSize {
  return nil
 }

 rand.Seed(time.Now().UnixNano())
 n := rand.Intn(2)

 var y any // 被踢出的元素 y

 // 直接利用 Go map 的無序遍歷特性
 // 遍歷時將 [第一個元素] 作爲隨機元素踢出
 if n == 0 {
  // 從 T1 桶隨機選擇一個元素踢出
  for k, v := range T1 {
   // 將 x 插入到 y 的桶 (覆蓋式)
   T1[k] = x
   y = v
   break
  }
 } else {
  // 從 T2 桶隨機選擇一個元素踢出
  for k, v := range T2 {
   // 將 x 插入到 y 的桶 (覆蓋式)
   T2[k] = x
   y = v
   break
  }
 }

 return y
}

// 插入元素
func Insert(x any) bool {
 idx1, idx2 := H1(x), H2(x)

 // 如果 T1 桶對應桶爲空,則插入 T1
 if _, ok := T1[idx1]; !ok {
  T1[idx1] = x
  return true
 }
 // 如果 T2 桶對應桶爲空,則插入 T2
 if _, ok := T2[idx2]; !ok {
  T2[idx2] = x
  return true
 }

 y := KicksOut(x)
 // 哈希桶已滿,進行擴容
 if y == nil {
  ReHash()
 }

 // 插入元素 y
 return Insert(y)
}

不同的版本

一個哈希桶

兩個哈希函數和一個哈希桶

如圖所示,在未發生哈希碰撞之前,哈希桶的利用率只有 50%。

四路哈希桶

兩個哈希函數和四路哈希桶

如圖所示是一個改進的哈希桶,每個桶有 4 個槽位,當哈希函數映射到同一個桶時,其它 3 個槽位如果有空位,那麼就不會有元素被踢出,降低了碰撞概率。

布穀鳥過濾器

布穀鳥過濾器布穀鳥哈希算法 進行了如下優化改進:

通過異或計算尋找新桶

異或計算性質: 三個值中的任意兩個值進行異或計算,都可以得出第三個值。

示例代碼: 數字 1, 2, 3 執行異或計算

package main

func main() {
 println(1 ^ 2) // 3
 println(3 ^ 1) // 2
 println(3 ^ 2) // 1
}

布穀鳥過濾器 爲了節省內存,保存的是 x 的指紋信息,而非源值,那麼當某個元素 x 被踢出時,需要找一個新桶 h2(x),如何在不損失 x 的指紋信息的情況下,計算新桶 (候選桶) 並存儲呢?

布穀鳥過濾器 採用了巧妙的算法: 將桶 h1(x) 和 x 的指紋信息哈希值 hash( finger(x) ) 進行 異或計算 得出新的桶,這樣當新桶的值後面被踢出時, 可以通過 異或計算 反轉得到 x 的指紋信息。

對於元素 x, 計算兩個哈希值:

h1(x) = hash(x), h2(x) = h1(x) ⊕ hash(x’s fingerprint)

踢出桶上的元素時 (不管該桶是 h1(x) 還是 h2(x)),直接使用該桶的索引 index 和存儲在桶中的指紋信息計算備用桶 j

j = i ⊕ hash(fingerprint)

均衡分配

此外,指紋與桶進行 異或計算 之前會進行哈希,從而在表中均衡分配。如果備用位使用 i ⊕ hash(fingerprint) 計算時不將指紋進行哈希,且指紋的大小與表的大小相比很小,那麼踢出的元素最終會落在鄰近的桶。

例如使用 8 位指紋,踢出的元素將被放置到離桶 i 最多 256 (2^8) 的桶,因爲 異或計算 將改變桶索引的低 8 位,而高位不會改變。 哈希指紋可以確保這些元素可以重新定位到哈希表的不同的桶中,達到均衡分配,減少哈希碰撞並提高表的利用率。

空間優化

較大的桶可以提高表的利用率,使用 2 個哈希函數,桶大小爲 1 時,負載因子爲 50% (上面提到的第一種布穀鳥哈希算法版本), 但是當使用桶大小 2, 4, 8 時,負載因子分別會增加到 84%, 95%, 98%

實驗數據表明,當誤判率 r > 0.002 時,每個桶使用 2 個槽位比 4 個槽位效果更好,當 r 爲到 0.00001 < r ≤ 0.002 時,每個桶 4 個槽位可以最小化空間。

半排序桶

半排序的本質是: 對每個指紋取 4 位,該 4 位可以表示爲一個 16 進制,對於 b 個指紋的 4 位存儲就可以表示爲 b 個 16 進制,進行 重複組合計算 後, 可以通過索引其位置來找到對應的指紋 (也就是某個組合值)。

假設每個桶包含 b = 4 個指紋,每個指紋 f = 4 bit,一個未壓縮的桶佔 4 x 4 = 16 bit。但是如果我們對每個 4 位指紋進行排序(空項被視爲存儲值爲 "0" 的指紋), 那麼共有 3876 個重複組合值。如果我們預先計算並將 3876 個值存儲在一個額外的表中 (表中每個位置表示一個指紋組合),並將桶替換爲預先計算好的表, 那麼桶可以用 12 bit 表示整個表 (2 ^ 12 = 4096> 3876),而不是 16 bit 表示桶,這樣算下來,平均每個指紋可以節省 1 bit

3876 是怎樣計算出來的?

重複組合數學公式

其中 n 表示被選擇的東西個數, r 表示選擇個數,(順序可以重複)。

根據數學公式,我們可以編寫如下代碼:

package main

import "fmt"

// 計算重複組合數量
func multiCombination(n, r int) int {
 if n == 0 || r == 0 {
  return 0
 }
 numerator := 1
 denominator := 1
 for i := 1; i <= r; i++ {
  numerator *= n + i - 1
  denominator *= i
 }
 return numerator / denominator
}

func main() {
 fmt.Printf("n = %d, r = %d, combinations = %d\n", 16, 4, multiCombination(16, 4))
}

在上面的代碼中,計算了在 16 bit4 bit 指紋的重複組合數量。

$ go run main.go

# 輸出如下
n = 16, r = 4, combinations = 3876

開源庫

有了前文的理論基礎後,接下來一起探索下具體的代碼實現。筆者選擇開源的 linvon/cuckoo-filter 作爲研究 布穀鳥過濾器 代碼實現,版本爲 v0.4.0

這個庫的優點

這裏直接引用庫作者的原文:


在翻閱了 Github 上 cuckoofilter 的 golang 實現後,發現已有的實現都存在一些缺點:

因爲作者的場景需要更優的空間和自定的假陽性率,因此移植了原論文的 C++ 實現,並做了一些優化,主要包括:


示例

package main

import (
 "fmt"
 "github.com/linvon/cuckoo-filter"
)

func main() {
 // 初始化一個布穀鳥過濾器
 // 使用半排序桶
 //    每個桶包含 4 個指紋, 每個指紋 4 bits
 // 最大存放元素數量 4096
 cf := cuckoo.NewFilter(4, 4, 1<<12, cuckoo.TableTypePacked)

 // 添加一些元素
 cf.Add([]byte(`Hello World`))
 cf.Add([]byte(`Hello Golang`))

 // 檢測元素是否存在
 fmt.Printf("%v\n", cf.Contain([]byte(`Hello World`)))
 fmt.Printf("%v\n", cf.Contain([]byte(`Hello Golang`)))
 fmt.Printf("%v\n", cf.Contain([]byte(`Hello Rust`)))

 // 輸出元素數量
 fmt.Printf("filter size = %d\n", cf.Size())

 // 刪除元素
 cf.Delete([]byte(`Hello World`))

 // 輸出過濾器統計信息
 fmt.Println("\n", cf.Info())
}
$ go run main.go

# 輸出如下

true
true
false
filter size = 2

CuckooFilter Status:

PackedHashtable with tag size: 4 bits
4 packed bits(3 bits after compression) and 0 direct bits
Associativity: 4
Total # of rows: 2048
Total # slots: 8192

Keys stored: 1
Load factor: 0.0001220703125
Hashtable size: 3 KB
bit/key:   24632

源碼解析

接口

linvon/cuckoo-filter 實現了 普通單表 和空間優化的 基於半排序桶的壓縮表,將兩者的通用部分抽象爲 table 接口,通過運行時的 工廠方法 可以在初始化時根據不同的參數生成不同的過濾器。

const (
 // 普通表
 TableTypeSingle = 0

 // 壓縮表
 TableTypePacked = 1
)

type table interface {
  ...
}

func getTable(tableType uint) interface{} {
 switch tableType {
 case TableTypePacked:
  return NewPackedTable()
 default:
  return NewSingleTable()
 }
}

UML

過濾器數據結構

victimCache 結構體表示過濾器執行 Add 操作時被 踢出 的元素對象。

type victimCache struct {
 index uint
 tag   uint32
 used  bool
}

Filter 結構體表示 過濾器 對象,非常簡潔,只有三個字段: 被踢出元素, 元素數量, 底層用於存儲的表實例

type Filter struct {
 victim   victimCache
 numItems uint
 table    table
}

初始化過濾器

func NewFilter(tagsPerBucket, bitsPerItem, maxNumKeys, tableType uint) *Filter {
 // 根據表內存放元素和每個桶的元素指紋數量,計算需要的桶的數量
 numBuckets := getNextPow2(uint64(maxNumKeys / tagsPerBucket))

 // 如果表的負載因子過高,就將桶的數量擴容 (翻 1 倍)
 // 負載因子如何計算出來的呢?
 //    框架這裏進行了 3 個硬編碼的值:
 //        默認:      0.99
 //        桶大小爲 2: 0.85
 //        桶大小爲 4: 0.96
 if float64(maxNumKeys)/float64(numBuckets*tagsPerBucket) > maxLoadFactor(tagsPerBucket) {
  numBuckets <<= 1
 }

 // 桶最少得有 1 個
 if numBuckets == 0 {
  numBuckets = 1
 }

 // 工廠方法根據參數類型生成過濾器
 table := getTable(tableType).(table)

 // 表初始化
 _ = table.Init(tagsPerBucket, bitsPerItem, numBuckets, nil)
 return &Filter{
  table: table,
 }
}

添加元素

Add 方法添加一個元素到表中,返回是否添加成功。

func (f *Filter) Add(item []byte) bool {
 // 如果被踢出的元素沒有找到可用的桶
 // 那麼繼續添加元素只會進入惡行循環,降低性能
 // 此時直接返回即可
 if f.victim.used {
  return false
 }

 // 計算哈希值和桶索引,然後將元素添加到表中對應的桶
 i, tag := f.generateIndexTagHash(item)
 return f.addImpl(i, tag)
}

查找元素

Contain 方法檢測給定元素是否存在於表中。

func (f *Filter) Contain(key []byte) bool {
 // 計算元素的哈希值和桶索引
 i1, tag := f.generateIndexTagHash(key)
 // 計算候選桶索引
 i2 := f.altIndex(i1, tag)

 hit := f.victim.used && tag == f.victim.tag && (i1 == f.victim.index || i2 == f.victim.index)

 // 滿足以下兩個條件之一,說明參數元素存在於表中:
 //    1. 參數元素和表內被踢出元素的哈希值以及桶位置相同
 //    2. 在表的某個桶內找到了相同的參數元素哈希
 if hit || f.table.FindTagInBuckets(i1, i2, tag) {
  return true
 }
 return false
}

計算元素數量

Size 方法計算表內當前存儲的元素數量,如果 被踢出元素 一直沒有找到可用的桶,元素數量 + 1。

func (f *Filter) Size() uint {
 var c uint
 if f.victim.used {
  c = 1
 }
 return f.numItems + c
}

計算負載因子

LoadFactor 方法計算當前表的 負載因子, 計算公式爲:

表內當前元素數量 / 表內可存儲元素數量

func (f *Filter) LoadFactor() float64 {
 return 1.0 * float64(f.Size()) / float64(f.table.SizeInTags())
}

重置過濾器

Reset 方法會重置過濾器,相當於重新初始化。

func (f *Filter) Reset() {
    // 底層表內元素初始化爲 0
    f.table.Reset()

    // 元素計數歸 0
    f.numItems = 0

    // 重置被踢出元素
    f.victim.index = 0
    f.victim.tag = 0
    f.victim.used = false
}

計算誤判率

FalsePositiveRate 方法計算 過濾器誤判率需要注意的是,該方法會調用 Reset 方法重置 過濾器

func (f *Filter) FalsePositiveRate() float64 {
 n1 := make([]byte, 4)

 f.Reset() // 重置過濾器

 // 獲取底層表可存儲元素數量
 n := f.table.SizeInTags()
 // 循環向表內添加元素,元素爲 [0 ... n]
 for i := uint32(0); i < uint32(n); i++ {
  binary.BigEndian.PutUint32(n1, i)
  f.Add(n1)
 }

 // 計算誤判率採用的檢測次數 (這裏硬編碼爲 10 W)
 var rounds uint32 = 100000
 // 誤判計數
 fp := 0
 for i := uint32(0); i < rounds; i++ {
  // 給定一個不可能存在表中的元素
  binary.BigEndian.PutUint32(n1, i+uint32(n)+1)
  // 正常情況下,Contain 方法返回的都是 false
  if f.Contain(n1) {
   // 如果 Contain 方法返回 true, 則屬於誤判
   // 誤判計數 + 1
   fp++
  }
 }
 f.Reset()  // 重置過濾器

 // 誤判計數 / 檢測次數 = 誤判率
 return float64(fp) / float64(rounds)
}

哈希算法

module github.com/linvon/cuckoo-filter

go 1.14

require github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165

go.mod 文件定義中可以看到,linvon/cuckoo-filter 使用的哈希算法是 MetroHash

MetroHash 是一個哈希函數算法,可用於計算輸入數據的 64 位128 位哈希值,支持增量式哈希計算,具有較高的性能和較低的碰撞率概率。

func (f *Filter) altIndex(index uint, tag uint32) uint {
 // 0x5bd1e995 是 MurmurHash2 算法的哈希常量
 return f.indexHash(uint32(index) ^ (tag * 0x5bd1e995))
}

此外,在計算 候選桶 的索引時,也用到了 Murmur2 算法。

省略部分

普通單表壓縮表 的底層表存儲實現,由於時間關係不再展開分析,感興趣的讀者可以自行閱讀源代碼。

調用關係圖

調用關係示意圖

小結

本文概括了 布穀鳥過濾器 的算法描述,並對比了其和 布隆過濾器 的主要差異。在代碼實現方面,筆者選擇了開源的 linvon/cuckoo-filter[1], 着重分析了庫的接口設計和主要 API 方法實現。最後順帶提一下,如果讀者決定使用 linvon/cuckoo-filter 到項目中,需要注意的是: 庫內部並沒有做 併發限制,使用 Add, Contain 等方法時可能會遇到常見的 併發競態 問題,這就要求調用方在使用時需要做好相應的併發處理。

Reference

鏈接

[1]

linvon/cuckoo-filter: https://github.com/linvon/cuckoo-filter

[2]

Cuckoo Filter: Practically Better Than Bloom: http://www.cs.cmu.edu/~binfan/papers/conext14_cuckoofilter.pdf

[3]

Cuckoo filter: https://en.wikipedia.org/wiki/Cuckoo_filter

[4]

布穀鳥哈希和布穀鳥過濾器: https://www.qtmuniao.com/2021/12/07/cuckoo-hash-and-cuckoo-filter/

[5]

布穀鳥過濾器實戰對比與調參指南: http://www.linvon.cn/posts/cuckoo_guide/

[6]

布穀鳥過濾器:實際上優於布隆過濾器: http://www.linvon.cn/posts/cuckoo/

[7]

linvon/cuckoo-filter: https://github.com/linvon/cuckoo-filter

[8]

Cuckoo Hashing Visualization: http://www.lkozma.net/cuckoo_hashing_visualization/

[9]

List of hash functions: https://en.wikipedia.org/wiki/List_of_hash_functions

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pmx73QJx5bGMRkBn122Afw