图 1:本文封面,点明题目与一致性哈希的解法方向。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。这是一道把「分布式缓存怎么分片」压缩成一行公式的题:公式好背,但它翻车的代价和修复方案的原理,才是面试官真正想听的。
想象面试现场,面试官问:「缓存数据按 hash(key) 对节点数取模分片,现在加一台机器,会发生什么?怎么改进?」
第一步,审题。
取模分片的问题不在分片本身,在于分片函数里藏着节点数。hash(key) mod N 里 N 一变,几乎所有 key 的归属都要重算。对缓存来说,这意味着扩容的瞬间旧映射大面积失效,全部请求穿透到数据库——扩容本来是救命的,结果成了雪崩的导火索。题目考的就是:你能不能说出失效的比例,以及怎么把重映射的代价降下来。
第二步,基础回答:一致性哈希。
把哈希值想象成一个 0 到 2³² 的圆环。每台服务器节点按标识算哈希、落在环上某个位置;每个 key 也算哈希落在环上,沿顺时针方向遇到的第一个节点就是它的归属。
现在新增节点 D,它落在环上某处,只有「前一个节点到 D 之间这一段弧」上的 key 需要改归 D,其余 key 纹丝不动。理论期望上,从 n 台扩到 n+1 台,只有约 1/(n+1) 的 key 迁移——3 台扩 4 台只动四分之一,而不是取模方案的四分之三。节点少时还有个小毛病:几个点在环上分布不均,负载倾斜。解法是虚拟节点——每个物理节点映射成上百个虚拟点撒在环上,分布自然摊匀,迁移也更平滑。
第三步,用可运行代码实测。
下面这份代码分别实现取模分片与带虚拟节点的一致性哈希,实测扩容时 key 的迁移比例,用 Node.js 22.15.0 直接运行(node consistent-hash.js)即可:
'use strict';
const { createHash } = require('node:crypto');
function hash32(s) {
return createHash('md5').update(s).digest().readUInt32BE(0);
}
// 方案一:hash(key) % 节点数
function modAssign(key, nodeCount) {
return hash32(key) % nodeCount;
}
// 方案二:一致性哈希——key 沿环顺时针遇到的第一个虚拟节点即归属
function buildRing(nodes, vnodesPerNode) {
const ring = []; // [位置, 物理节点]
for (const node of nodes) {
for (let v = 0; v < vnodesPerNode; v++) {
ring.push([hash32(`${node}#${v}`), node]);
}
}
return ring.sort((a, b) => a[0] - b[0]);
}
function ringAssign(key, ring) {
const pos = hash32(key);
for (const [p, node] of ring) {
if (p >= pos) return node;
}
return ring[0][1]; // 越过环尾,绕回第一个节点
}
const KEYS = 10000;
const keys = Array.from({ length: KEYS }, (_, i) => `user:${i}`);
const oldNodes = ['A', 'B', 'C'];
const newNodes = ['A', 'B', 'C', 'D']; // 扩容一台
// 取模:3 台 -> 4 台
let movedMod = 0;
for (const k of keys) {
if (modAssign(k, 3) !== modAssign(k, 4)) movedMod++;
}
// 一致性哈希(每节点 150 个虚拟节点):3 台 -> 4 台
const VNODES = 150;
const ring3 = buildRing(oldNodes, VNODES);
const ring4 = buildRing(newNodes, VNODES);
let movedRing = 0;
for (const k of keys) {
if (ringAssign(k, ring3) !== ringAssign(k, ring4)) movedRing++;
}
console.log(`共 ${KEYS} 个 key,从 3 节点扩到 4 节点`);
console.log(`取模分片迁移:${movedMod} 个(${((movedMod / KEYS) * 100).toFixed(1)}%)`);
console.log(
`一致性哈希迁移:${movedRing} 个(${((movedRing / KEYS) * 100).toFixed(1)}%),理论期望约 1/4`
);
// 虚拟节点对分布均匀性的影响
function spread(nodes, vnodes) {
const ring = buildRing(nodes, vnodes);
const count = new Map(nodes.map((n) => [n, 0]));
for (const k of keys) count.set(ringAssign(k, ring), count.get(ringAssign(k, ring)) + 1);
return [...count.values()];
}
const uneven = spread(oldNodes, 1);
const even = spread(oldNodes, VNODES);
console.log(`无虚拟节点时三节点分布:${uneven.join(' / ')}(期望各 ${(KEYS / 3).toFixed(0)})`);
console.log(`每节点 ${VNODES} 个虚拟节点时:${even.join(' / ')}`);
实测输出如下:
共 10000 个 key,从 3 节点扩到 4 节点
取模分片迁移:7391 个(73.9%)
一致性哈希迁移:2322 个(23.2%),理论期望约 1/4
无虚拟节点时三节点分布:309 / 5614 / 4077(期望各 3333)
每节点 150 个虚拟节点时:3358 / 3465 / 3177
四行输出各自对应一个考点:取模方案扩容后近四分之三的 key 换了家,缓存约等于清空;一致性哈希只迁 23%,贴着理论值 1/4;不配虚拟节点时三台机器分到 309/5614/4077,倾斜到离谱;配上 150 个虚拟节点后,三台基本各占三分之一。数字比概念更有说服力。
图 2:哈希环上扩容只迁移相邻一段弧,虚拟节点摊匀分布。
可能的追问。
「缩容呢?」对称的——下线的节点只把它那段弧交还给顺时针方向的下一个节点。「一致性哈希就够了吗?」工程上常配合迁移期双读(新旧映射各查一次)平滑过渡;现代缓存代理与客户端分片库大多内置了这套机制。「为什么还要虚拟节点?」实测的分布行就是答案:节点少、虚拟点少时倾斜严重,虚拟节点是用少量内存换均匀性。
易错点清单。
一,说不出取模翻车的比例——N 变 1,约 (N-1)/N 的 key 重映射。二,只会画环,讲不清 key 的归属规则是「顺时针第一个节点」。三,漏掉虚拟节点,节点一少负载倾斜就答不上来。四,把一致性哈希当成持久化方案——它管的是映射与迁移,数据本身还是要靠副本与回填。
回到面试现场,建议的答题节奏:先点破取模函数里藏着 N,给出扩容大面积失效的结论;再画环讲归属规则与迁移范围,主动报出 1/(n+1) 的期望值;最后补虚拟节点解决倾斜。三步讲完,这道题就从「背概念」变成「懂设计」了。
embedding 缓存、会话路由、推理实例分片,本质都是同一个扩缩容问题。你在生产里做缓存扩容时,用的是一致性哈希、槽位分区还是别的方案?欢迎聊聊。
参考资料
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,
node:crypto哈希与Map行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/