2001 年,Rasmus Pagh 和 Flemming Friche Rodler 发表了一篇只有 10 页的论文:Cuckoo Hashing。这篇论文用一个极其简洁的想法,解决了哈希表领域一个长期未解的问题:如何实现最坏情况 O(1) 的查找?
传统的开放寻址(线性探测、Robin Hood)只能保证期望 O(1)——在高负载因子或对抗性输入下,查找可能退化。链式哈希更不用说,最坏情况是 O(n)。
而 Cuckoo Hashing 的查找是确定性 O(1):查看最多 2 个位置,要么找到,要么确定不存在。
这个保证对某些场景至关重要:网络交换机的精确匹配表(ACL、路由表)需要硬实时的查找——不能接受"大部分时候 O(1),偶尔 O(n)"。DPDK 的 rte_hash 就是基于 Cuckoo Hashing 实现的。
Cuckoo Hashing:最坏 O(1) 查找的优雅设计
一、开放寻址的痛点
在进入 Cuckoo Hashing 之前,先回顾一下已有的开放寻址方案为什么不够好。
线性探测(Linear Probing)
线性探测是最简单的开放寻址策略:冲突时沿数组往后一个一个找空位。它的优点是 cache 友好——连续内存访问。但致命缺点是聚簇效应(clustering):一旦某个区域出现连续的已占 slot,后续哈希到这个区域的元素只会让连续段更长,形成正反馈。
期望情况下,负载因子 时的平均探测长度为 。当 时,平均探测长度约 50 步。更关键的是最坏情况:在对抗性输入下,线性探测的查找可以退化到 ——攻击者构造一组 key,让它们全部哈希到同一个小区域,形成一条超长的聚簇链。
Robin Hood 哈希
Robin Hood 哈希改进了线性探测:插入时,如果当前元素已经"偏移"了很远(探测序列很长),而新元素偏移更短,就把新元素让位给老元素——"劫富济贫"。这使得所有元素的探测长度的方差大幅降低。
Robin Hood 哈希的期望最大探测长度是 ——远好于线性探测。但它仍然不是 。对于需要硬实时保证的场景(如网络交换机每秒处理数十亿个包,每个包需要查一次流表), 和 之间的差距就是"能用"和"不能用"的差距。
我们需要什么
理想的哈希表应该满足:
- 查找是确定性 ——不是期望,不是摊还,是最坏情况。
- 内存访问次数有明确上界——最好是 2 次或 3 次。
- 空间利用率不能太差——50% 以上的负载因子。
Cuckoo Hashing 完美地回答了前两个需求,第三个需求通过后续的桶化和 d-ary 扩展也得到了解决。
二、Cuckoo Hashing 核心思想
名字的由来
Cuckoo(布谷鸟)是一种巢寄生鸟类——它不自己筑巢,而是把蛋下在其他鸟的巢里,让"房东"鸟帮它孵蛋。更过分的是,布谷鸟幼鸟孵化后会把巢中其他蛋推出去。
Cuckoo Hashing 的插入操作与此类似:新元素到达时,如果它的"家"(哈希位置)已经被别人占了,它会把现有元素踢出去,自己占据位置。被踢出的元素则去找自己的另一个"家"——如果那里也有人,继续踢。
两个表、两个哈希函数
准备两个哈希函数 和 ,以及两个大小为 的表 和 。
每个元素 恰好只能存在于两个位置之一: 或 。这个约束使得查找变得极其简单。
查找:恰好 2 次访存
lookup(x):
if T1[h1(x)] == x: return FOUND
if T2[h2(x)] == x: return FOUND
return NOT_FOUND
无论表中有多少元素,无论负载因子多高,查找永远只需要 2 次内存访问。没有探测链,没有链表遍历,没有任何条件分支。这就是确定性 的来源。
对比一下:链式哈希表在最坏情况下需要遍历整条链();线性探测在最坏情况下需要扫描整个数组();Robin Hood 在最坏情况下也需要 次访问。Cuckoo Hashing 彻底消除了查找路径的不确定性。
删除:同样是 O(1)
delete(x):
if T1[h1(x)] == x: T1[h1(x)] = EMPTY; return
if T2[h2(x)] == x: T2[h2(x)] = EMPTY; return
删除和查找一样简单——检查两个位置,找到就清除。不需要像线性探测那样处理"墓碑"标记,也不需要像链式哈希那样维护链表指针。
三、插入过程详解
插入是 Cuckoo Hashing 中唯一复杂的操作——也是它名字的来源。
踢人机制
insert(x):
if T1[h1(x)] is empty:
T1[h1(x)] = x; return SUCCESS
// 位置被占,踢出现有元素
y = T1[h1(x)]
T1[h1(x)] = x
if T2[h2(y)] is empty:
T2[h2(y)] = y; return SUCCESS
// 继续踢
z = T2[h2(y)]
T2[h2(y)] = y
// z 需要回到 T1 找位置...可能继续踢...
// 如果踢出链太长(超过阈值),rehash
这个"踢出链"(eviction chain)是 Cuckoo Hashing 的核心机制。
下图展示了踢出链的工作过程,以及桶化 Cuckoo Hashing 如何提高负载因子:
踢人链的具体过程
用一个具体例子说明。假设 ,要插入元素 A、B、C、D、E:
初始状态:T1 = [_, _, _, _] T2 = [_, _, _, _]
插入 A: h1(A)=0, T1[0] 空 → T1[0]=A
T1 = [A, _, _, _] T2 = [_, _, _, _]
插入 B: h1(B)=1, T1[1] 空 → T1[1]=B
T1 = [A, B, _, _] T2 = [_, _, _, _]
插入 C: h1(C)=0, T1[0] 被 A 占据
踢出 A → T1[0]=C
A 去 T2: h2(A)=2, T2[2] 空 → T2[2]=A
T1 = [C, B, _, _] T2 = [_, _, A, _]
插入 D: h1(D)=0, T1[0] 被 C 占据
踢出 C → T1[0]=D
C 去 T2: h2(C)=2, T2[2] 被 A 占据
踢出 A → T2[2]=C
A 去 T1: h1(A)=0, T1[0] 被 D 占据
踢出 D → T1[0]=A
D 去 T2: h2(D)=3, T2[3] 空 → T2[3]=D
T1 = [A, B, _, _] T2 = [_, _, C, D]
注意第四次插入时,踢出链经过了 4 步才找到空位。链的长度取决于表的拥挤程度和哈希函数的分布质量。
下图以第四次插入(D)为例,展示踢出链的每一步:
flowchart TD
start(["插入 D: h1(D)=0"])
s1["T1[0] 被 C 占据\n踢出 C → T1[0] ← D"]
s2["C 去 h2(C)=T2[2]\nT2[2] 被 A 占据\n踢出 A → T2[2] ← C"]
s3["A 去 h1(A)=T1[0]\nT1[0] 被 D 占据\n踢出 D → T1[0] ← A"]
s4["D 去 h2(D)=T2[3]\nT2[3] 为空 ✓"]
done(["T2[3] ← D 踢出链结束,共 3 次搬移"])
start --> s1 --> s2 --> s3 --> s4 --> done
style done fill:#1a4a1a,stroke:#3fb950,color:#e6edf3
style start fill:#1a2a4a,stroke:#388bfd,color:#e6edf3
环检测
踢出链可能形成环——元素 A 踢出 B,B 踢出 C,C 又踢回 A。一旦出现环,DFS 风格的踢出将永远无法终止。
检测环的最简单方法是设置最大踢出次数(MAX_KICKS)。当踢出次数超过阈值时,停止并触发 rehash。经验值是 500 到 1024 次——在正常负载因子下,踢出链长度极少超过 ,所以超过 500 次几乎可以确定遇到了环或者表太满。
另一种更精确的方法是维护一个访问标记:记录踢出过程中访问过的 bucket,如果再次访问同一个 bucket 就说明有环。但这增加了额外的空间开销,实践中很少使用。
何时触发 rehash
Rehash 意味着重新选择哈希函数的 seed,并将所有元素重新插入新表。触发条件通常有两种:
- 踢出链超长:单次插入的踢出次数超过 MAX_KICKS。这说明当前的哈希函数组合导致了局部拥堵或环。
- 负载因子过高:当总负载因子超过阈值(对于基本 2-way cuckoo,通常是 45%-49%;对于桶化版本,通常是 85%-90%)时,主动 rehash 并扩容。
Rehash 的期望时间是 ,并且由于新 seed 是随机选择的,rehash 后的表结构几乎一定更好。理论上,rehash 失败的概率是 ,所以极少需要连续 rehash 两次。
四、数学分析
Cuckoo Graph
Cuckoo Hashing 的正确性可以用随机图理论精确分析。构造一个二部图(bipartite graph),称为 Cuckoo 图:
- 左顶点集 = 的 个位置
- 右顶点集 = 的 个位置
- 每个元素 对应一条边
在这个模型下, 个元素在 个顶点上产生 条随机边。
树、单环和多环
Cuckoo 图中每个连通分量的结构决定了插入是否成功:
- 树(tree):连通分量中边数 = 顶点数 - 1。这意味着每个位置最多被一个元素"需要",插入必定成功。
- 单环(unicyclic):连通分量中边数 = 顶点数。存在恰好一个环,但通过沿着环"旋转"(每个元素移到它的另一个位置),仍然可以安置所有元素。
- 多环(multicyclic):连通分量中边数 > 顶点数。此时踢出链会陷入死循环——某些元素无处安放。
因此,插入成功 Cuckoo 图中不存在多环连通分量。
下图展示三种连通分量结构。树型组件(边数 = 顶点数-1)可直接安置所有元素;单环组件(边数 = 顶点数)通过沿环旋转仍可安置;多环组件(边数 > 顶点数)时某些元素无处安放,必须 rehash:
graph LR
subgraph tree ["树型 — 可插入"]
direction LR
T1a(["T1[0]"]) -- "A" --- T2a(["T2[1]"])
T1b(["T1[2]"]) -- "B" --- T2b(["T2[3]"])
T1c(["T1[1]"]) -- "C" --- T2c(["T2[0]"])
end
subgraph unicyc ["单环 — 旋转可安置"]
direction LR
U1(["T1[0]"]) -- "X" --- V1(["T2[2]"])
U1 -- "Y" --- V2(["T2[3]"])
U2(["T1[1]"]) -- "Z" --- V1
end
subgraph multicyc ["多环 — 需 Rehash"]
direction LR
P1(["T1[0]"]) -- "P" --- Q1(["T2[0]"])
P1 -- "Q" --- Q2(["T2[1]"])
P2(["T1[1]"]) -- "R" --- Q1
P2 -- "S" --- Q2
end
阈值 c 约等于 0.5
随机图理论中有一个经典结论:当一个随机二部图有 个顶点和 条边时:
- 当 时(即 ),图几乎确定只包含树和单环分量。
- 当 时(即 ),巨分量(giant component)出现,多环概率急剧上升。
- 当 时,多环几乎必然存在。
所以基本 Cuckoo Hashing 的负载因子上限是 ,即 。
更精确地说,Devroye 和 Morin(2003)证明了:当 时,插入全部 个元素成功的概率为:
当 固定且 时,成功概率趋近于 1。但当 (负载因子趋近 0.5)时,需要 才能保证高概率成功。
期望踢出链长度
在 的前提下,单次插入的期望踢出链长度是 。更精确地,Pagh 和 Rodler 证明了踢出链长度超过 的概率为 ——指数衰减。这意味着插入的期望时间是 ,只是最坏情况可能触发 rehash(),但 rehash 的频率足够低,摊还下来仍然是 。
五、d-ary Cuckoo Hashing:突破 50% 限制
更多的哈希函数
2004 年,Fotakis 等人提出了 d-ary Cuckoo Hashing:使用 个哈希函数。每个元素有 个可能的位置。
理论结果令人惊讶:
| 哈希函数数 d | 最大负载因子 | 查找访问次数 |
|---|---|---|
| 2 | ~50% | 2 |
| 3 | ~91% | 3 |
| 4 | ~97% | 4 |
| 5 | ~99% | 5 |
从 2 到 3 个哈希函数,负载因子从 50% 跳到 91%——收益巨大。但查找时间从 2 次增加到 3 次内存访问。
直觉上为什么 的效果这么好?回到随机图模型。 时,每个元素对应 Cuckoo 图中的一条边; 时,每个元素对应一个超边(hyperedge)。随机超图的 peelability 阈值远高于随机二部图——因为超边之间"竞争"资源的概率更低,图的局部结构更稀疏。
Dietzfelbinger 和 Weidling(2007)给出了精确阈值的计算方法,本质上归结为以下递推:对于 -uniform 随机超图, 个超边和 个顶点,当 ( 阶 peeling 阈值)时,图几乎确定可以完全 peel。
桶化(Bucketized)Cuckoo Hashing
另一种提高负载因子的方法:每个位置存储多个元素(bucket)。这不增加查找的内存访问次数——因为同一个 bucket 内的元素在同一个 cache line 上。
如果每个 bucket 存 个元素:
| 配置 (d, k) | 最大负载因子 | cache-line 友好性 |
|---|---|---|
| (2, 1) | ~50% | 2 次随机访问 |
| (2, 4) | ~95% | 2 次随机访问(bucket 对齐 cache line) |
| (2, 8) | ~98% | 2 次随机访问(bucket = 1-2 cache lines) |
| (3, 4) | ~99.9% | 3 次随机访问 |
这就是 DPDK rte_hash 的选择:2 个哈希函数,每个 bucket 存 8 个元素,负载因子可达 95%。
桶化为什么效果这么好?因为一个 -slot 的 bucket 相当于给每个位置增加了 个"替补"。元素不再需要精确落入一个 slot,而是落入一个范围——这大幅降低了冲突概率。
踢出链的路径搜索:DFS vs BFS
基本 Cuckoo Hashing 的踢出使用简单的交替踢出(先踢 T1,被踢的去 T2,被踢的再去 T1...)。这本质上是 DFS(深度优先搜索)。
DFS 的问题是:它可能找到很长的踢出路径,即使存在更短的路径。长路径意味着更多的内存写入和更高的 cache 失效。
BFS 可以找到最短踢出路径,减少搬移次数:
// BFS 踢出路径搜索(简化伪代码)
int bfs_evict(CuckooHash *ht, uint64_t key, uint64_t value) {
// 队列中每个元素记录:bucket_idx, slot_idx, parent
Queue q;
enqueue(&q, (Node){h1(key), -1, NULL});
enqueue(&q, (Node){h2(key), -1, NULL});
while (!queue_empty(&q) && q.depth < MAX_BFS_DEPTH) {
Node cur = dequeue(&q);
Bucket *b = &ht->buckets[cur.bucket];
for (int i = 0; i < BUCKET_SIZE; i++) {
if (!b->slots[i].occupied) {
// 找到空位!沿 parent 链回溯,反向搬移
backtrack_and_place(&q, cur, i, key, value);
return 1;
}
// 该 slot 的元素可以被踢到它的另一个 bucket
size_t alt = alternate_bucket(ht, b->slots[i].key, cur.bucket);
enqueue(&q, (Node){alt, i, &cur});
}
}
return 0; // BFS 深度超限,需要 rehash
}
BFS 的额外空间开销(队列)在实践中可以接受——DPDK 限定 BFS 最大深度为 1024 步,队列大小约几 KB。
哈希函数的要求
Cuckoo Hashing 的理论分析假设哈希函数是完全随机的(每个 key 的哈希值独立均匀分布)。但实际的哈希函数(如 MurmurHash、xxHash)不是完全随机的。
理论上,Cuckoo Hashing 需要 -wise 独立的哈希函数。Pagh 和 Rodler 的原始论文假设完全随机,后续工作(如 Patrascu 和 Thorup 2012)证明了 5-wise 独立就够用。
实践中,简单的做法是使用两个独立的哈希函数(不同的 seed),例如:
uint64_t h1(uint64_t key) { return murmurhash64(key, seed1); }
uint64_t h2(uint64_t key) { return murmurhash64(key, seed2); }
Patrascu 和 Thorup 还证明了 simple tabulation hashing 就足以让 Cuckoo Hashing 正确工作。Simple tabulation hashing 将 key 分成若干字节,每个字节查一个随机表,然后 XOR:
uint64_t tab_hash(uint64_t key) {
uint64_t h = 0;
for (int i = 0; i < 8; i++) {
h ^= table[i][(key >> (i * 8)) & 0xFF];
}
return h;
}
只需要 8 次表查找和 8 次 XOR——极快。而且只需要 的随机表(L1 cache 放得下)。Tabulation hashing 只有 3-wise 独立,但利用它的特殊结构可以证明更强的性质。这是理论指导实践的经典案例。
六、Cuckoo Filter:从哈希表到概率数据结构
灵感来源
2014 年,Fan 等人发表了 Cuckoo Filter: Practically Better Than Bloom。Cuckoo Filter 不存储完整的 key,只存储 key 的指纹(fingerprint,通常 8-16 bit)。
这使得 Cuckoo Filter 像 Bloom Filter 一样是概率数据结构(有假阳性,无假阴性),但有两个关键优势:
- 支持删除:Bloom Filter 不能删除(会引入假阴性),Cuckoo Filter 可以(删除指纹)
- 空间效率更高:同样的假阳性率下,Cuckoo Filter 比 Bloom Filter 少用 10-25% 的空间
空间效率分析
假阳性率为 时:
- Bloom Filter 需要 bit
- Cuckoo Filter(bucket 大小 )需要约 bit
当 时,Cuckoo Filter 更省空间。
指纹与踢出
Cuckoo Filter 的一个巧妙之处是:踢出时需要知道被踢元素的"另一个位置",但我们只存了指纹,没存原始 key。怎么办?
解决方案是使用 partial-key cuckoo hashing:
XOR 的对称性保证了:给定一个位置和指纹,可以计算出另一个位置:
这个设计使得踢出操作不需要原始 key——只需要指纹和当前位置就够了。
Cuckoo Filter 与 Bloom Filter 对比
| 维度 | Bloom Filter | Cuckoo Filter |
|---|---|---|
| 删除 | 不支持(Counting Bloom 支持,但空间翻倍) | 原生支持 |
| 空间效率() | 较差 | 优 10-25% |
| 空间效率() | 较好 | 略差 |
| 查找时间 | 次哈希 + 次随机内存访问 | 2 次哈希 + 2 次内存访问 |
| 插入时间 | 确定性 | 摊还(偶尔踢出) |
| 合并(union) | 支持(位 OR) | 不支持 |
| 实现复杂度 | 简单 | 中等 |
Cuckoo Filter C 实现
// cuckoo_filter.c — 简化的 Cuckoo Filter 实现
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#define CF_BUCKET_SIZE 4
#define CF_MAX_KICKS 500
#define CF_FP_BITS 8
typedef uint8_t fingerprint_t;
typedef struct {
fingerprint_t fps[CF_BUCKET_SIZE];
} CFBucket;
typedef struct {
CFBucket *buckets;
size_t num_buckets;
size_t count;
} CuckooFilter;
static uint32_t cf_hash(const void *data, size_t len) {
uint32_t h = 0x811c9dc5;
const uint8_t *p = data;
for (size_t i = 0; i < len; i++) {
h ^= p[i];
h *= 0x01000193;
}
return h;
}
static fingerprint_t cf_fingerprint(const void *data, size_t len) {
uint32_t h = cf_hash(data, len);
fingerprint_t fp = (h >> 16) & 0xFF;
return fp ? fp : 1; // 指纹不能为 0(0 表示空槽)
}
static size_t cf_index(CuckooFilter *cf, const void *data, size_t len) {
return cf_hash(data, len) % cf->num_buckets;
}
static size_t cf_alt_index(CuckooFilter *cf, size_t index, fingerprint_t fp) {
uint32_t fp_hash = cf_hash(&fp, sizeof(fp));
return (index ^ fp_hash) % cf->num_buckets;
}
CuckooFilter *cf_create(size_t capacity) {
CuckooFilter *cf = calloc(1, sizeof(CuckooFilter));
cf->num_buckets = capacity / CF_BUCKET_SIZE;
if (cf->num_buckets < 4) cf->num_buckets = 4;
cf->buckets = calloc(cf->num_buckets, sizeof(CFBucket));
return cf;
}
int cf_lookup(CuckooFilter *cf, const void *data, size_t len) {
fingerprint_t fp = cf_fingerprint(data, len);
size_t i1 = cf_index(cf, data, len);
size_t i2 = cf_alt_index(cf, i1, fp);
for (int j = 0; j < CF_BUCKET_SIZE; j++) {
if (cf->buckets[i1].fps[j] == fp) return 1;
if (cf->buckets[i2].fps[j] == fp) return 1;
}
return 0;
}
int cf_insert(CuckooFilter *cf, const void *data, size_t len) {
fingerprint_t fp = cf_fingerprint(data, len);
size_t i1 = cf_index(cf, data, len);
size_t i2 = cf_alt_index(cf, i1, fp);
for (int j = 0; j < CF_BUCKET_SIZE; j++) {
if (cf->buckets[i1].fps[j] == 0) {
cf->buckets[i1].fps[j] = fp;
cf->count++;
return 1;
}
}
for (int j = 0; j < CF_BUCKET_SIZE; j++) {
if (cf->buckets[i2].fps[j] == 0) {
cf->buckets[i2].fps[j] = fp;
cf->count++;
return 1;
}
}
// 踢出
size_t idx = (rand() & 1) ? i1 : i2;
for (int kick = 0; kick < CF_MAX_KICKS; kick++) {
int slot = rand() % CF_BUCKET_SIZE;
fingerprint_t evicted = cf->buckets[idx].fps[slot];
cf->buckets[idx].fps[slot] = fp;
fp = evicted;
idx = cf_alt_index(cf, idx, fp);
for (int j = 0; j < CF_BUCKET_SIZE; j++) {
if (cf->buckets[idx].fps[j] == 0) {
cf->buckets[idx].fps[j] = fp;
cf->count++;
return 1;
}
}
}
return 0; // 满了
}
int cf_delete(CuckooFilter *cf, const void *data, size_t len) {
fingerprint_t fp = cf_fingerprint(data, len);
size_t i1 = cf_index(cf, data, len);
size_t i2 = cf_alt_index(cf, i1, fp);
for (int j = 0; j < CF_BUCKET_SIZE; j++) {
if (cf->buckets[i1].fps[j] == fp) {
cf->buckets[i1].fps[j] = 0;
cf->count--;
return 1;
}
if (cf->buckets[i2].fps[j] == fp) {
cf->buckets[i2].fps[j] = 0;
cf->count--;
return 1;
}
}
return 0;
}
七、并发 Cuckoo Hashing
Cuckoo Hashing 的踢出机制给并发带来了独特的挑战:一次插入可能修改多个 bucket,而查找需要读取两个 bucket——如果插入和查找并发执行,读者可能看到不一致的状态。
问题:踢出链破坏读一致性
考虑以下场景:
sequenceDiagram
participant A as 线程 A(查找 X)
participant T as 哈希表
participant B as 线程 B(插入,踢出链经过 X)
A->>T: 读 T1[h1(X)] → 无 X
Note over B,T: 踢出链:将 X 从 T2[h2(X)] 搬到 T1[h1(X)]
B->>T: 写 T1[h1(X)] = X
B->>T: 清空 T2[h2(X)]
A->>T: 读 T2[h2(X)] → 无 X
Note over A: 假未命中!X 实际存在但被漏掉
元素 X 在查找的两步之间被搬移了,导致"假未命中"(false miss)。这在普通开放寻址哈希表中不会发生——元素不会在查找过程中改变位置。
DPDK 的实现策略
DPDK 的 rte_hash 针对网络数据平面的特点,采用了分层并发策略:
读写分离模式:默认模式下,多个线程可以同时读,但只有一个线程可以写。写线程在踢出时使用 store-release 语义,确保读线程看到的是一致的状态。
多写者模式:使用 per-bucket 的自旋锁保护写操作。每个 bucket 用一个 uint32_t 作为锁,锁住 primary 和 secondary 两个 bucket 后才执行踢出。为避免死锁,总是按 bucket 地址从小到大的顺序加锁。
// DPDK 风格的 per-bucket 加锁(简化)
void cuckoo_insert_locked(CuckooHash *ht, uint64_t key, uint64_t value) {
size_t b1 = h1(ht, key);
size_t b2 = h2(ht, key);
// 按地址顺序加锁,避免死锁
if (b1 < b2) {
lock(&ht->buckets[b1].lock);
lock(&ht->buckets[b2].lock);
} else {
lock(&ht->buckets[b2].lock);
lock(&ht->buckets[b1].lock);
}
// 在两个 bucket 内执行插入或踢出
do_insert(ht, key, value, b1, b2);
unlock(&ht->buckets[b1].lock);
unlock(&ht->buckets[b2].lock);
}
乐观并发(Optimistic Cuckoo Hashing)
Fan 等人在 2014 年提出了一种更高效的方案——乐观并发控制。核心思想是给每个 bucket 加一个版本计数器:
- 读者在读取之前记录两个 bucket 的版本号。
- 读者读取两个 bucket 的内容。
- 读者再次检查版本号——如果版本号没变,说明读取期间没有写操作,结果有效。如果版本号变了,重试。
// 乐观读取(无锁)
int cuckoo_lookup_optimistic(CuckooHash *ht, uint64_t key, uint64_t *value) {
retry:;
uint32_t v1_before = atomic_load(&ht->buckets[h1(ht, key)].version);
uint32_t v2_before = atomic_load(&ht->buckets[h2(ht, key)].version);
// 尝试在两个 bucket 中查找
int found = search_buckets(ht, key, value);
uint32_t v1_after = atomic_load(&ht->buckets[h1(ht, key)].version);
uint32_t v2_after = atomic_load(&ht->buckets[h2(ht, key)].version);
if (v1_before != v1_after || v2_before != v2_after)
goto retry;
return found;
}
这种方案在读多写少的场景下极其高效——读路径完全无锁、无 CAS、无原子操作(只需要两次 atomic_load,在 x86 上就是普通的 MOV 指令)。写路径仍然需要锁,但写的频率远低于读。
路径发现与路径搬移分离
乐观并发的另一个关键优化是把踢出过程分成两个阶段:
- 路径发现(path discovery):用 BFS 找到从满 bucket 到空 bucket 的搬移路径,不修改任何数据。这个阶段不需要锁。
- 路径搬移(path displacement):沿着路径反向搬移元素。每一步只锁一个 bucket,搬移一个元素。
反向搬移的关键在于:从空 slot 开始,把前一个 bucket 的元素搬过来,这样每一步都保证元素在搬移过程中"同时存在于两个位置"而不是"暂时消失"——读者即使在搬移过程中查找,也一定能找到元素。
八、完整 C 实现
下面是一个包含 BFS 踢出、rehash、查找和删除的完整 2-way cuckoo hash table 实现:
// cuckoo_hash.c — 2-way Cuckoo Hash with BFS eviction and rehash
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#define BUCKET_SIZE 4
#define MAX_BFS_DEPTH 512
#define LOAD_THRESHOLD 0.85
typedef struct {
uint64_t key;
uint64_t value;
uint8_t occupied;
} Slot;
typedef struct {
Slot slots[BUCKET_SIZE];
} Bucket;
// BFS 搜索节点
typedef struct {
size_t bucket;
int slot;
int parent;
int depth;
} BFSNode;
typedef struct CuckooHash {
Bucket *buckets;
size_t num_buckets;
size_t size;
uint64_t seed1, seed2;
} CuckooHash;
static uint64_t hash64(uint64_t key, uint64_t seed) {
key ^= seed;
key = (key ^ (key >> 30)) * 0xbf58476d1ce4e5b9ULL;
key = (key ^ (key >> 27)) * 0x94d049bb133111ebULL;
return key ^ (key >> 31);
}
static size_t h1(CuckooHash *ht, uint64_t key) {
return hash64(key, ht->seed1) % ht->num_buckets;
}
static size_t h2(CuckooHash *ht, uint64_t key) {
return hash64(key, ht->seed2) % ht->num_buckets;
}
static size_t alt_bucket(CuckooHash *ht, uint64_t key, size_t cur) {
size_t b1 = h1(ht, key), b2 = h2(ht, key);
return (cur == b1) ? b2 : b1;
}
CuckooHash *cuckoo_create(size_t capacity) {
CuckooHash *ht = calloc(1, sizeof(CuckooHash));
ht->num_buckets = capacity / BUCKET_SIZE;
if (ht->num_buckets < 16) ht->num_buckets = 16;
ht->buckets = calloc(ht->num_buckets, sizeof(Bucket));
ht->seed1 = 0x123456789abcdef0ULL;
ht->seed2 = 0xfedcba9876543210ULL;
return ht;
}
int cuckoo_lookup(CuckooHash *ht, uint64_t key, uint64_t *value) {
size_t b1 = h1(ht, key), b2 = h2(ht, key);
for (int i = 0; i < BUCKET_SIZE; i++) {
if (ht->buckets[b1].slots[i].occupied &&
ht->buckets[b1].slots[i].key == key) {
if (value) *value = ht->buckets[b1].slots[i].value;
return 1;
}
if (ht->buckets[b2].slots[i].occupied &&
ht->buckets[b2].slots[i].key == key) {
if (value) *value = ht->buckets[b2].slots[i].value;
return 1;
}
}
return 0;
}
int cuckoo_delete(CuckooHash *ht, uint64_t key) {
size_t b1 = h1(ht, key), b2 = h2(ht, key);
for (int i = 0; i < BUCKET_SIZE; i++) {
if (ht->buckets[b1].slots[i].occupied &&
ht->buckets[b1].slots[i].key == key) {
ht->buckets[b1].slots[i].occupied = 0;
ht->size--;
return 1;
}
if (ht->buckets[b2].slots[i].occupied &&
ht->buckets[b2].slots[i].key == key) {
ht->buckets[b2].slots[i].occupied = 0;
ht->size--;
return 1;
}
}
return 0;
}
// BFS 踢出:寻找最短搬移路径
static int bfs_insert(CuckooHash *ht, uint64_t key, uint64_t value) {
BFSNode queue[MAX_BFS_DEPTH];
int head = 0, tail = 0;
size_t b1 = h1(ht, key), b2 = h2(ht, key);
queue[tail++] = (BFSNode){b1, -1, -1, 0};
queue[tail++] = (BFSNode){b2, -1, -1, 0};
while (head < tail && tail < MAX_BFS_DEPTH) {
BFSNode cur = queue[head];
Bucket *b = &ht->buckets[cur.bucket];
for (int i = 0; i < BUCKET_SIZE; i++) {
if (!b->slots[i].occupied) {
// 找到空位,沿 parent 链反向搬移
int idx = head;
int empty_slot = i;
size_t empty_bucket = cur.bucket;
while (queue[idx].parent >= 0) {
BFSNode *parent = &queue[queue[idx].parent];
Slot *src = &ht->buckets[parent->bucket].slots[queue[idx].slot];
ht->buckets[empty_bucket].slots[empty_slot] = *src;
empty_bucket = parent->bucket;
empty_slot = queue[idx].slot;
idx = queue[idx].parent;
}
// 放入新元素
ht->buckets[empty_bucket].slots[empty_slot] =
(Slot){key, value, 1};
ht->size++;
return 1;
}
// 将该 slot 的元素加入 BFS 队列
if (cur.depth < 8 && tail < MAX_BFS_DEPTH) {
size_t alt = alt_bucket(ht, b->slots[i].key, cur.bucket);
queue[tail++] = (BFSNode){alt, i, head, cur.depth + 1};
}
}
head++;
}
return 0; // BFS 深度超限
}
// rehash:重新选择 seed 并重新插入所有元素
int cuckoo_rehash(CuckooHash *ht) {
Bucket *old_buckets = ht->buckets;
size_t old_num = ht->num_buckets;
size_t new_num = old_num * 2;
ht->buckets = calloc(new_num, sizeof(Bucket));
if (!ht->buckets) { ht->buckets = old_buckets; return 0; }
ht->num_buckets = new_num;
ht->size = 0;
ht->seed1 ^= 0xdeadbeefcafebabeULL;
ht->seed2 ^= 0x0123456789abcdefULL;
for (size_t i = 0; i < old_num; i++) {
for (int j = 0; j < BUCKET_SIZE; j++) {
if (old_buckets[i].slots[j].occupied) {
Slot *s = &old_buckets[i].slots[j];
// 先尝试直接插入
size_t b1 = h1(ht, s->key), b2 = h2(ht, s->key);
int placed = 0;
for (int k = 0; k < BUCKET_SIZE && !placed; k++) {
if (!ht->buckets[b1].slots[k].occupied) {
ht->buckets[b1].slots[k] = *s;
ht->size++;
placed = 1;
}
}
for (int k = 0; k < BUCKET_SIZE && !placed; k++) {
if (!ht->buckets[b2].slots[k].occupied) {
ht->buckets[b2].slots[k] = *s;
ht->size++;
placed = 1;
}
}
if (!placed && !bfs_insert(ht, s->key, s->value)) {
// rehash 失败(极少发生),恢复旧表
free(ht->buckets);
ht->buckets = old_buckets;
ht->num_buckets = old_num;
return 0;
}
}
}
}
free(old_buckets);
return 1;
}
int cuckoo_insert(CuckooHash *ht, uint64_t key, uint64_t value) {
// 检查是否需要扩容
double load = (double)ht->size / (ht->num_buckets * BUCKET_SIZE);
if (load > LOAD_THRESHOLD) {
cuckoo_rehash(ht);
}
// 先尝试直接放入空位
size_t b1 = h1(ht, key), b2 = h2(ht, key);
for (int i = 0; i < BUCKET_SIZE; i++) {
if (!ht->buckets[b1].slots[i].occupied) {
ht->buckets[b1].slots[i] = (Slot){key, value, 1};
ht->size++;
return 1;
}
}
for (int i = 0; i < BUCKET_SIZE; i++) {
if (!ht->buckets[b2].slots[i].occupied) {
ht->buckets[b2].slots[i] = (Slot){key, value, 1};
ht->size++;
return 1;
}
}
// 没有空位,BFS 踢出
if (bfs_insert(ht, key, value))
return 1;
// BFS 失败,rehash 后重试
if (cuckoo_rehash(ht))
return cuckoo_insert(ht, key, value);
return 0;
}
void cuckoo_destroy(CuckooHash *ht) {
free(ht->buckets);
free(ht);
}
int main(void) {
CuckooHash *ht = cuckoo_create(10000);
// 插入
int success = 0, fail = 0;
for (uint64_t i = 1; i <= 8000; i++) {
if (cuckoo_insert(ht, i, i * 100))
success++;
else
fail++;
}
printf("Inserted: %d, Failed: %d, Load: %.2f%%\n",
success, fail,
100.0 * ht->size / (ht->num_buckets * BUCKET_SIZE));
// 查找
int found = 0;
for (uint64_t i = 1; i <= 8000; i++) {
uint64_t val;
if (cuckoo_lookup(ht, i, &val) && val == i * 100)
found++;
}
printf("Lookup correct: %d / 8000\n", found);
// 删除
int deleted = 0;
for (uint64_t i = 1; i <= 4000; i++) {
if (cuckoo_delete(ht, i)) deleted++;
}
printf("Deleted: %d, Remaining: %zu\n", deleted, ht->size);
// 验证删除后查找
int false_found = 0;
for (uint64_t i = 1; i <= 4000; i++) {
uint64_t val;
if (cuckoo_lookup(ht, i, &val)) false_found++;
}
printf("False positives after delete: %d (should be 0)\n", false_found);
cuckoo_destroy(ht);
return 0;
}
九、网络设备中的应用
Cuckoo Hashing 在网络领域的应用比在通用编程中更为广泛。原因很简单:网络设备的数据平面需要确定性延迟,而 Cuckoo Hashing 恰好提供了这一保证。
TCAM 替代
TCAM(Ternary Content-Addressable Memory)是网络交换机中用于精确匹配和通配符匹配的专用硬件。它可以在一个时钟周期内查找任意模式,但缺点极为突出:
- 功耗高:TCAM 每比特的功耗是 SRAM 的 100 倍以上。
- 容量小:一块高端交换芯片上的 TCAM 通常只有几 MB。
- 价格贵:同容量下比 SRAM 贵 10-50 倍。
Cuckoo Hashing 用 SRAM 实现精确匹配,查找时间只需 2 次 SRAM 访问(对于桶化版本,一次访问读取整个 bucket)。对于不需要通配符匹配的场景(如 MAC 地址表、精确五元组匹配),Cuckoo Hash 可以完全替代 TCAM,节省大量功耗和芯片面积。
DPDK rte_hash
DPDK(Data Plane Development Kit)的 rte_hash 是 Cuckoo Hashing 最著名的工业级实现之一。它用于网络数据包的精确匹配(如五元组查找)。
// rte_hash 的核心结构(简化)
struct rte_hash {
uint32_t num_buckets; // 桶数量(2 的幂)
uint32_t bucket_entries; // 每桶 8 个 slot
struct rte_hash_bucket *buckets;
// 每个 bucket:
// - 8 个 signature(哈希值的低 16 位,用于快速比较)
// - 8 个 key_idx(指向实际 key-value 的索引)
};
// 查找路径
int rte_hash_lookup(struct rte_hash *h, const void *key) {
uint32_t hash = hash_func(key);
uint32_t primary = hash & bucket_mask;
uint32_t secondary = primary ^ (hash >> 16);
uint16_t sig = hash & 0xFFFF;
// 检查 primary bucket(8 个 slot,用 SIMD 并行比较签名)
if (bucket_lookup(h->buckets[primary], sig, key))
return found;
// 检查 secondary bucket
if (bucket_lookup(h->buckets[secondary], sig, key))
return found;
return not_found;
}
DPDK rte_hash 在 Intel Xeon 上的性能(100 万个规则):
| 操作 | 延迟(ns) | 吞吐量(Mops/s) |
|---|---|---|
| 查找(命中) | 30-50 | 35-45 |
| 查找(未命中) | 25-40 | 40-55 |
| 插入 | 50-100 | 15-25 |
| 删除 | 40-60 | 25-35 |
查找未命中比命中还快——因为未命中只需要比较签名(16 bit),大部分 slot 的签名不匹配就能排除。命中则需要额外比较完整 key。
OVS(Open vSwitch)流表
OVS 的 Megaflow Cache 使用了基于 Cuckoo Hashing 的 cmap(concurrent map)实现。每个数据包到达时,OVS 需要在流表中查找匹配的 flow rule。传统实现使用链式哈希表,但链式哈希的指针追逐对 cache 不友好。
OVS 的 cmap 采用了 Cuckoo Hashing 的变体:
- 每个 bucket 包含若干 slot,每个 slot 存储一个 32-bit 的哈希签名和一个指向实际 flow entry 的指针。
- 查找时先比较签名,签名匹配后再比较完整的 flow key。
- 使用 RCU(Read-Copy-Update)实现无锁读取,写操作持有全局锁。
这种设计让 OVS 在千万级流表规模下仍能保持每个包 2 次 cache line 访问的查找性能。
十、基准测试
测试环境:Intel i9-12900K,gcc -O2,桶化 Cuckoo(2 表,bucket size = 4),固定表大小(131072 桶 × 4 槽 = 524288 槽),每项取 7 次运行中位数。
不同负载因子下的性能
| 负载因子 | 插入成功率 | 平均踢出次数/插入 | 查找命中(ns) | 查找未命中(ns) | Mops/s(命中) |
|---|---|---|---|---|---|
| 50% | 100% | 0.004 | 12.6 | 29.0 | 79 |
| 70% | 100% | 0.024 | 15.5 | 26.9 | 64 |
| 85% | 100% | 0.071 | 16.6 | 18.7 | 60 |
| 90% | 100% | 0.101 | 16.6 | 16.1 | 60 |
| 95% | 100% | 0.152 | 17.9 | 14.1 | 56 |
xychart-beta
title "Cuckoo(2,4) 查找延迟 vs 负载因子"
x-axis ["50%", "70%", "85%", "90%", "95%"]
y-axis "延迟 (ns)" 0 --> 35
bar [12.6, 15.5, 16.6, 16.6, 17.9]
line [29.0, 26.9, 18.7, 16.1, 14.1]
关键观察:
- 查找命中延迟极稳定(12.6→17.9 ns,变化仅 42%)——总是恰好 2 次 bucket 访问,不受负载影响
- 查找未命中在高负载反而更快(29→14 ns)——高负载时表更紧凑;检查 8 个槽后快速确认不存在
- 平均踢出次数极低——即使 95% 负载,每次插入平均搬移不到 0.2 个元素;桶大小=4 使两桶同时全满概率极低
- 推荐工作区间:70-90% 负载因子
Cuckoo vs Robin Hood vs Chained
以下对比在相同条件下进行(70% 负载因子,367K 个 64-bit 键):
| 实现 | 查找命中(ns) | 查找未命中(ns) | 插入(ns) | 删除(ns) |
|---|---|---|---|---|
| Cuckoo(2,4) | 19.5 | 25.4 | 18.3 | 19.0 |
| Robin Hood | 17.2 | 17.9 | 17.5 | 19.1 |
| Chained | 11.0 | 13.9 | 20.5 | 32.4 |
xychart-beta
title "吞吐量对比 (Mops/s,70% 负载)"
x-axis ["查找命中", "查找未命中", "插入", "删除"]
y-axis "Mops/s" 0 --> 100
bar [51.3, 39.3, 54.7, 52.6]
bar [58.1, 55.7, 57.0, 52.2]
bar [91.0, 71.7, 48.9, 30.9]
几个值得注意的现象:
Chained 查找命中最快(91 Mops/s)。 在负载因子 ≈ 1.0 时(每桶约 1 个元素),链式哈希几乎不需要链表遍历,bucket 定位即命中。代价是删除需要指针遍历(30.9 Mops/s 最慢),且内存分布碎片化对大表不友好。
Cuckoo 和 Robin Hood 整体接近。 在 70% 负载下两者的吞吐量在同一量级。Cuckoo 的优势不在于绝对吞吐量,而在于确定性——每次查找恰好 2 次 bucket 访问,无论表中有多少元素、负载因子多高。
Cuckoo 查找未命中慢于 Robin Hood(39 vs 56 Mops/s)。 Robin Hood 可利用探测距离提前终止(dist < probe_dist 时立即确认不存在),特别高效。Cuckoo 必须检查两个 bucket 的全部 8 个槽才能确认不存在。
尾延迟对比
以下数据采用批量采样(每批 200 次连续查找,50000 个样本),消除 clock_gettime 调用开销的影响:
| 百分位 | Cuckoo (2,4) | Robin Hood |
|---|---|---|
| p50 | 5.3 ns | 4.1 ns |
| p99 | 7.1 ns | 5.8 ns |
| p99.9 | 56 ns | 52 ns |
| p99.99 | 459 ns | 330 ns |
xychart-beta
title "尾延迟对比 (ns/op)"
x-axis ["p50", "p99", "p99.9", "p99.99"]
y-axis "延迟 (ns)" 0 --> 500
bar [5.3, 7.1, 56.0, 459.0]
bar [4.1, 5.8, 52.0, 330.1]
在 70% 负载下,Cuckoo 和 Robin Hood 的尾延迟差距不大。Cuckoo 尾延迟的真正优势在高负载(85-95%)下才会显著拉开——此时 Robin Hood 探测序列急剧变长,p99.9 延迟可达数百纳秒;而 Cuckoo 仍然严格 2 次 bucket 访问,延迟几乎不变。
对于网络数据平面(每个包都需要硬实时查找),精确的最坏情况上界比平均吞吐量更重要——这正是 Cuckoo Hashing 在高性能网络设备中广泛应用的根本原因。
十一、工程陷阱清单
| 序号 | 陷阱 | 症状 | 解法 |
|---|---|---|---|
| 1 | 哈希函数相关性 | 某些 key 的 h1 和 h2 总是映射到相同的 bucket | 确保两个哈希函数真正独立(不同 seed,或用 tabulation hashing) |
| 2 | 踢出链死循环 | 插入卡死或无限循环 | 设置 MAX_KICKS 上限(推荐 500-1024),超过就 rehash |
| 3 | rehash 时机不当 | 负载因子到 95% 才 rehash,导致踢出链平均长度暴增 | 在 85-90% 时就开始渐进式 rehash |
| 4 | Cuckoo Filter 重复插入 | 同一个 key 插入多次,占满 bucket 的所有 slot | 插入前先查找;或使用 counting 变体 |
| 5 | 并发踢出竞争 | 多线程同时踢出,造成 ABA 问题或数据丢失 | 使用 per-bucket 细粒度锁,或采用 optimistic cuckoo hashing(Fan 2014) |
| 6 | bucket 大小与 cache line 不对齐 | 一次 bucket 查找跨两个 cache line,延迟翻倍 | 确保 bucket 大小为 64B 的因子(如 8 slots x 8B = 64B) |
| 7 | 指纹碰撞导致误删 | Cuckoo Filter 删除时,删了同 bucket 中另一个相同指纹的元素 | 使用更长的指纹(12-16 bit),或使用 counting 变体 |
| 8 | DFS 踢出路径过长 | 插入延迟的方差极大,偶尔出现毫秒级延迟尖刺 | 改用 BFS 找最短路径;DPDK 默认用 BFS 且限制深度 |
| 9 | rehash 期间无法服务请求 | 大表 rehash 耗时数百毫秒,造成服务中断 | 使用渐进式 rehash:维护新旧两张表,逐步搬移 |
| 10 | 取模运算开销大 | hash % num_buckets 中除法指令延迟高 | 将 num_buckets 设为 2 的幂,用 hash & (num_buckets - 1) 代替 |
十二、个人观点
Cuckoo Hashing 是哈希表领域最优雅的设计之一。它用"踢出"这个简单的概念,把查找的最坏情况从 降到了 。代价是插入可能需要一条踢出链,但对于读多写少的场景(如网络转发表、缓存),这是非常划算的交易。
我对 Cuckoo Hashing 有几个深层观察:
一、读写不对称的设计哲学。 Cuckoo Hashing 体现了一种"把不确定性从读路径转移到写路径"的设计哲学。传统哈希表在读和写上分摊不确定性——读可能需要长探测,写可能需要扩容。Cuckoo Hashing 则把所有不确定性集中到写路径(踢出链),让读路径完全确定。这种思路在很多系统中都有体现:LSM-tree 把写的不确定性留给 compaction,让读路径更可控;B+tree 把分裂的开销留给写路径,让范围查询更流畅。
二、理论与工程的鸿沟。 原始的 2-ary Cuckoo Hashing 只能到 50% 负载因子,在实践中几乎不可用。是 bucketized 设计(一个工程优化,不需要新理论)把它变成了实用的数据结构。这说明算法论文的理论结果和工程可用性之间,往往隔着一层"工程创造力"。
三、Cuckoo Filter 的启示。 从 Cuckoo Hash Table(精确数据结构)到 Cuckoo Filter(概率数据结构),partial-key hashing 的核心 idea 是:存储更少的信息,但保留足够的信息来执行操作。这个 idea 在数据结构设计中反复出现——Bloom Filter 只存在性信息,HyperLogLog 只存最大前导零。好的数据结构设计,本质上是回答"我最少需要存什么,才能回答这个问题"。
四、确定性的价值。 在通用编程中,期望 和确定性 的差距看起来不大—— 的尾延迟对大多数应用来说完全可以接受。但在某些领域,这个差距就是"能不能用"的边界。网络设备的数据平面必须在纳秒级完成查找,不能接受任何毛刺;实时交易系统需要严格的延迟 SLA;安全关键系统需要可证明的时间上界。Cuckoo Hashing 告诉我们:确定性是有价值的,值得为它付出插入路径上的额外复杂性。
如果你的工作负载是读多写少、需要确定性延迟、或者需要支持删除的近似成员查询——Cuckoo 家族是完美选择。