Skip to content

缓存扩容后,一致性哈希为什么仍会打穿数据库? ​

缓存原来有三台机器,流量上涨后加了第四台。发布完新路由,数据库读量突然上升,缓存命中率掉了下去。有人说:“是不是没用一致性哈希?换成哈希环就好了。”这句话只说对了一半。

一致性哈希能减少路由变化,却不会把旧机器上的数据自动搬到新机器。哪怕只有四分之一的键改了归属,十万个热点键里也可能有两万多个去新机器碰一个空缓存。如果数据库本来只剩一点余量,这波回源足以把它压垮。要说清扩容,需要把三件事拆开:键现在应该去哪台、数据此刻实际在哪台、谁有权接收新的写入。

这期我用一段能直接运行的 Go 程序,从三台扩到四台,比较取模与哈希环;然后把程序没有模拟的那部分补上:冷启动、迁移时的并发写、路由版本和失败后的恢复。所有节点和键都是演示数据,实验数字只说明这套固定输入下的路由结果,不是生产集群的性能测量。

对 3 取余改成对 4 取余,错在哪儿 ​

最简单的分片办法是 hash(key) % 节点数。假设哈希值为 17,三台时 17 % 3 = 2,四台时 17 % 4 = 1,这个键的目标机器变了。机器一增加,公式里的除数对每个键都变了,并非只影响新机器附近的键。

对均匀分布的整数哈希值,三台变四台时,什么时候旧余数等于新余数?看 hash(key) % 12 就够了。12 是 3 和 4 的最小公倍数,一个周期里只有余数 0、1、2 满足“对 3 取余和对 4 取余相同”,其余 9 种情况都会换节点。因此理论上约四分之三的键换了归属。这个推导只适用于这里的 3→4 和均匀哈希假设;扩容比例不能一概背成“75%”。

路由变化对缓存与持久化存储的后果不同。缓存去新位置读不到,可以从数据库回填,只是要算清回源峰值。若没有其他副本的持久化数据还留在旧位置,直接按新公式读,会把“尚未迁移”误判成“对象不存在”。取模本身没有错,错在把一个会随节点数整体变化的公式,当作已经完成数据迁移的事实。

哈希环究竟固定了什么 ​

一致性哈希把节点和键放到同一个哈希空间中。把这个空间首尾相接想成一个环:键从自己的位置顺时针走,遇到的第一个节点就是归属节点。哈希值最大处继续往前会回到零,这就是“环”的由来。它是一个路由规则,不要求真的有一个物理的环形网络。

画个很小的例子。节点 A 在位置 10,B 在 40,C 在 80。哈希到 25 的键去 B,哈希到 90 的键绕回去找 A。现在新节点 D 落在 20:位置在 (10,20] 的键原来去 B,现在去 D;哈希到 25 的键仍然去 B。旧节点的位置没有变,因此原来归 A 的键不会突然跑到 C,原来归 C 的键也不会无缘无故跑到 B。

加入一台新机器时,只会有一部分键从旧节点转向新节点;不会发生“旧节点之间互相改派”。这是哈希环最有用的性质。假如节点位置随机且负载按键数均匀,四台机器中新增节点的期望份额大约是四分之一。所以三台扩四台时,路由改动的目标量级约为 25%,比取模的约 75% 小。但“期望四分之一”并不是“每次恰好四分之一”,更不表示迁移过程已经发生。

这里说的“均匀”还藏了一个条件:我们统计的是键的数量。一个超级热门的键每秒读十万次,另外十万个冷键每小时读一次,按键数平衡完全不等于按请求量平衡。一致性哈希不会自动拆分一个热键。

一台机器只占环上一个点,会有多偏 ​

如果三台机器各占一个点,环上三段的长度取决于随机落点。A 和 B 很近、B 和 C 很远时,某个节点可能管着接近半圈,另一个只管很短一截。就算键的哈希很均匀,机器负载也不均匀。

虚拟节点是一个朴素补救:让 A 不是只占一个位置,而是占 A#0、A#1、A#2 ……很多位置;B、C 也一样。每台物理机接管许多零散区段,大区段和小区段更容易互相抵消。扩容时给 D 加同样数量的虚拟位置,它从各处接走小块区间,而不是从某一台机器接走整整一大段。

代价也直接:环上的点变多,路由表要存这些点;计算一把键归谁,要在有序点表里找第一个大于等于键哈希的位置。代码里可以二分查找,复杂度大致是 O(log(机器数 × 每机虚拟节点数))。建环还要排序。和一次网络往返相比这通常不大,但如果每个客户端都自己建环,就要控制配置大小与更新时间。

虚拟节点数也不是越多越好。增加虚拟节点会改变原有环上的位置集合;同一台机器从 128 个点改成 512 个点,也会重新分配一些键。若你在扩容时顺手更改所有老节点的虚拟节点数,就破坏了“只向新节点迁移”的前提。节点 ID、哈希算法、虚拟节点编号规则、编码方式都应当作为路由协议固定下来,不能各版本客户端各算各的。

自己跑一次十万个键 ​

下面这个程序只用 Go 标准库。它用 SHA-256 的前 64 位给键和虚拟节点定位,用同一批 key-000000 到 key-099999 比较三台与四台的路由。默认每台机器 512 个虚拟节点,可以用 -replicas 1 或 -replicas 32 改参数。SHA-256 在这里是为了让实验输入稳定、分布容易观察,不意味着缓存路由必须使用密码学哈希。

保存为 main.go,运行 go run main.go:

go
package main

import (
    "crypto/sha256"
    "encoding/binary"
    "flag"
    "fmt"
    "sort"
)

type point struct {
    hash uint64
    node string
}

type ring []point

func hash(s string) uint64 {
    sum := sha256.Sum256([]byte(s))
    return binary.BigEndian.Uint64(sum[:8])
}

func makeRing(nodes []string, replicas int) ring {
    r := make(ring, 0, len(nodes)*replicas)
    for _, node := range nodes {
        for i := 0; i < replicas; i++ {
            r = append(r, point{hash(fmt.Sprintf("%s#%d", node, i)), node})
        }
    }
    sort.Slice(r, func(i, j int) bool { return r[i].hash < r[j].hash })
    return r
}

func (r ring) owner(key string) string {
    h := hash(key)
    i := sort.Search(len(r), func(i int) bool { return r[i].hash >= h })
    if i == len(r) {
        i = 0
    }
    return r[i].node
}

func main() {
    replicas := flag.Int("replicas", 512, "virtual nodes per physical node")
    flag.Parse()
    oldNodes := []string{"A", "B", "C"}
    newNodes := []string{"A", "B", "C", "D"}
    before := makeRing(oldNodes, *replicas)
    after := makeRing(newNodes, *replicas)

    movedMod, movedRing, oldToOld := 0, 0, 0
    loadBefore := map[string]int{}
    loadAfter := map[string]int{}
    for i := 0; i < 100_000; i++ {
        key := fmt.Sprintf("key-%06d", i)
        h := hash(key)
        if h%3 != h%4 {
            movedMod++
        }
        a, b := before.owner(key), after.owner(key)
        loadBefore[a]++
        loadAfter[b]++
        if a != b {
            movedRing++
            if b != "D" {
                oldToOld++
            }
        }
    }
    fmt.Printf("虚拟节点/机器: %d\n", *replicas)
    fmt.Printf("取模迁移: %d/100000\n", movedMod)
    fmt.Printf("哈希环迁移: %d/100000\n", movedRing)
    fmt.Printf("旧节点之间迁移: %d\n", oldToOld)
    fmt.Printf("扩容前: A=%d B=%d C=%d\n", loadBefore["A"], loadBefore["B"], loadBefore["C"])
    fmt.Printf("扩容后: A=%d B=%d C=%d D=%d\n", loadAfter["A"], loadAfter["B"], loadAfter["C"], loadAfter["D"])
}

这段代码在本地运行的输出如下。取模迁移和哈希环迁移都是路由目标改变的键数,没有真的启动缓存、复制数据或访问数据库:

text
虚拟节点/机器: 512
取模迁移: 75273/100000
哈希环迁移: 24729/100000
旧节点之间迁移: 0
扩容前: A=33873 B=33307 C=32820
扩容后: A=25451 B=25627 C=24193 D=24729

75,273 接近前面推导的 75,000;24,729 接近新增节点约四分之一的期望份额。最值得看的是“旧节点之间迁移: 0”。加 D 以后,原来属于 A、B、C 的键如果换了归属,目标只会是 D。这不是某个样本恰好幸运,而是旧节点点位保持不动时,环形路由的结构性结果。

把参数改成 go run main.go -replicas 1,同一批键的四台分布会变成 A=13,192、B=34,285、C=45,257、D=7,266。这里新节点只接走 7.3% 的键,远没达到“四台各 25%”。改成每台 32 个虚拟节点时,D 接走 17,312 个;512 个时接走 24,729 个。虚拟节点增多通常使分布更平滑,但这些具体数字依赖节点名、哈希函数和测试键集合,不能当成通用推荐参数。

代码还有几个刻意保留的简化。它把节点 ID 直接写成 A、B、C,真实系统要用稳定的节点标识;它取 64 位位置,没有处理位置碰撞,生产实现需要定义确定的碰撞处理和排序规则;它只比较键的路由数量,不模拟请求频率、对象大小、TTL、复制或故障。越过这些前提,实验数字就不再代表真实系统的风险。

512 个虚拟节点是怎么算出来的 ​

上面的 512 不是“经验推荐值”,只是为了让一次实验里的分布比较平滑。用同一批十万个键、同一组节点名,只改每台的虚拟节点数,可以得到下面这张表:

每台虚拟节点D 接走的键扩容后最少的机器扩容后最多的机器
17,266D:7,266C:45,257
3217,312D:17,312A:28,164
51224,729C:24,193B:25,627
102424,895B:24,261A:25,569

一台机器只放一个点,随机落点把 D 挤在很短的区间里;点数增多后,每台机器拿到的是许多小段之和,极端长短更容易相互抵消。但不要从这张表推出“虚拟节点数增加,负载一定单调变好”。换一批节点 ID 或键,32 到 128 的某次结果也可能暂时更偏。这里展示的是固定输入的一次观测,不是参数选择定理。

期望为什么接近 25%?在四台机器容量相同、各自使用相同数量且独立散布的点时,任意一个位置的顺时针第一个点,按对称性属于 A、B、C、D 的机会相同。大量键均匀落到哈希空间里,D 的期望份额就是四分之一。有限的虚拟节点、有限的样本和非均匀请求热度,会让实际结果偏离这个数。算法保证的是“新增点只从其后继旧节点手里接范围”,并不保证每次扩容的请求负载恰好按机器数平均。

参数要靠本系统的键与流量来挑。可以用一份脱敏后的键样本,离线构造 32、128、512 等多种环,比较每台机器分到的对象字节数、读 QPS 和内存估计,而不只比键数。若不同虚拟节点数之间的收益已经很小,就没必要为了几千分之一的平均差异把路由表继续放大。

新路由生效了,D 机器上却没有旧数据 ​

回到开头。实验里 D 接走了 24,729 个键。路由器知道这些键以后归 D,不代表 D 的内存里已经有它们。最简单的上线方式是直接把新环发布到所有客户端:这些键第一次读 D 都未命中,随后去数据库补。若它们是冷键,这可能没什么;若恰好包括热门数据,数据库会在短时间内收到本来由 B、C 缓存挡住的读请求。

先给回源量做个估算。假设迁移的不是十万个互不相关的键,而是十万个都有读流量的缓存对象;在新节点还没预热时,理论上的第一次读都可能回源。真实情况由 TTL、热度分布、同一键请求合并、预热速度和数据库容量决定,不能用 24.7% 直接乘总 QPS 断言峰值。但这个比例足以提醒我们:路由改动小,不代表回源峰值一定小。

缓存系统可以选择几种过渡方式。第一种是接受冷启动,但给数据库回源加并发上限、请求合并和热点预热。多个人同时读同一个新归属键,只让一个请求回源,其余等待同一结果,否则一个热键就能把数据库打出尖峰。第二种是迁移前按旧环扫描或根据热键榜预热 D,等命中率达到目标再切路由。第三种是过渡期按新环读 D,未命中时依据旧环去原节点找,找到就写回 D;旧节点也没有时才回数据库。三种方法能否使用,取决于缓存是否允许旧值、是否有独立的权威数据源,以及旧节点会保留多久。

“去旧节点兜底”听着简单,细节却不能省。请求先读 D 没找到,转向旧节点 B;在两次读之间,另一个请求把新值写到了 D。如果这时我们把 B 的旧值再写回 D,就可能覆盖新值。对于纯缓存,可以用版本号或来源数据库的更新时间比较后再填充;没有版本可比时,至少要让回填遵守只在键仍不存在时写入,并接受这不等于完整的一致性保证。若系统对值的新旧有严格要求,就不能靠“未命中就随便补”来实现迁移。

不要把缓存和数据库中的“有这个键”也混为一谈。某些缓存把缺失值短暂缓存为“空对象”,用来挡住不存在的键。扩容时 D 缺缓存,并不能说明源数据库不存在;过渡层也不能把“D 没有”和“业务数据为空”用同一个标记表示。否则路由变化可能被误诊成一波业务数据删除。

两种客户端各拿一版环,会发生什么 ​

假设服务有两个实例,甲已经拿到四节点配置,乙还在用三节点配置。对一部分键,甲会去 D,乙还会去 B。单看每个实例,路由算法都算对了;放在一起,它们却可能同时写不同位置。缓存的短暂分裂也许能靠 TTL 结束,持久化数据的分裂就危险得多。

因此“发布新环”本身要有版本。至少让日志和请求带上 routeEpoch,知道每次路由依据的是哪份成员表。控制面负责下发成员表,数据面负责按已知版本路由;当某个客户端落后时,服务端可以返回“我不再负责这个范围,请刷新配置”之类的可识别信号,而不是悄悄收下新写入。具体协议可以各不相同,但必须有办法回答:同一时刻,谁是某个键的权威写入点?

切换前还得问“客户端什么时候算准备好”。如果仅把节点列表写进配置中心,不能保证所有进程同时刷新;如果等所有进程确认,再把新版本设为可写,遇到一个失联实例又可能永远等不到。通常要定义明确的截止时间和过期版本处理策略。旧版本读请求可短暂转发,旧版本写请求要么转发到新主,要么拒绝并让客户端重试;不能让两个版本长期各写各的。

路由表的确定性也要测试。两个语言的客户端,一个按 UTF-8 给 A#1 做哈希,另一个按本地字符集;一个把节点标识大小写规范化,另一个没有;它们看到同一份机器名单也会算出不同环。哈希函数、字节编码、节点名、虚拟节点数、排序与碰撞规则,都是协议的一部分。给各语言客户端跑同一批测试键,核对归属节点,比只看“都叫一致性哈希”可靠。

如果这只是一个应用内部的缓存,最省心的办法可能是把路由收进同一个网关或客户端库,减少多版本扩散。要不要让每个业务服务自己算环,是工程组织的选择;分布式路由的成本,不只在算法的 sort.Search。

节点只是换了 IP,为什么键也搬家了 ​

这个事故也很常见:机器没扩容,只是把 B 从一台旧主机换到新主机,路由代码却用 host:port 当节点 ID。哈希环上的点由 hash("B的地址#虚拟节点编号") 算出来;地址一变,B 的所有点都变了。旧 B 从环里消失,新 B 又作为一台新节点加入。对路由算法来说,这不是“原地换机器”,而是一次删除加一次添加,键的归属会经历两次变化。

稳定节点 ID 应表达逻辑身份,网络地址只是这个身份当前指向的实例。若你希望原来的分区仍归“B”,可以让 B 的逻辑 ID 保持不变,通过服务发现把 B 指到新地址;数据复制与主从切换仍要另行完成。若你本来打算让新机器接管 B 的数据范围,也应有一个明确的切换步骤,而不是靠 IP 字符串的变化暗中重排键。

哈希算法升级同理。Go 程序里用 SHA-256 前 64 位;生产库某次升级若改成另一种哈希,或把键名从 user:42 改成 USER:42,旧环上的键位置会整体改变。这不是普通的代码优化,而是一次潜在的全量路由迁移。路由协议最好带算法版本,发布前用固定测试键对照新旧结果,确认改动范围。没有准备迁移时,应继续支持旧算法直至数据完成切换。

缩容不是把扩容反过来点一下 ​

给环加入 D 时,变化的键只会流向 D;删掉 B 时,B 原来负责的每段键会交给对应的顺时针后继。这个路由性质同样漂亮,但如果 B 已经突然宕机,后继没有 B 的副本,算法也变不出丢失的数据。

计划内缩容可以先把 B 的范围复制到目标节点,在过渡期让目标追上写入,再撤掉 B 的点。计划外故障则要依赖预先放置的副本、可重放日志或独立数据源;没有这些,最多做到“请求被送到一台活机器”,做不到“活机器知道正确答案”。这也是为什么缓存与持久化存储不能共用同一句“失效后顺时针接管”的描述。

无论加节点还是删节点,成员表要让所有客户端最终收敛到同一版本。否则甲认为 B 已下线,乙仍向 B 写入;乙成功了一次,并不能证明这次写入已被新负责节点看见。算法定义了某个成员表下的归属,成员表何时生效和数据何时安全转移,仍是系统协议要回答的事。

换成持久化存储,就不能只谈命中率了 ​

假设缓存里的值只是数据库的副本,D 为空时还可回源。现在换一种设定:A、B、C 存的是订单主数据,没有别的权威副本。某键原来在 B,新环把它交给 D。如果先发布新环再搬数据,D 收到查询时不能回答“订单不存在”;如果先把 B 的数据复制到 D 再切写入,复制期间 B 还可能不断接收更新,D 的副本会落后。

可以把迁移拆成四个清楚的阶段,而不是一句“后台慢慢搬”:

阶段读取位置写入的权威位置必须确认的条件
准备BBD 已建好目标范围,旧环仍有效
复制BBD 拿到快照,还在追 B 的增量
切换B 或 D,按版本处理明确切到 DD 已追到约定写入点,旧版本写入被拒绝或转发
收尾DDD 的数据校验通过,B 的旧副本过了回退窗口

这个表不是通用分布式事务协议,而是提醒每个阶段都要回答“谁能接收写入”。比如复制过程中,B 上某订单版本从 7 更新到 8,D 先收到版本 8,再收到网络重试的旧版本 7,D 要能拒绝 7。若仅按到达顺序覆盖,快照与增量的交错就会把新值改回旧值。版本、日志序号或其他可比较的提交标识必须来自权威写入路径,不能用两台机器各自的本地时间比较。

切换点更难。若“D 已追上”是通过查看某个时间点的复制延迟得出的,而 B 随后又接收一笔新写入,D 依旧可能落后。要么短暂停止 B 对目标范围的写入,确认剩余增量追平后再让 D 接管;要么把迁移期间的写入转发或双写,并定义确认条件和失败补偿。双写不是免费午餐:B 成功、D 失败时谁来重试?两次写入顺序不同怎么办?所以发布方案里应当有明确的水位、重放和回退规则。

回滚也不是把路由版本号改回去那么简单。切到 D 以后如果只向 D 写,B 上的数据立即开始变旧。此时 D 出故障,直接回 B 可能丢掉切换后的写入。真正可回滚,需要在回退窗口内让 B 追上 D 的变化,或把新写入留在可重放日志中。对仅作缓存的系统可以容忍回源与局部未命中,对订单主数据则必须把这些路径走通后再扩容。

这一整段几乎没有哈希算法,偏偏是生产系统扩容的主体。算法决定哪些键需要移动;迁移协议决定移动期间读写是否仍正确。

节点宕机,后继接过的是地址还是数据 ​

哈希环对故障节点也能算出新的目标。假设 B 下线,原本指向 B 的键会沿环继续找下一个节点。路由马上有答案,不代表那个后继节点手里有 B 的数据。对于纯缓存,后继从数据库重建,回源可能再次激增;对于持久化存储,只有事先放过副本、并设计副本提升与版本判断,才能谈接管。

这时还需要分清“节点真的死了”和“某些客户端暂时连不上它”。甲认为 B 活着,乙认为 B 死了,两者的环不同。若都允许写同一键,就可能形成两份结果。故障检测、成员表传播、写入仲裁与数据复制必须一起设计,单靠哈希环不能给出一致性保证。Dynamo 把一致性哈希用于分区与副本选择,同时还要靠成员信息、版本和读写协调处理故障;只抄它的“环”并没有抄到完整系统。

副本放哪也不能只找环上相邻的几个虚拟点了事。相邻的虚拟点可能属于同一台物理机,甚至同一故障域;三份看似独立的副本若都在一个机架,一次机架故障仍会一起消失。要按不同物理节点、机架或可用区选副本,才能让“副本数 3”对应真实的容错能力。实际数据系统通常还会有比这更复杂的放置策略。

前面提到超级热键,它也是这张图上的例外。环按键归属,没法把一个键自然切成十份。如果 D 接走的区间里有一个占总请求量 20% 的键,D 的请求负载可能远超 25%。可以复制这个热点的只读缓存、在上游合并请求、把热键单独路由,或者在业务上拆分它;怎么做取决于读写一致性要求。增加虚拟节点主要改善大量键的统计分布,治不了单键热点。

Redis Cluster 为什么不直接让客户端算这个环 ​

说到这里,有人可能会拿 Redis Cluster 举例:“它扩容不是也要迁移 slot 吗?是不是没用好一致性哈希?”这个比较要先把路由层次弄清楚。Redis Cluster 把键固定映射到 16,384 个 hash slot,计算大致是 CRC16(key) mod 16384(哈希标签另有规则)。新增机器时,分母仍是 16,384,键到 slot 的关系不因机器数变化而重算;运维改变的是“哪些 slot 归哪台节点”。这是一个固定逻辑分区加显式迁移的设计,不是前面 Go 程序中的虚拟节点哈希环。

这个区别很实用。用哈希环时,客户端凭稳定算法和成员表就能算目标节点;用固定 slot 时,客户端持有的是 slot 到节点的映射,服务端可在迁移期间指导它更新。Redis Cluster 在 slot 迁移中区分源节点的 MIGRATING 和目标节点的 IMPORTING 状态,并使用 ASK 等重定向处理正在搬的键;迁完后再公布新的归属。也就是说,它明确承认“路由归属”和“数据实际位置”在迁移期间不能简单当成同一件事。

这不是说 slot 一定比哈希环高级。slot 映射需要维护,迁移协议也复杂;哈希环适合节点集合动态变化、希望客户端独立算路由的场景。两者在解决不同的管理问题。读完环的算法就想自己造一个“Redis Cluster”,中间差的正是迁移状态、客户端重定向和故障处理。

固定逻辑分区还能降低频繁修改哈希函数的影响:扩机器时不必重算每个键到 slot 的编号,只移动一些 slot 的归属。当然,选多少 slot、怎样分配、热点 slot 如何处理仍是工程问题。把“逻辑分区稳定”误当成“数据移动免费”,依旧会在扩容时遇到网络、CPU 和回源成本。

不想存一张环,还有 Jump Hash ​

哈希环需要存所有虚拟节点的位置。如果机器编号能始终是 0,1,2...N-1,还可以看 Jump Consistent Hash。它对给定键和桶数直接算一个桶,基本不需要保存环表;从 N 个桶增加到 N+1 个桶时,旧键要么保持原桶,要么跳到新增的 N 号桶。论文给出的算法同时追求少迁移和较均匀的分布。

它的约束也正好写在输入里:桶号得连续。增加一个尾部桶很自然;中间的 2 号桶永久下线,剩下 0、1、3、4 时怎么办?直接把 4 改名为 2,会改变映射含义,数据还要随之调整。若系统经常移除任意节点、需要复杂权重或者不希望逻辑桶编号和物理机器绑定,不能只看 Jump Hash 的计算速度就选它。可以在物理机前面加一层稳定的逻辑桶,再迁移桶的物理归属;这时又回到了 slot 映射的管理问题。

三种办法放到桌面上,比较点就清楚了:

路由办法扩一个节点时要维护的东西容易踩的边界
hash(key) % N大量旧键改派节点顺序与 NN 一变,许多旧键换目标
虚拟节点哈希环主要把旧键交给新节点有序虚拟点和稳定节点 ID分布方差、成员版本、数据仍需搬
Jump Consistent Hash主要把旧键交给新增尾号桶连续桶编号任意节点删除或权重不方便

这里说的都是路由,不是复制、迁移和一致性协议。实际选型时,我会先问:目标数据能否回源?节点是经常追加、任意删除,还是逻辑分区长期稳定?成员表由谁发布、多久一致?谁负责迁移中的读写?这些答案比算法名称更先决定系统形态。

从“移动了多少键”算到“数据库会吃多少流量” ​

上一节实验只数键。运维真正关心的是扩容后一小时,数据库会不会顶满。可以给每个键加一个读频率,再按新旧路由是否变化加总,得到“受影响读流量占比”。如果每个键每分钟读一次,按键数估算还有点意义;如果极少数热键承载了大部分请求,它们落不落在 D 的新范围里,会决定峰值。

举个只为说明计算方式的例子:总读量每秒 10,000 次,其中一个热键每秒 2,000 次。D 接走了约 25% 的键,若这个热键恰好也改派给 D,其余读量又有约 25% 改派,受影响流量约是 2,000 + 0.25 × 8,000 = 4,000 次/秒,也就是 40%。若热键没改派,受影响量则可能接近 20%。这些数是手算情景,不是对任何真实集群的预测;它说明为什么“迁移键占比 25%”不足以推断“回源流量只增加 25%”。

还要看同一键的并发。每秒两千次读一个刚搬走的键,若没有请求合并,最坏时很多请求会同时发现 D 为空,各自冲向数据库。第一次回源虽然会填缓存,但那批已经发出的并发查询不会自动消失。所以在扩容窗口,要监控新节点按 key 的并发回填数、数据库连接等待、慢查询、应用超时以及热点键分布。仅看总命中率,可能等报警来了才知道是哪一个键造成回源风暴。

预热也要算预算:把新节点接管的所有数据一口气从数据库扫出来,可能和冷启动一样打满数据库。按历史热度预热、控制并发、从旧缓存复制、分批开放新节点区间,都是在换取一个更平缓的流量曲线。选哪种不用争“理论最优”,先量数据库安全余量,再定允许的回源上限和扩容速度。

把扩容演练缩小到一个键 ​

抽象的“四阶段迁移”容易看完就忘。只跟踪一个键 K,更容易发现漏洞。扩容前它归 B,值为 v7;新环把它交给 D。现在甲客户端已经切到新环,乙客户端还没切。甲读 D 未命中,乙在 B 把 K 更新为 v8。如果甲随后从 B 读到 v7 并回填 D,那么即便 B 最终是 v8,D 也可能停在旧值。你会在两个路由版本的客户端上读到不同数据。

这条时序的处理取决于数据性质。纯缓存且数据库是唯一权威时,D 可以直接从数据库回源,但要做并发合并与限流;旧缓存回填则要带版本,且只有回填版本不旧于目标现值才接受。持久化数据不能靠缓存式“过期再说”,要用明确的写入主、复制日志与切换水位控制这段并发。无论哪种,演练时都应故意让“甲读 D 未命中”和“乙写 B”同时发生,不能只测无流量时离线复制的快乐路径。

还有一种相反的错:过渡期为了保险同时写 B 和 D,应用看到 B 写成功、D 写超时就重试整笔操作。若这笔写入有副作用,可能造成重复;若只重试 D,又必须保证重试顺序不会落后于下一笔写。可以用每个键单调递增的版本和幂等业务编号来处理,但版本生成和写入顺序要有可靠来源。双写是一套协议,不是两行 put()。

演练结果应该能落到可以观察的字段:请求使用的路由版本、旧归属、新归属、真正读取的节点、回填来源、值版本,以及最终有没有访问数据库。没有这些,线上只剩一个“缓存未命中率升高”的曲线,很难分清是正常冷启动、路由分裂,还是迁移覆盖了新值。

让程序验证“只迁到 D”,而不是盯着一次输出 ​

前面的 旧节点之间迁移: 0 是哈希环的核心性质,值得变成回归测试。下面的 main_test.go 与前面的程序放在同一目录;若还没有 Go module,先运行 go mod init example.com/hash-ring,再运行 go test。测试不要求某个特定键一定归谁,只要求加 D 后,发生变化的键都去 D;还检查成员列表顺序不同不会改变结果:

go
package main

import (
    "fmt"
    "testing"
)

func TestAddNodeOnlyMovesToNewNode(t *testing.T) {
    old := makeRing([]string{"A", "B", "C"}, 512)
    next := makeRing([]string{"A", "B", "C", "D"}, 512)
    sameMembersDifferentOrder := makeRing([]string{"C", "A", "B"}, 512)

    for i := 0; i < 100_000; i++ {
        key := keyForTest(i)
        a, b := old.owner(key), next.owner(key)
        if a != b && b != "D" {
            t.Fatalf("%s moved from %s to old node %s", key, a, b)
        }
        if old.owner(key) != sameMembersDifferentOrder.owner(key) {
            t.Fatalf("%s changed owner when only the input order changed", key)
        }
    }
}

func keyForTest(i int) string {
    return fmt.Sprintf("key-%06d", i)
}

这类测试不是“算法数学证明”,但能抓住实现中常见的回归:排序漏了、节点 ID 依赖输入顺序、扩容时重算了旧节点点位。更严格的测试还应覆盖哈希位置碰撞、空环、重复节点 ID、不同语言的字节编码。本文的演示代码刻意没有写这些生产边界,复制去做真正的路由库前必须补上。

实际工程里,测试数据要包括不同长度和字符集的键,还应把几把固定键及其归属写成跨语言契约样例。升级哈希库、改变字符串格式化方法或改虚拟节点数量时,这些契约测试会直接提醒:这不是无害重构,而是一次数据路由变更。

机器规格不一样,虚拟节点该怎么分 ​

实验里 A、B、C、D 都是假设相同容量的机器,所以每台放了 512 个虚拟节点。生产里若 D 内存只有 B 的一半,还把它们放同样多的点,D 理论上就要接近同样数量的键,容量很快不够。一个直观办法是按容量比例分配虚拟节点:例如大机器 512 个、小机器 256 个。这样小机器在环上的期望区间更短。

但“容量”也不是一个单一数字。缓存节点可能先被内存大小限制,也可能被网卡吞吐、CPU 解压、单线程事件循环或连接数限制。按内存加权能改善数据量,未必能改善请求量;热点分布还会让按键数的权重失真。设置权重前,先量每台的实际瓶颈,以及单位键占用的大小与访问频率。若 D 的数据量达到预期但 CPU 被一个热键打满,继续增减虚拟节点只会把问题挪给别的机器。

权重调整本身又是一次迁移。把 D 从 256 个虚拟点调到 512 个,相当于向环上再加入 256 个点,它会继续从其他节点接走键;把某台机器的点减少,原有区间要交给后继。配置变更时必须像扩容一样估算受影响键和读写流量,不能把“调一下权重”当成无状态的性能参数。节点权重、路由版本与实际数据分布也应保存在运维记录中,方便回滚和排障。

有时更简单的做法是先不追求“每台恰好 25%”。只要容量有余量且迁移流程安全,A=25.5%、B=25.6%、C=24.2%、D=24.7% 与完全平均相比并没有业务意义上的差别。相比之下,允许冷启动把数据库连接打满,风险更实在。平衡指标要为容量目标服务,不值得为了表格看起来整齐而不断重排键。

一次扩容该怎样收尾 ​

如果由我来验收这次缓存扩容,我会先把实验里的静态“迁移 24,729 个键”,换成线上真实流量的估算:最近一天有多少独立键,按新旧两版路由会改派多少;这些键占多少 QPS、多少内存、多少对象大小;其中哪些是热点。这个对比可以离线算,不需要先真的切路由。

接着在小比例流量上试新版本,把路由版本号写进指标。观察改派键的命中率、D 的回填耗时、旧节点兜底比例、数据库回源 QPS 和连接等待。若数据库指标逼近事先设定的安全上限,停止扩大范围,先降低迁移并发或增加预热。不要等所有客户端同时刷新成员表后才发现没有回退窗口。

切完也不能只看“D 收到流量了”。要确认旧节点兜底请求持续下降,新节点命中逐步上升,旧路由版本客户端已退出,旧节点原区间的数据不会在还被读取时提前清理。若期间写入仍可能走老节点,先修写入路由再清旧数据。缓存有权威数据库时,清理的底线是还能从数据库恢复;持久化存储则要核对完整副本与版本水位。

最后演练一次失败:假设 D 刚接完 25% 的键就宕机,新环会怎样调整?这些键现在去谁?它们从哪个数据源恢复?数据库还能否承受第二波回源?扩容的成功条件不是“新增节点正常加入”,而是新增节点出问题时,系统也知道如何退回可控状态。

这里的每个动作都能对应一项具体观测。对自己写的哈希环尤其要保留路由版本、节点列表摘要、新旧 owner、实际请求节点和数据版本。只记录一个“缓存命中率”不够:指标掉了以后,你需要知道是路由变了、数据没搬到、客户端配置没更新,还是热门键恰好集中到新节点。

这套办法真正买到了什么 ​

一致性哈希买到的是一个漂亮但有限的性质:成员增加时,原有大部分键不用换归属。在演示的三台扩四台里,取模改派约 75.3% 的键,512 虚拟节点的哈希环改派约 24.7%;旧节点之间没有互相改派。这些数都由文中的代码和固定测试键算出,读者可以自己运行、换节点名或虚拟节点数复核。

它没有买到自动数据迁移、均匀的请求流量、零回源、故障时的数据副本,也没有买到同时使用两版路由时的写入一致性。对缓存,认真算冷启动和数据库预算;对持久化存储,认真设计复制、版本、水位和切换。再往后才是选哈希环、固定 slot,还是 Jump Hash。先弄清数据的权威位置和迁移协议,再选路由算法,扩容时才不会被一条看似正确的哈希公式骗过去。本站另有一篇关于缓存更新后冒出旧价格的文章,讨论写入路径上的一致性问题。

参考资料 ​

梧桐和风 · 让复杂的技术讲得更清楚