扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑
凌晨两点,线上告警炸了:某个用户的数据"随机消失"了。
查下来原因很朴素——我们扩容了。Redis 从 6 个分片加到 9 个分片,取模算法 hash(key) % N 的 N 变了,于是 几乎所有 key 的归属节点都变了。数据没丢,但一半的请求打到了空节点上,另一半打到还在缓存里的旧节点上。缓存命中率从 95% 掉到 40%,数据库瞬间承接了平时三倍的流量,连接池打满,接口大面积超时。
那天我学到的第一课是:扩容不是加机器,是搬家。而搬家搬得好不好,取决于你在第几层用了什么算法。
取模分片是最直觉的方案,也是最容易在扩容当天把后端打到失智的方案。
一、先问一句:一致性哈希到底在解决什么
先把问题定义清楚,否则我们容易把"一致性哈希"当成一个玄学数据结构来崇拜。
它解决的是:当 key 的数量远大于节点数量时,如何把 key 映射到节点,并且在节点增减时,让尽可能少的 key 搬家。
注意是"远大于"。如果你的集群只有 3 个节点、key 只有 10 个,那一致性哈希纯属大炮打蚊子。哈希取模在这种情况下毫无问题,甚至更简单。
一致性哈希真正的主场是:节点数量会变、key 数量巨大、迁移成本高昂的场景。典型的就是 Redis Cluster、Memcached 集群、分片式存储、以及各种自研的 KV 分层路由。
二、取模哈希为什么是个定时炸弹
先看最朴素的做法:
node = hash(key) % nodeCount
它的优点是极其简单,代码两行搞定,分布也足够均匀——在节点数固定的时候。
问题出在 nodeCount 变成变量的那一刻。数学上很残酷:
- 从 6 个节点扩到 9 个节点,只有
6/9 ≈ 67%的 key 还能落在原节点上 - 换句话说,约 33% 的 key 全部搬家
- 如果从 9 扩到 12,搬家比例是 25%;从 12 扩到 16,搬家比例 25%
规律是:N 变 M 时,约有 1 - N/M 比例的 key 需要迁移。扩容幅度越小,搬家越少;但只要 N 和 M 有公约数(这是常态),迁移量就是"确定的大数",一点都不含糊。
更要命的是瞬时效应。迁移不是一瞬间完成的,扩容期间会出现同一个 key 在两个节点上都存在(新节点预热 + 老节点还没清)或者都不存在的窗口。这时候缓存击穿率飙升,流量全部压向数据库。
我在那次事故里看到的监控曲线就是典型形态:分片 QPS 断崖式重新分布,同时数据库 QPS 曲线以三倍斜率抬头。整件事的连锁反应只用了不到 90 秒。
三、一致性哈希:把映射空间变成一个环
一致性哈希的核心思路只有一句话:把节点和 key 都映射到同一个环上,key 顺时针找到的第一个节点就是它的归属。
构建过程:
- 把整个哈希空间抽象成一个 0 到 2^32-1 的环
- 每个物理节点映射到环上的若干个"虚拟节点"
- 每个 key 计算
hash(key) % 2^32,得到环上的一个位置 - 从该位置顺时针出发,遇到的第一个虚拟节点所属的物理节点,就是这个 key 的归属
扩容时会发生什么?假设你从 6 个节点加到 7 个,只需要在环上插入 7 号节点的那几个虚拟节点。
只有落在"7 号节点虚拟节点"和"它前面那个虚拟节点"之间的 key 需要搬家。其余的 key,顺时针第一个虚拟节点根本没变,归属纹丝不动。
这就是一致性的本质:把"全局重映射"变成"局部增量插入"。加 1 个节点,迁移量从 33% 骤降到 1/7 左右的量级,且新增节点负载轻、旧节点负载重,正好给你一个平滑的数据搬迁窗口。
四、动手写一个:Go 实现
光讲原理没用,咱们写一个能跑的版本。
type ConsistentHash struct {
replicas int // 每个物理节点的虚拟节点数量
hashRing map[uint32]string // 哈希值 -> 物理节点 ID
keys []int // 排序后的哈希值,用于二分查找
nodes map[string]bool
}
func NewConsistentHash(replicas int) *ConsistentHash {
return &ConsistentHash{
replicas: replicas,
hashRing: make(map[uint32]string),
nodes: make(map[string]bool),
}
}
// 节点加入:生成 replicas 个虚拟节点
func (c *ConsistentHash) Add(node string) {
c.nodes[node] = true
for i := 0; i < c.replicas; i++ {
h := hashKey(fmt.Sprintf("%s#%d", node, i))
c.hashRing[h] = node
c.keys = append(c.keys, h)
}
sort.Ints(c.keys)
}
// 节点移除
func (c *ConsistentHash) Remove(node string) {
delete(c.nodes, node)
for i := 0; i < c.replicas; i++ {
h := hashKey(fmt.Sprintf("%s#%d", node, i))
delete(c.hashRing, h)
}
// 重建有序键列表
c.keys = c.keys[:0]
for h, n := range c.hashRing {
c.keys = append(c.keys, h)
}
sort.Ints(c.keys)
}
// 查找归属节点:顺时针第一个虚拟节点
func (c *ConsistentHash) Get(key string) (string, bool) {
if len(c.hashRing) == 0 {
return "", false
}
h := hashKey(key)
idx := sort.SearchInts(c.keys, int(h))
if idx == len(c.keys) {
idx = 0 // 环绕
}
return c.hashRing[uint32(c.keys[idx])], true
}
func hashKey(key string) uint32 {
h := fnv.New32a()
h.Write([]byte(key))
return h.Sum32()
}
结构清晰、逻辑正确,但它只是"正确",还没到"能用"。生产环境你至少还要补上:节点变更时的并发保护、路由结果的本地缓存、节点健康检查与自动摘除。下面是加分项。
五、两个细节,能让一致性哈希从"能用"变成"能用好"
细节一:虚拟节点数量,不是越多越好
replicas 取 1,节点少的时候也能用,但负载均衡会非常糟糕——环上只有几个点,随机 key 撞上某个点的概率方差极大,会出现"某节点扛 40% 流量,其他节点各 10%"的惨状。
业界经验值是 150 到 200,Redis Cluster 的默认 hash-max-entries 相关配置也是类似量级。虚拟节点越多,分布越均匀,但环越大、查找略慢(不过用二分查找后,这点开销可以忽略)。
另外注意一个坑:虚拟节点名不要用简单的序号拼接。像 node-0, node-1, node-2 这种,如果哈希函数分布不理想,虚拟节点容易在环上"扎堆",实际效果会退化。上面代码里用 node#i 配合 FNV-1a,是比较稳的写法。
细节二:负载倾斜是真实存在的
一致性哈希解决的是"搬家量最小",它不保证绝对均匀。在节点数少的时候,偏差可能达到 10% 以上。
如果你对均衡有强要求,可以加一层统计采样校正:定期抽样一批 key,统计每个节点的实际归属比例,发现某个节点明显偏低,就给它动态增加虚拟节点。这是很多生产级实现(如部分 Java 客户端库)会做的"重平衡"逻辑。
六、最难的部分不是算法,是迁移
一致性哈希把"要搬哪些 key"这个问题算清楚了,但真正痛苦的是怎么搬。
给你三条路径,各有各的代价:
方案一:停机迁移——最简单,但需要停服。除非是内部工具类数据,一般业务系统不会接受。
方案二:双写 + 回填 + 切读——主流做法。流程大致是:
- 根据新旧环计算差集,得到"需要迁移的 key 集合"
- 进入双写阶段:新写入同时落旧节点和新节点
- 后台任务扫描旧节点,把存量数据回填到新节点
- 校验通过后,把读流量切到新环
- 观察一段时间,确认旧节点流量归零后再摘除
这个方案的致命点在于:回填期间旧节点的读是滞后的。如果回填很慢,你切读的时候会发现部分 key 在新节点上是旧值。所以回填必须支持限速,不能把源库和目标库一起打满。
方案三:双环并行 + 读兜底——新旧两套路由同时存在,读请求先查新环,未命中再查旧环,靠"最终一致"兜底。省事,但读放大和延迟会明显上升。
迁移期间最要命的一件事:缓存击穿
key 搬家到新节点时,如果新节点上没有预热就直接承接流量,那就是一次"缓存击穿"叠加"数据库过载"。
应对方式主要有三种:
- 预热:迁移前按访问热点把热门 key 的值提前灌进新节点
- 限流与熔断:给迁移窗口单独设阈值,超了就主动降级,宁可返回旧值也不打穿数据库
- 逻辑过期 + 异步刷新:读到的旧值带一个"已过期但仍可用"的标记,后台慢慢刷新,避免热点 key 集中失效
说白了,扩容是一个持续几十分钟甚至几小时的"半故障状态",你要做的不是"不出错",而是"出错时也不要雪崩"。
七、一致性哈希不是银弹:热点 key 怎么办
一致性哈希擅长处理"key 均匀分布"的理想世界,但真实业务里总有超级热点:某个大 V 的主页、某个爆款商品、某个被全网推送的内容。
这类 key 无论你怎么分片,它就是压在一个节点上。这时候一致性哈希帮不了你,得换手段:
方法一:拆 key。把 user:profile:123 拆成 user:profile:123:{0..9},十个分片各存一份,读的时候合并。Redis Cluster 的 hash tag 就是干这个的——{user:123} 这种写法能强制 key 落到同一个 slot,而 {a}-{b} 则能强制不同的 key 落到不同 slot。
方法二:加副本。热点 key 复制多份到多个节点,读请求随机打散。代价是写放大和一致性维护成本上升。
方法三:本地缓存。把热点 key 的值推到应用进程的本地缓存里,靠订阅机制做失效通知。这层能把 90% 的读请求挡在网络之外,但要注意本地缓存的一致性窗口和内存占用。
方法四:直接把它变成静态资源。热点读多、写少的数据,提前算好推给 CDN。这是最暴力但也最有效的一招。
八、最后的碎碎念
回头看那次凌晨两点的故障,我最后悔的不是用了取模,而是没有把"扩容"当成一个需要设计的功能。
很多团队在系统上线时把分片方案当成一次性决定,写死在代码和配置里,扩容时才发现要改代码、发版、等重启。正确的做法是:
- 路由层和数据层彻底解耦,节点列表从配置中心/注册中心动态获取,加减节点不需要发版
- 提前演练——在预发环境做一次真实的扩缩容演练,测的指标是缓存命中率曲线、数据库 QPS 峰值、以及回填耗时,而不是"能不能加上去"
- 把迁移过程做成可观测的:双写比例、回填进度、切换时机都应该是面板上的一根根线,而不是半夜靠人肉 grep 日志
一致性哈希是 1997 年的论文,今天几乎所有分片式存储的底层都在用它的变体。它并不性感,甚至有点反直觉——但它把"扩缩容"从一场灾难,变成了一次可以排练的日常操作。
这大概就是好的基础设施的样子:不炫技,但每次都能接住。
本文涉及的所有讨论均基于通用工程实践,不针对任何特定业务场景。示例代码为教学用途,生产使用前请自行补充并发保护、健康检查与监控埋点。