随机化与近似结构

一致性哈希

简单取模错在哪

把 key 分配到 N 台机器上,最直接的写法:

const node = nodes[hash(key) % nodes.length];

正确、均匀、一行。但加一台机器时全盘崩塌 —— % 5 变成 % 6, 几乎每个 key 的归属都变了。

实测 20 万个 key,从 5 台加到 6 台:

简单取模 hash % N     迁移 83.31%     (理论 1 - 1/6 = 83.33%)

⚠️ 而理论上只需要迁移 16.7% —— 新机器应当承担 1/6 的 key, 把这部分搬过去就够了,其余 83.3% 本可以原地不动。 📌 也就是说,取模法做了 5 倍于必要的搬迁。对缓存来说这意味着 一次扩容后缓存几乎全失效,请求全部打到数据库。

环:把节点和 key 放到同一个空间里

⭐ 一致性哈希的想法:别用「第几台」定位,用「哪一段区间」定位。

把哈希值想成一个首尾相接的环(0 到 2³²-1)。节点按 hash(节点名) 落在环上, key 按 hash(key) 落在环上,每个 key 归属于顺时针方向遇到的第一个节点。

        A
    ↗       ↘
  E     环     B      key 落在这里 ─┐
    ↖       ↙                      ↓ 顺时针
        D ← C                    归 B 所有

加一个节点 F,只有「原本落在 F 与它逆时针方向前一个节点之间」的 key 变了归属, 其余节点之间的分界线一条都没动。

function createRing(nodes, vnodes) {
  const points = [];
  for (const n of nodes)
    for (let i = 0; i < vnodes; i++) points.push([hash(`${n}#${i}`), n]);
  points.sort((a, b) => a[0] - b[0]);

  return (key) => {
    const h = hash(key);
    // 环形:超过最后一个点就绕回第一个
    if (h > points[points.length - 1][0]) return points[0][1];
    let lo = 0, hi = points.length - 1;
    while (lo < hi) {                       // ⭐ 二分找第一个 ≥ h 的点
      const m = (lo + hi) >> 1;
      if (points[m][0] < h) lo = m + 1; else hi = m;
    }
    return points[lo][1];
  };
}

📌 查找是在有序数组上二分,O(log(N·vnodes))。

🚨 hash 是什么,决定了下面每一张表。 这一篇里它是 FNV-1a 32 位 再过一遍 murmur3 的收尾混合:

function hash(s) {
  let x = 2166136261;
  for (let j = 0; j < s.length; j++) {
    x ^= s.charCodeAt(j);
    x = Math.imul(x, 16777619);
  }
  x ^= x >>> 16;  x = Math.imul(x, 2246822507);   // ⭐ 这三行不是装饰
  x ^= x >>> 13;  x = Math.imul(x, 3266489909);   //   去掉它们虚拟节点会失效
  return (x ^ (x >>> 16)) >>> 0;                  //   见下面「雪崩性」一节
}

⚠️ 本篇所有数字的测量口径:20 万个 key(k0 到 k199999),5 个节点, 新增节点记作 F、删除节点记作 C。凡是标了「中位数」的表,都是换 200 组不同的 节点名各测一次得到的分布——单独一组节点名只是一次抽样,见下文。

⭐ 真正的保证:非必要迁移为 0

5 → 6 个节点,把迁移的 key 按「去了哪」拆开。200 组节点名各测一次:

vnode     总迁移 中位数 (p10~p90)     两个老节点之间的迁移(200 组最大值)
   1        12.5%  ( 2.2 ~ 34.9)              0.0000%
  10        16.6%  (11.2 ~ 23.4)              0.0000%
  50        16.6%  (14.3 ~ 19.4)              0.0000%
 150        16.6%  (15.0 ~ 18.4)              0.0000%
 500        16.7%  (15.8 ~ 17.4)              0.0000%
1000        16.7%  (16.2 ~ 17.3)              0.0000%

⭐ 最后一列全是 0.0000% —— 这才是一致性哈希的核心性质: 它从不在两个既有节点之间搬数据。 每一个被迁移的 key,去向都是新节点。

📌 这一列和别的列不是一类东西。中间那一列会随抽样上下浮动, 最后这一列是结构性的:跨 2400 组配置(两种哈希 × 200 组节点名 × 6 档 vnode) 一个反例都没有。要记的是这一列。

⭐ 另外注意总迁移量:vnode 一大起来就稳定停在 16.6~16.7%, 正是理论下界 1/6。它搬的每一个 key 都是非搬不可的。

删节点同理。删掉 C(vnode=1 那组恰好是 C 独占了 47%):

vnode=  1   迁移 47.0%   而 C 原本就持有 47.0%
vnode=150   迁移 18.8%   而 C 原本就持有 18.8%

📌 迁移量恰好等于被删节点原本持有的份额 —— 一个多余的 key 都没动 (实测「原本不属于 C 的 key 被动了几个」= 0)。

🚨 但环上直接放节点会严重不均

只放 5 个点到一个 2³² 的环上,间距是随机的,分布好不到哪去。实测:

vnode=1   A=50423  B=30518  C=94049  D=10318  E=14692
          最大/最小 = 9.1 倍     标准差/均值 = 76.2%

⚠️ C 拿到 47% 的流量,D 只拿 5.2%。这不是哈希函数不好,是点太少 —— 5 个随机点本来就分不均一个环。

📌 这一条能算出来:n 个节点各放 v 个点时,单个节点的负载份额服从 Beta(v, nv-v),于是标准差/均值 = √((n-1)/(nv+1))。 n=5、v=1 代入得 81.6%,实测这一组 76.2%、200 组的中位数 75.5% (p10p90 是 47.0110.1%)—— 对得上,也说明单看一组毫无意义。

解法是虚拟节点:每个物理节点在环上放 v 个副本(A#0、A#1…), 点多了间距自然趋于均匀。

vnode     最大/最小 中位数(p10~p90)     标准差/均值 中位数(p10~p90)    理论值
    1      15.8×   ( 4.6 ~ 85.1)           75.5%  (47.0 ~ 110.1)      81.6%
   10       2.1×   ( 1.5 ~  3.1)           25.9%  (13.3 ~  39.2)      28.0%
   50       1.4×   ( 1.2 ~  1.7)           11.1%  ( 5.8 ~  17.0)      12.6%
  150       1.2×   ( 1.1 ~  1.3)            6.6%  ( 3.6 ~  10.2)       7.3%
  500       1.1×   ( 1.1 ~  1.2)            3.8%  ( 2.2 ~   5.6)       4.0%
 1000       1.1×   ( 1.0 ~  1.1)            2.7%  ( 1.6 ~   4.0)       2.8%

⭐ 每一档都贴着理论值 √((n-1)/(nv+1)),收益一路按 1/√v 递减,没有平台期。 从 150 加到 1000,均衡度还能从 6.6% 再降到 2.7%。

⚠️ 所以常见实现取 100~200,理由不是「再加就没用了」,而是性价比: 6.6% 的不均衡在真实集群里已经够用,而 v 再翻十倍要多排序、多驻留十倍的环上点位 (1000 台机器 × 1000 副本 = 一百万个点)。是成本换的,不是收益没了。

🚨 虚拟节点能不能起效,取决于哈希的雪崩性

上面那张表如果把 hash 换成朴素的 FNV-1a(去掉那三行收尾混合), 同样的代码、同样的 key,结果完全变样:

vnode    朴素 FNV-1a    加收尾混合    理论值      朴素版的「有效点数」
    1       77.7%          75.5%      81.6%        1 / 1
   10       73.0%          25.9%      28.0%        1 / 10      ← 十个副本等于一个
   50       42.3%          11.1%      12.6%        5 / 50
  150       28.3%           6.6%       7.3%       16 / 150
  500       17.7%           3.8%       4.0%       51 / 500
 1000       10.5%           2.7%       2.8%      108 / 1000

⚠️ 朴素版每一档都差理论值一大截,而且越往后差得越多(v=1000 时差 3.8 倍)。 如果只测这一版,会得出「虚拟节点收益在 50~150 之后趋于平缓」的结论 —— 那不是虚拟节点的性质,是哈希函数的缺陷。

原因看一眼相邻虚拟节点的哈希值就清楚了:

朴素 FNV-1a:  A#0 =1416880759   A#1 =1400103140   Δ = -16777619
               A#2 =1450435997   Δ = +50332857     A#3  Δ = -16777619
               A#4  Δ = -83888095                  A#5  Δ = -16777619
               → 相邻差恒为 16777619 的整数倍(-1×、+3×、-5× 轮转)

🚨 末尾字符只被异或、然后乘一次质数,A#0…A#999 就落成了一串步长约 2²⁴ 的 等差格点,而不是撒在环上。1000 个虚拟点实际只占到 108 个不同格点 (最后一列),v=10 时更极端:十个副本全叠在同一个格子上,与 v=1 完全等价 —— 这正是朴素版 v=1 到 v=10 几乎没有改善(77.7% → 73.0%)的原因。

⭐ 判据:v 加十倍,均衡度没按 √10 ≈ 3.2 倍改善,先怀疑哈希而不是怀疑虚拟节点。 最省事的自测是数一下「v 个虚拟点落在几个不同的位置上」,不够 v 个就说明哈希在结块。

📌 布隆过滤器那一篇撞上的是同一个坑的另一面: 那里的 k 个哈希靠 种子 ^ i 区分,结果 k 个位置的奇偶被锁死, 误判率系统性高出一到两成。同一个弱点,一个表现为负载不均,一个表现为误判偏高。

⚠️ 一个容易读反的现象

回头看迁移表:vnode=1 的中位数只有 12.5%,vnode=150 却要迁移 16.6% —— 虚拟节点越多,迁移越多? 看起来像是虚拟节点有害。

不是。把 vnode=1 那一组加入 F 前后的负载列出来就清楚了:

加 F 前   A=50423  B=30518  C=94049  D=10318  E=14692
加 F 后   A=50423  B=26993  C=94049  D=10318  E=14692  F=3525

⭐ F 只从 B 手里抢走了 1.8%,别的节点纹丝不动 —— 因为环上只有一个 F 点,它只能从顺时针方向的那一个邻居手里抢。 而它应得的份额是 1/6 ≈ 16.7%。

📌 所以:vnode=1 迁移量小,是「新节点没分到应得的份额」的症状,不是优点。 vnode=150 迁移 16.6%、恰好落在理论下界 16.7% 上,那才是正常工作的样子。

🚨 这类「指标变好其实是坏事」的情况,只看单一数字必然读反 —— 得把它和「新节点最终承担了多少」放在一起看。这两个数在一致性哈希里是同一个数: 迁移过去的 key 就是新节点最终持有的全部。

面试怎么答

「缓存集群扩容怎么办」这类题,一个完整回答是四层:

  1. 问题:取模法扩容要迁移 1-1/N,实测 5→6 台是 83.3%,缓存几乎全失效
  2. 环:节点与 key 映射到同一个哈希空间,顺时针归属;加删节点只影响相邻区间
  3. 保证:老节点之间迁移恒为 0 —— 迁移量恰好等于「新节点应得的」或「删除节点原有的」
  4. 虚拟节点:5 个点时最大/最小是 15.8 倍(中位数),加到 150 个副本压到 1.2 倍; 不均衡度按 √((n-1)/(nv+1)) 下降,取 100~200 是成本与收益的折中

⚠️ 第 4 点是分水岭。只答「用一致性哈希」而不提虚拟节点的话, 面试官通常会追问「那负载不均怎么办」—— 因为不加虚拟节点的一致性哈希 在真实集群里基本不能用。

📌 还想再加一层的话,说第 5 点:虚拟节点的前提是哈希对 节点名#序号 这种短相似串有雪崩性。用朴素 FNV-1a 之类的弱哈希,副本会叠在同一个格点上, 加多少个都没用 —— 这是个真实踩过的坑,比背公式更能证明动过手。

⚠️ 关于配套题

同样地,力扣上没有一致性哈希的题。下面那一道是思路上的近亲 (都是「加一个间接层,把昂贵的整体重排换成局部调整」),不是同类题。 📌 这一篇真正的练习是自己实现一遍再量一次:把上面那些表的数字跑出来, 比做十道题记得牢。⭐ 量的时候记住两件事 —— 换几组节点名再报数(一组只是一次抽样), 把结果和 √((n-1)/(nv+1)) 对一下(对不上就是哈希的问题)。

下一步

前三篇换的都是空间或结构上的确定性。 最后一篇的限流器换的是时间上的: 用 O(1) 的状态近似出一条速率曲线,而不是精确记录每一次请求。

练习

勾选记录做过哪些,0 / 1 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。