19.4 一致性哈希
一句话
一致性哈希把节点和 key 都映射到一个哈希环上,key 顺时针找到第一个节点;增删节点只影响相邻的一段弧,平均只迁移
1/(N+1)的数据——代价是环的元数据,以及「节点少时分布很不均」。
环:一条规则解决增删
hash 空间 [0, 2^64)
0
┌─────┴─────┐
N3 ●─┘ └─● N1
│ key k │
│ ↓ │ k 顺时针遇到的第一个节点 = N1
N2 ●───────────────┘
2^64-1
三条规则:
- 每个物理节点用
hash(node_id)映射成环上的一个(或多个)token; - 每个 key 用
hash(key)映射到环上一点; - 从 key 的位置顺时针找第一个 token,该 token 所属节点就是它的分片。
增删节点时:
- 增加节点:新 token 插入环,只接管「它到前一个 token」这段弧上的 key;
- 删除节点:它的弧交给顺时针后继,其他 key 不动;
- 迁移量:节点等权时,新增一个节点平均接管
1/(N+1)的 key。
一个可运行的最小实现
package main
import (
"fmt"
"sort"
)
func hash(s string) uint64 {
var h uint64 = 1469598103934665603
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= 1099511628211
}
// splitmix64 finalizer:避免相似 token 聚簇
h ^= h >> 30
h *= 0xbf58476d1ce4e5b9
h ^= h >> 27
h *= 0x94d049bb133111eb
h ^= h >> 31
return h
}
type ring struct {
toks []uint64
owners []int
}
func newRing(nodes, vnodes int) *ring {
r := &ring{}
for n := 0; n < nodes; n++ {
for i := 0; i < vnodes; i++ {
r.toks = append(r.toks, hash(fmt.Sprintf("node-%d#%d", n, i)))
r.owners = append(r.owners, n)
}
}
idx := make([]int, len(r.toks))
for i := range idx {
idx[i] = i
}
sort.Slice(idx, func(a, b int) bool { return r.toks[idx[a]] < r.toks[idx[b]] })
toks := make([]uint64, len(idx))
owners := make([]int, len(idx))
for i, j := range idx {
toks[i], owners[i] = r.toks[j], r.owners[j]
}
r.toks, r.owners = toks, owners
return r
}
func (r *ring) owner(key string) int {
h := hash(key)
i := sort.Search(len(r.toks), func(i int) bool { return r.toks[i] >= h })
if i == len(r.toks) {
i = 0 // 绕回环首
}
return r.owners[i]
}
func main() {
const keys = 100000
three := newRing(3, 1)
var load [3]int
for i := 0; i < keys; i++ {
load[three.owner(fmt.Sprintf("key-%d", i))]++
}
fmt.Printf("3 节点 × 1 token:负载份额 %d/%d/%d\n", load[0], load[1], load[2])
four := newRing(4, 1)
moved := 0
for i := 0; i < keys; i++ {
k := fmt.Sprintf("key-%d", i)
if three.owner(k) != four.owner(k) {
moved++
}
}
fmt.Printf("增加第 4 个节点:迁移 %.1f%% 的 key(理想 25%%)\n", float64(moved)*100/keys)
}
输出:
3 节点 × 1 token:负载份额 64729/28251/7020
增加第 4 个节点:迁移 32.9% 的 key(理想 25%)
两个数字都要会读:
- 负载份额 64.7% / 28.3% / 7.0%:3 个 token 把环切成 3 段,段长随机,所以极不均匀。这不是实现 bug,是「节点少 + 每节点 1 个 token」的固有方差——虚拟节点(19.5)就是治它的。
- 迁移 32.9%:单次实验偏离 25%,因为新节点的 token 落在哪段弧是随机的;多轮平均会收敛到约 25%(30 轮平均 29.4%,见 19.5 的表)。
⚠️ 哈希函数必须有良好的雪崩性。用 FNV-1a 直接哈希
node-2#0…node-2#9这类「只差后缀」的 token,值会挤在一起,同一节点的 vnode 聚成一段弧,负载能到 93%/4%/3%(19.5 有这组实测)。要么换 xxHash/MurmurHash,要么加 finalizer。
它到底「一致」在哪
一致性哈希的 consistent 指映射的稳定性,不是 CAP 里的一致性(Consistency):
| 「一致」 | 含义 |
|---|---|
| 一致性哈希的 consistent | 增删节点时,大多数 key 的映射不变(单调性) |
| CAP 的一致性 | 读能否看到最新写(16.2 线性一致性) |
两者没有任何关系。一个用一致性哈希分片的缓存,完全可能是最终一致的;一个线性一致的系统,也完全可以不用一致性哈希。
副本怎么放
单副本只解决「数据分到哪」,还要解决「副本放哪」:
key k → N1(主)
→ 顺时针下一个不同物理节点 N2(副本 1)
→ 再下一个 N3(副本 2)
两个细节:
- 跳过同一物理节点的多个 vnode:否则 3 副本可能落在同一台机器上;
- 机架/可用区感知:顺时针找节点时跳过同机架/同 AZ,直到凑齐副本数(否则机房断电一起挂)。
副本放置本质上是「在环上找一个满足约束的节点序列」,约束包括:不同物理节点、不同机架、不同可用区、不同地域。环只是提供了「从哪开始找」的确定性起点。
与其它「最小迁移」方案对比
| 方案 | 增删节点迁移 | 元数据 | 查找成本 | 权重 | 备注 |
|---|---|---|---|---|---|
取模 % N | 几乎全部 | 无 | O(1) | 无 | 扩容灾难 |
| 一致性哈希(环) | 1/(N+1) | N × v 个 token | O(log(Nv)) | ✅ vnode 加权 | 本文 |
| Rendezvous / HRW | 1/(N+1) | 无 | O(N)(可用树优化) | ✅ 加权打分 | 每个 key 算所有节点分数取最大 |
| Jump Consistent Hash | 1/(N+1) | 无 | O(log N) | ❌ 等权 | 桶必须编号 0..N-1,内存 O(1) |
| Maglev Hashing | 约 1/(N+1) | 查找表(大小 M) | O(1) | ✅ 表内加权 | Google 负载均衡器方案 |
Rendezvous(HRW)哈希
不用环:对每个 key 计算 score = hash(key, node),选分数最大的节点。
- 加节点:只有「新节点分数最高」的 key 会迁移,期望
1/(N+1); - 删节点:只有它拥有的 key 迁移;
- 没有 token 元数据,节点名变化不影响其他节点;
- 计算要遍历所有节点(O(N)),N 大时用层次结构/树优化。
它的分布比「1 token 的环」更均匀(每个 key 独立比较所有节点,没有弧长方差),所以在节点不多、想省元数据的场景很受欢迎。缺点是每次查找要算 N 次哈希。
Jump Consistent Hash
Lamping 与 Veach(2014)的算法:桶编号 0..N-1,用伪随机跳跃直接算出 key 属于哪个桶。
- 时间 O(log N),空间 O(1);
- 增删桶只迁移
1/(N+1); - 限制:桶必须连续编号、等权,不能按名字映射,也不支持任意权重。
适合「分片就是编号」的场景(如按 shard id 分库),不适合「节点随时上下线、名字固定」的集群。
Maglev Hashing
Google 的负载均衡器方案:为每个节点生成一张查找表(大小 M),key 哈希到表位置直接查。
- 查找 O(1),迁移小;
- 表大小 M 决定粒度与内存;
- 支持权重(通过表内条目分配)。
适合 L4 负载均衡这种「超高性能、节点集合偶尔变化」的场景。
反模式
| 反模式 | 后果 |
|---|---|
| 用 1 个 token/节点就上线 | 负载 65/28/7,热点片先挂 |
| 用弱哈希(无雪崩)生成 token | 同节点 vnode 聚簇,虚拟节点失效 |
| 用会变的字段(IP、主机名)做节点标识 | 重启/换 IP 后 token 变化,触发大规模迁移 |
| 副本顺时针找节点不跳过同机 | 3 副本落一台机器,机器挂全丢 |
| 节点权重相同但机器规格不同 | 小机器被压垮,大机器空闲 |
| 把一致性哈希当「强一致」 | 它只解决分布,不解决读写一致性 |
| key 范围查询用一致性哈希 | 环不保存顺序,范围查询要全环扫描 |
自检
Q1 3 个节点、每节点 1 个 token,负载是 64.7%/28.3%/7.0%。这是哈希函数坏了吗?
▸ 答案:不是。环被 3 个随机 token 切成 3 段,段长服从随机分布,方差很大;3 段里出现 65%/7% 很常见。哈希函数只保证「token 均匀落在环上」,不保证「少量 token 切出的段等长」。解决办法是每个物理节点放多个虚拟节点(19.5),把「3 段」变成「几百段」,用大数定律把方差压下去。
Q2 一致性哈希增加一个节点,为什么平均只迁移 1/(N+1)?为什么单次可能差很多?
▸ 答案:新节点插入环后只接管「它到前驱」这一段弧;等权节点各占约 1/(N+1) 的环长,所以期望迁移 1/(N+1)。单次实验中,新 token 落在哪段弧是随机的,弧长可能远大于或远小于平均值(示例里是 32.9%);token 越多(虚拟节点越多),单次波动越小。
Q3 一致性哈希的「一致」和 CAP 的「C」是一回事吗?
▸ 答案:不是。一致性哈希的 consistent 指「增删节点时映射尽量不变」(单调性),是分布算法;CAP 的 C 指「读能否看到最新写」,是一致性模型。两者正交:用一致性哈希分片的系统可以实现任何一致性等级,也可以完全不用一致性哈希而做到线性一致。
Q4 Rendezvous 哈希和一致性哈希环,各自适合什么场景?
▸ 答案:Rendezvous 不需要维护环/token,节点增减只影响自己那部分 key,分布也更均匀(没有弧长方差),但每次查找要算 O(N) 个哈希,适合节点数不多(几十到几百)、想省元数据的场景。环需要维护 token 表,查找 O(log T),但支持虚拟节点、权重、副本顺时针放置,适合节点多、需要精细控制权重的集群(如 Dynamo/Cassandra 风格)。节点数很大且要 O(1) 查找时,还会选 Maglev 这类查表方案。
Q5 一个 3 副本的环,为什么「顺时针取 3 个节点」不够,还要考虑机架/可用区?
▸ 答案:顺时针取 3 个不同物理节点只保证「不是同一台机器」,但如果这 3 台在同一个机架或同一个可用区,机架断电/ AZ 故障会同时带走 3 副本,等于单副本。副本放置要按故障域排列:先跨机器,再跨机架,再跨可用区/地域;环提供确定性起点,约束由放置策略叠加。这就是「一致性哈希 + 故障域感知」的组合。
跨篇相关:分区键与负载不均见 19.1 分区键设计;取模扩容的迁移代价见 19.2 哈希分区;虚拟节点如何压平方差见 19.5 虚拟节点;有界负载的变种见 19.6 有界负载一致性哈希;节点增减后的数据搬迁见 19.7 再平衡与数据迁移;缓存层的一致性哈希实践见 41.2 本地缓存与分布式缓存;会话粘性路由的实测见 37.2 无状态化;CAP 与一致性的关系见 17.1 CAP 的由来与准确表述;服务发现与客户端路由见 48.2 服务发现。