扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑

2026-11-11 1 0

扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑

凌晨两点,线上告警炸了:某个用户的数据"随机消失"了。

查下来原因很朴素——我们扩容了。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 顺时针找到的第一个节点就是它的归属。

构建过程:

  1. 把整个哈希空间抽象成一个 0 到 2^32-1 的环
  2. 每个物理节点映射到环上的若干个"虚拟节点"
  3. 每个 key 计算 hash(key) % 2^32,得到环上的一个位置
  4. 从该位置顺时针出发,遇到的第一个虚拟节点所属的物理节点,就是这个 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"这个问题算清楚了,但真正痛苦的是怎么搬。

给你三条路径,各有各的代价:

方案一:停机迁移——最简单,但需要停服。除非是内部工具类数据,一般业务系统不会接受。

方案二:双写 + 回填 + 切读——主流做法。流程大致是:

  1. 根据新旧环计算差集,得到"需要迁移的 key 集合"
  2. 进入双写阶段:新写入同时落旧节点和新节点
  3. 后台任务扫描旧节点,把存量数据回填到新节点
  4. 校验通过后,把读流量切到新环
  5. 观察一段时间,确认旧节点流量归零后再摘除

这个方案的致命点在于:回填期间旧节点的读是滞后的。如果回填很慢,你切读的时候会发现部分 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 年的论文,今天几乎所有分片式存储的底层都在用它的变体。它并不性感,甚至有点反直觉——但它把"扩缩容"从一场灾难,变成了一次可以排练的日常操作。

这大概就是好的基础设施的样子:不炫技,但每次都能接住。


本文涉及的所有讨论均基于通用工程实践,不针对任何特定业务场景。示例代码为教学用途,生产使用前请自行补充并发保护、健康检查与监控埋点。

相关文章

「数据过千万就得分表」——这句话害了多少人?分库分表的四个坑与正解
你的代码一行没改,QPS 却从 800 掉到 200:Java 锁膨胀的四个阶段,以及那个不可逆的瞬间
你的订单在 A 库,库存卡在 B 库:分布式事务的四份解药,没有一个是银弹
缓存明明命中了,为什么接口还是慢?—— HTTP 缓存里的那点破事
监控全绿,用户却喊卡:你的平均值一直在撒谎
你的服务被刷爆,可能不是流量大:限流器写错的 7 个姿势

发布评论