哈希表 · 从散列函数到渐进式 rehash
数组按下标取值是 O(1),但下标必须是小整数。hash table 补上中间那一步:拿一个散列函数把任意 key 压成 32 位整数,再折算成数组下标,于是「按 key 取值」也降到平均 O(1)。整个数据结构的全部复杂度,都来自这一步折算不是双射——两个不同的 key 会算出同一个下标。
本系列分四条线。第一条是散列函数:什么叫「散得开」,为什么取低位会被对齐的地址打死,以及 hash flooding 这类拿碰撞当武器的攻击。第二条是冲突解决的两大流派,chaining 把同槽的 key 串成链,open addressing 让它们沿探测序列往后挪,后者的删除必须留墓碑、而 Robin Hood 变体能把探测距离摊平。第三条是规模:负载因子、成倍扩容的摊还代价,以及 Redis dict 用双表加 rehashidx 把一次性 rehash 拆成每次操作搬一格。第四条走向现代实现与分布式:Swiss table 的 control byte 让一条 SIMD 指令筛掉整组候选,cuckoo hashing 把查找钉死在两次访存,consistent hashing 与 jump hash 则解决「加一台机器要搬多少数据」。
每页都能改 key、单步执行,观察 hash 值如何折算成下标、探测路径怎么走、扩容时哪些格子被搬走。
地基:散列函数与两类冲突解决
key 到下标分两步:散列函数给出 32 位整数,折算规则把它压进 0..m-1。两步各有各的坑——散列函数可能把差异只留在低位,折算规则可能只看低位。冲突无法避免( 个槽装 个 key 时必然发生),两大流派的分野是「同槽的 key 存在哪」:chaining 存在槽外的链上,open addressing 存在表内别的槽里。
从 key 到下标:散列函数
key 到下标要走两步:散列函数给出 32 位整数,折算规则把它压进 0 到 m-1。两步的失效方式不同——散列函数可能把差异只留在低位,折算规则可能只读低位;两者叠在一起时表退化成一条链。
链地址法:把冲突挪到槽外
同槽的 key 串成一条链,槽只存链头指针。表可以装超过槽数的元素,删除只是摘一个节点;代价是每一跳都可能是一次 cache miss,而扩容治不了「hash 值真的相同」那种链。
开放寻址:冲突就往后挪
同槽的 key 全存在表内,冲突时沿探测序列往后找空位。换来的是紧凑数组与顺序访存,代价是删除必须留墓碑、且 linear probing 会形成自我加强的聚簇。Robin Hood 变体不减少总探测量,只把它摊平。
两个流派的分工判据
散列函数与冲突解决 · 延伸阅读
- Hash table — Wikipedia en.wikipedia.org 总览:负载因子、两类冲突解决、各操作的期望与最坏复杂度,以及主流语言标准库的实现选择。
- FNV Hash — Fowler / Noll / Vo isthe.com FNV-1 与 FNV-1a 的规范页,含各位宽的 offset basis 与 prime,以及本系列引擎用来自检的测试向量。
- SMHasher — 散列函数测试套件 github.com Austin Appleby 的散列质量测试集:雪崩、差分、稀疏 key、碰撞率。判断一个散列函数够不够好,看它过不过 SMHasher。
- SipHash — a fast short-input PRF 131002.net Aumasson 与 Bernstein 的带密钥散列,抗 hash flooding。Python、Rust、Redis 的字符串散列都用它。
- Crosby & Wallach · Denial of Service via Algorithmic Complexity Attacks (2003) usenix.org hash flooding 的原始论文:构造大量同槽 key 把哈希表退化成链表,从而以极小流量打瘫服务。
探测方式与 Robin Hood
- Celis, Larson & Munro · Robin Hood Hashing (1985) citeseerx.ist.psu.edu Robin Hood 的原始论文:插入时按探测距离决定谁让位,使 PSL 的方差降到常数级。
- Robin Hood Hashing — Emmanuel Goossaert codecapsule.com 带图的实现讲解与 backward shift 删除,本页 §5 的写法参照。
- Linear probing — Wikipedia en.wikipedia.org primary clustering 的成因、Knuth 1963 的期望探测次数公式,以及它对散列函数独立性的要求。
规模:负载因子与两种 rehash
表满了要换一张更大的。成倍扩容让 次插入的总搬迁量保持在 ,单次却可能搬走全部 key——摊还是好的,尾延迟不是。Redis dict 的答案是不搬:建好新表,让后续每次读写各搬一格,旧表在正常流量里自己排空。
负载因子与成倍扩容
表满了要换一张更大的。成倍扩容让 n 次插入的总搬迁量停在 O(n),摊还每次约 3 次写入;代价是这些搬迁全挤在少数几个点上,最贵的一次要搬走当前全部 key。
渐进式 rehash:Redis 的双表
扩容时不搬 key,只建好新表并把游标置零,此后每次读写各搬一格。总搬迁量分毫不减,单次峰值从「全表」降到「一条链」。代价是两张表并存期间每次查找可能要查两遍。
摊还常数与尾延迟是两个指标
扩容与 Redis dict · 延伸阅读
-
redis/src/dict.c
github.com
渐进式 rehash 的一手实现:
dictRehash的搬迁循环、dictRehashStep的单步调用点、dict_can_resize与 fork 期间的暂停逻辑。 - Redis · Memory optimization redis.io 各类型的编码切换阈值与内存开销,含 dict 与紧凑编码之间的取舍。
- 摊还分析讲义 · Brown CS cs.brown.edu 聚合法、记账法、势能法三种摊还分析手法,动态数组成倍扩容是其中的标准例题。
布局:为 cache line 与访存次数重做的两版实现
现代实现优化的不再是比较次数而是访存次数。Swiss table 把每个 key 的 7 位指纹单独存成一张紧凑的 control byte 数组,一次 SIMD 比较筛掉整组 16 个候选;cuckoo hashing 走另一条路,给每个 key 只留两处候选,查找恒为两次访存,代价是插入可能连锁踢出。
Swiss table:一条指令筛掉一整组
把每个 key 的 7 位指纹单独存进一张紧凑的 control byte 数组,一条 SIMD 指令并行比较 16 个字节,得到一个候选位掩码。真正的 key 比较平均只发生一次,代价是极低的假匹配率。
cuckoo hashing:只给两处候选
每个 key 只允许落在两个位置之一,于是查找恒为两次访存,与负载因子无关。代价在插入侧:候选位置都被占时把原住户踢去它的另一处,链式接力;负载越过 0.5 就几乎必然成环。
现代布局 · 延伸阅读
-
Swiss Tables Design Notes — abseil
abseil.io
control byte 的布局、SIMD 组扫描、为什么用 7 位指纹,以及与
std::unordered_map的取舍对照。 - Matt Kulukundis · Designing a Fast, Efficient, Cache-friendly Hash Table (CppCon 2017) youtube.com Swiss table 的设计演讲,逐步展示每一项改动带来的实测收益。
- Fan, Andersen & Kaminsky · MemC3 (2013) cs.cmu.edu 把 cuckoo hashing 用进 memcached:分桶 cuckoo、乐观并发读、以及为什么两次访存的确定性对尾延迟重要。
- Fan et al. · Cuckoo Filter (2014) cs.cmu.edu cuckoo hashing 的思路移植到概率型集合,支持删除,是 Bloom filter 的现代替代之一。
分片:把同一套思路放大到一群机器
槽换成机器,问题就变成「加一台要搬多少数据」。取模分片的答案是几乎全部,consistent hashing 把它压到 ,jump consistent hash 连环都不存、直接算出桶号。
分片 · 延伸阅读
- Karger et al. · Consistent Hashing and Random Trees (1997) cs.princeton.edu consistent hashing 的原始论文:环、虚拟节点、以及加减节点只影响 份额的证明。
- Lamping & Veach · A Fast, Minimal Memory, Consistent Hash Algorithm (2014) arxiv.org jump consistent hash:五行代码、零内存、精确均衡,代价是桶只能从尾部增减。
- DeCandia et al. · Dynamo (SOSP 2007) allthingsdistributed.com 把 consistent hashing 用进生产系统,含虚拟节点数如何影响负载均衡与再平衡代价。
- Redis Cluster Specification redis.io Redis 没用环,而是固定 16384 个 hash slot 再把 slot 分给节点——另一种「加一层间接」的分片方案。