跳到主内容

实测对比:缓存扩容如何把迁移量压到 10% ​

问题背景:缓存集群一扩容,命中率为什么雪崩 ​

缓存集群从 4 个节点扩到 5 个节点,理论上只增加了 25% 的容量,但如果使用 hash(key) % nodeCount 定位节点,绝大多数 key 的归属都会变化。以 100 万个 key 为例,4 节点到 5 节点约有 80% 的 key 需要迁移;节点数越多,扩容瞬间缓存越像被整体清空,数据库会承受明显的读冲击。

一致性哈希的核心价值,是把节点也放入同一个哈希环:key 沿环顺时针找到第一个节点。扩容时,只有新节点与其后继节点之间的一段数据需要迁移,理论迁移比例约为 1/(N+1)。真实工程里还要解决两个问题:节点较少时分布不均,以及节点规格不同导致的容量倾斜。常见做法是为每个物理节点配置大量虚拟节点。

本文用一个可直接运行的 Go 程序,对比普通取模哈希、无虚拟节点一致性哈希、不同虚拟节点数量下的分布与迁移量,并给出缓存扩容时可落地的配置建议。

方案对比:取模哈希、一致性哈希与虚拟节点 ​

普通取模哈希实现最简单,但节点数变化会改变取模基数,不适合频繁扩缩容。一致性哈希只影响环上的局部区间,扩容迁移量稳定。虚拟节点则是物理节点在环上的多个分身,副本数越多,key 分布越接近均匀,单节点热点概率越低。

写入缓存 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% 的必要迁移,再通过异步预热和限速灰度平滑释放,通常就能把扩容从“缓存雪崩演练”变成一次可观测、可回滚的常规操作。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。