外观
实测对比:缓存扩容如何把迁移量压到 10%
问题背景:缓存集群一扩容,命中率为什么雪崩
缓存集群从 4 个节点扩到 5 个节点,理论上只增加了 25% 的容量,但如果使用 hash(key) % nodeCount 定位节点,绝大多数 key 的归属都会变化。以 100 万个 key 为例,4 节点到 5 节点约有 80% 的 key 需要迁移;节点数越多,扩容瞬间缓存越像被整体清空,数据库会承受明显的读冲击。
一致性哈希的核心价值,是把节点也放入同一个哈希环:key 沿环顺时针找到第一个节点。扩容时,只有新节点与其后继节点之间的一段数据需要迁移,理论迁移比例约为 1/(N+1)。真实工程里还要解决两个问题:节点较少时分布不均,以及节点规格不同导致的容量倾斜。常见做法是为每个物理节点配置大量虚拟节点。
本文用一个可直接运行的 Go 程序,对比普通取模哈希、无虚拟节点一致性哈希、不同虚拟节点数量下的分布与迁移量,并给出缓存扩容时可落地的配置建议。
方案对比:取模哈希、一致性哈希与虚拟节点
普通取模哈希实现最简单,但节点数变化会改变取模基数,不适合频繁扩缩容。一致性哈希只影响环上的局部区间,扩容迁移量稳定。虚拟节点则是物理节点在环上的多个分身,副本数越多,key 分布越接近均匀,单节点热点概率越低。
需要强调的是,一致性哈希不是“零迁移”,而是“有上界的局部迁移”。虚拟节点也不是越多越好:副本数增加会带来更多环上节点、内存占用和路由表更新成本。对大多数 Redis 代理、本地缓存分片或客户端路由场景,每物理节点 100 到 500 个虚拟节点已经能获得较好的均衡效果。
Go 实现:可运行的一致性哈希路由
下面示例不依赖第三方库,使用 FNV-1a 哈希。virtualFactor 表示每个物理节点的虚拟节点数,weights 可用于异构节点:8C16G 的机器可以配置更高权重,从而分到更多虚拟节点。
go
package main
import (
"fmt"
"hash/fnv"
"sort"
"strconv"
)
// PhysicalNode 表示一个真实缓存节点
type PhysicalNode struct {
Name string
Weight int // 权重,容量大的节点可配置更大值
}
// HashRing 保存虚拟节点环和虚拟节点到物理节点的映射
type HashRing struct {
virtualFactor int
nodes map[string]PhysicalNode
ring []uint32
owner map[uint32]string
}
func NewHashRing(virtualFactor int) *HashRing {
if virtualFactor <= 0 {
virtualFactor = 1
}
return &HashRing{
virtualFactor: virtualFactor,
nodes: make(map[string]PhysicalNode),
owner: make(map[uint32]string),
}
}
func hashString(s string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(s))
return h.Sum32()
}
// AddNode 添加物理节点,并按权重生成虚拟节点
func (r *HashRing) AddNode(node PhysicalNode) {
if node.Weight <= 0 {
node.Weight = 1
}
r.nodes[node.Name] = node
for i := 0; i < r.virtualFactor*node.Weight; i++ {
// 虚拟节点名称必须包含物理节点名和副本编号
vn := node.Name + "#vn" + strconv.Itoa(i)
v := hashString(vn)
r.ring = append(r.ring, v)
r.owner[v] = node.Name
}
sort.Slice(r.ring, func(i, j int) bool { return r.ring[i] < r.ring[j] })
}
// GetNode 返回 key 顺时针遇到的第一个物理节点
func (r *HashRing) GetNode(key string) string {
if len(r.ring) == 0 {
return ""
}
h := hashString(key)
idx := sort.Search(len(r.ring), func(i int) bool {
return r.ring[i] >= h
})
if idx == len(r.ring) {
idx = 0
}
return r.owner[r.ring[idx]]
}
func modNode(key string, nodeCount int) string {
if nodeCount <= 0 {
return ""
}
return "node-" + strconv.Itoa(int(hashString(key))%nodeCount)
}
func buildNodes(n int) []PhysicalNode {
nodes := make([]PhysicalNode, 0, n)
for i := 0; i < n; i++ {
nodes = append(nodes, PhysicalNode{
Name: "node-" + strconv.Itoa(i),
Weight: 1,
})
}
return nodes
}
func buildRing(n, virtualFactor int) *HashRing {
ring := NewHashRing(virtualFactor)
for _, node := range buildNodes(n) {
ring.AddNode(node)
}
return ring
}
func main() {
const keyCount = 100000
// 普通取模:4 节点扩到 5 节点
modMoved := 0
for i := 0; i < keyCount; i++ {
key := "user:" + strconv.Itoa(i)
if modNode(key, 4) != modNode(key, 5) {
modMoved++
}
}
fmt.Printf("取模哈希迁移量:%.2f%%\n", float64(modMoved)*100/keyCount)
// 一致性哈希:不同虚拟节点副本数
for _, vf := range []int{1, 50, 200, 800} {
oldRing := buildRing(4, vf)
newRing := buildRing(5, vf)
moved := 0
counts := make(map[string]int)
for i := 0; i < keyCount; i++ {
key := "order:" + strconv.Itoa(i)
before := oldRing.GetNode(key)
after := newRing.GetNode(key)
if before != after {
moved++
}
counts[after]++
}
min, max := keyCount, 0
for _, c := range counts {
if c < min { min = c }
if c > max { max = c }
}
fmt.Printf("虚拟节点=%3d,迁移量=%5.2f%%,最大/最小负载比=%.2f\n",
vf, float64(moved)*100/keyCount, float64(max)/float64(min))
}
}在本机一次典型运行中,取模哈希迁移率约为 80%;一致性哈希迁移率约为 20%,符合 1/5 的理论值。虚拟节点为 1 时,负载可能明显倾斜;提升到 200 后,最大节点与最小节点负载比通常可降到 1.1 左右;继续增加到 800,均衡收益变小,但环内存和拓扑发布成本继续上升。
扩容步骤:从双路由到灰度迁移
生产扩容不要只“部署新节点然后切换路由”。更稳妥的流程如下。
第一,发布新拓扑前保留旧拓扑。客户端或代理应同时持有 oldRing 与 newRing,根据版本号灰度切换。第二,读取时先按新环路由,新节点 miss 后可尝试旧节点;命中旧数据时可异步回填新节点。第三,写入直接走新环,避免扩容后还持续产生旧分片数据。第四,迁移任务按 key 或虚拟节点区间分批限速执行,并监控数据库 QPS、缓存命中率和回源延迟。
双读或回填逻辑可以抽象为如下伪代码:
go
func (s *CacheService) GetWithMigration(key string) (string, error) {
newNode := s.newRing.GetNode(key)
if val, ok := s.read(newNode, key); ok {
return val, nil
}
// 新节点未命中时,尝试旧节点,避免扩容瞬间全部回源数据库
oldNode := s.oldRing.GetNode(key)
if oldNode != newNode {
if val, ok := s.read(oldNode, key); ok {
// 限速异步回填,避免迁移风暴
s.migrationQueue <- MoveTask{
Key: key,
FromNode: oldNode,
ToNode: newNode,
}
return val, nil
}
}
val, err := s.db.Load(key)
if err != nil {
return "", err
}
s.writeAsync(newNode, key, val, s.ttl)
return val, nil
}如果缓存中保存的是短 TTL 数据,也可以不做全量搬迁,让旧数据自然过期,只对热点 key 做主动预热。判断标准是:能否接受扩容窗口内回源流量上升,以及数据库是否有足够余量。
避坑总结:一致性哈希常见错误与修正
1. 虚拟节点命名不稳定,导致扩容变成全量迁移
常见错误是只对节点编号生成虚拟节点,例如使用 hash(strconv.Itoa(i))。当插入新节点或删除故障节点后,副本编号可能复用,原本 vn-3 属于 A 节点,变更后可能属于 B 节点,造成大量 key 错误漂移。修正方法是虚拟节点名称必须包含稳定的物理节点标识,例如 node-address#vn3,且节点生命周期内不要复用名称。
2. 只追求均匀分布,忽略迁移限速
虚拟节点调到 1000 个以后,分布确实更均匀,但扩容时客户端同时回填,会把旧缓存节点、网络和数据库一起打满。现象是缓存命中率下降不多,但数据库 CPU、连接池等待时间和 P99 延迟快速上升。应按虚拟节点区间切分任务,加入令牌桶限速、并发度控制和失败重试,并在业务低峰执行。
3. 物理节点权重配置后没有同步更新客户端
异构集群常给大规格节点更高权重,但如果部分客户端仍使用旧权重,环上的虚拟节点集合不同,同一个 key 会被路由到不同节点,表现为短时反复 miss、写后读不到。修正方法是将拓扑版本号、节点列表、虚拟节点系数和权重作为统一配置发布;客户端校验版本,拒绝混用不同算法参数的环。
4. 故障摘除与扩容使用不同策略
直接删除故障节点会让它的所有虚拟节点同时消失,流量瞬时压到环上的后继节点。更稳妥的方式是引入受控降级:先降低故障节点权重或将其虚拟节点标记为迁移中,再逐步把副本转移到多个目标节点;同时配合主从切换和热点 key 保护,避免单点摘除引发连锁击穿。
5. 把一致性哈希误当成缓存一致性方案
一致性哈希解决的是 key 到节点的稳定路由,不解决数据库与缓存之间的数据一致性。写路径仍需根据业务选择旁路缓存、写穿、删缓存重试或订阅 binlog 刷新等策略。否则扩容迁移再平滑,也可能把旧值复制到新节点,放大脏读问题。
实测结论与配置建议
如果集群规模固定且几乎不扩缩容,普通取模哈希足够简单;只要存在在线扩容、故障摘除或异构容量管理,就应选择一致性哈希。虚拟节点数量建议从每物理节点 100 到 300 开始压测:观察 10 万到 1000 万 key 下的负载偏差、路由表内存、拓扑切换耗时和扩容回源量。
对缓存扩容而言,真正的目标不是把迁移量做到理论最低,而是在命中率、数据库压力、迁移时长和实现复杂度之间取得平衡。4 节点扩到 5 节点时,将约 80% 的无效扰动控制到约 20% 的必要迁移,再通过异步预热和限速灰度平滑释放,通常就能把扩容从“缓存雪崩演练”变成一次可观测、可回滚的常规操作。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。