一致性哈希:从 Karger 哈希环到 Jump、Maglev 与有界负载

关于一致性哈希,流传着几种互相矛盾的说法:一种说”哈希环加 100 到 200 个虚拟节点就够均匀了”,另一种说”要把偏差压到 5%,100 个节点得配几万个虚拟节点”;有人说 Maglev “查找 \(O(1)\)、迁移也最少”,还有人说 Envoy 的环哈希”每个节点 1024 个虚拟节点”。这几句话都不对:虚拟节点带来的相对偏差约为 \(1/\sqrt{V}\),与节点数 \(n\) 基本无关,而峰值负载只随 \(\sqrt{\ln n}\) 缓慢增长;Maglev 的论文明确说它用一部分迁移量换取了均衡;Envoy 的 minimum_ring_size 默认值 1024 是整个环的条目数,100 台等权主机时每台只分到 11 个点。

本文按”问题定义 → 哈希环 → HRW → Jump → Multi-probe → Maglev → 有界负载 → 生产实现 → 争论”的顺序展开。所有均衡度与迁移量数字都来自同目录的模拟程序 reproduce/chash.c(环境见第八节),源码结论钉在具体版本上:Envoy v1.31.0、nginx 1.26.2、Cassandra 1.2 / 3.0 / 4.0 / 5.0。

最朴素的分片是 \(\text{node} = h(k) \bmod n\)。从 \(n\) 台扩到 \(n+1\) 台时,一个 key 不动当且仅当 \(h \bmod n = h \bmod (n+1)\),即 \(h - r\) 同时是 \(n\) 与 \(n+1\) 的倍数(\(r\) 为余数),每 \(n(n+1)\) 个连续哈希值里只有 \(n\) 个满足,所以要迁移的比例是

\[ 1 - \frac{n}{n(n+1)} = \frac{n}{n+1}. \]

100 台扩到 101 台,99% 的 key 换了位置;第八节实测是 99.0%。对缓存,这等于整体失效;对存储,这等于全量搬迁。

Karger、Lehman、Leighton、Panigrahy、Levine 和 Lewin 在 STOC 1997 的论文 Consistent Hashing and Random Trees 中提出一致性哈希,原始动机是 Web 缓存的热点:不同客户端知道的缓存服务器集合(论文称为视图,view)可能不一致,但希望它们对同一个对象给出相近的答案。论文 4.1 节定义了四个性质:

  • 均衡性(balance):对固定视图 \(V\),每个桶分到的对象比例是 \(O(1/|V|)\);
  • 单调性(monotonicity):视图从 \(V_1\) 扩成 \(V_2 \supseteq V_1\) 时,对象只会从旧桶移到新桶,不会在旧桶之间移动;
  • 分散度(spread):同一个对象在所有视图里被分到的不同桶数要小;
  • 负载(load):同一个桶在所有视图里被分到的不同对象数要小。

后两个性质针对”客户端视图不一致”的缓存场景。Lamping 和 Veach 在 Jump 论文里指出,数据存储场景所有客户端看到同一组分片,只需要前两个性质,这正是 Jump 能做到零内存的前提(第四节)。下文说的”最小迁移”指:节点集合变化一个成员时,只有约 \(1/n\) 的 key 移动,且全部移入新节点或移出被删节点。

二、哈希环与虚拟节点

原始构造与今天的实现

Karger 论文 4.2 节的构造是:用随机函数把桶和对象都映射到单位区间,对象分给距离最近的桶点;为了均衡,每个桶复制 \(\kappa \log C\) 份(\(C\) 为桶数上界)再各自随机映射。也就是说,虚拟节点不是后人打的补丁,而是原论文构造的一部分。定理 4.1 在此基础上给出了带概率的保证:单调性成立,每个桶分到的比例以高概率是 \(O(1/|V|)\),分散度与负载以高概率是 \(O(t \log C)\)。4.3 节还给出实现:平衡二叉树查找是 \(O(\log C)\),把区间切成约 \(C \log C\) 个等长小段、每段单独建树后,期望查找时间是 \(O(1)\)。

今天的实现多数改成”顺时针找第一个点”(Chord 的 successor 规则,ketama、Envoy 都这样做),并把哈希空间取成 \([0, 2^{32})\) 或 \([0, 2^{64})\):

哈希环的归属规则与扩容:左图 A、B、C 三个节点各占环上一点,每个节点拥有从前一个点到自己的那段弧,k1 到 k5 五个 key 顺时针归属到第一个节点;右图在 B 与 C 之间加入 D 后,只有落在 B 到 D 之间的 k3 从 C 移到 D,其余 key 的归属不变
  • 彩色弧表示归属范围:节点拥有”前一个点(不含)到自己(含)“的那段弧。k5 在 C 之后、环绕到 A 之前,所以归 A。
  • 加入 D 只切开了 C 的范围,受影响的只有落在 \((B, D]\) 里的 k3。A、B 之间、B、C 之间不会发生任何迁移,这就是单调性。
  • 反过来,删除一个节点时它的整段弧交给顺时针的下一个节点。若每个节点只有一个点,被删节点的负载会全部压到一个邻居身上。

查找就是在有序数组上找第一个不小于 key 哈希的位置,走到末尾就回绕到 0(摘自 reproduce/chash.c):

/* index of the first point with pos >= h, wrapping to 0 */
static inline int ring_successor(const uint64_t *pos, int m, uint64_t h)
{
    int lo = 0, hi = m;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (pos[mid] < h) lo = mid + 1; else hi = mid;
    }
    return lo == m ? 0 : lo;
}

static inline int ring_lookup(const Ring *r, uint64_t kh)
{
    return r->owner[ring_successor(r->pos, r->npoints, kh)];
}

虚拟节点数与负载偏差

每个节点只放一个点时,三个节点的份额可以差得很远;每个节点放 \(V\) 个点后,一个节点的份额是 \(V\) 段随机弧长之和,波动变小:

虚拟节点的效果:左图三个节点各一个点,位置 0.10、0.45、0.52,份额为 58%、35%、7%;右图每个节点四个交错的点,份额变为 28%、38%、34%,仍不均匀但差距明显缩小

这个效果可以精确算出来。设 \(n\) 个节点各放 \(V\) 个点,共 \(nV\) 个点独立均匀地落在环上。\(nV\) 段间隔 \((D_1, \ldots, D_{nV})\) 服从参数全为 1 的 Dirichlet 分布;点的归属与位置无关,所以某个节点的份额 \(S\) 是其中 \(V\) 段之和,服从 \(\text{Beta}(V, (n-1)V)\):

\[ \mathbb{E}[S] = \frac{1}{n},\qquad \operatorname{Var}[S] = \frac{V \cdot (n-1)V}{(nV)^2 (nV+1)} = \frac{n-1}{n^2 (nV+1)}. \]

于是相对标准差是

\[ \frac{\sqrt{\operatorname{Var}[S]}}{\mathbb{E}[S]} = \sqrt{\frac{n-1}{nV+1}} \approx \frac{1}{\sqrt{V}}. \]

它几乎与 \(n\) 无关。Lamping 和 Veach 论文 Figure 2 的测量与此吻合:每桶 100 个点时标准误差 0.0997,1000 个点时 0.0316。

但容量规划看的是峰值。把 \(n\) 个份额近似为独立正态,最大值约比均值高 \(\sqrt{2 \ln n}\) 个标准差,所以峰均比约为 \(1 + \sqrt{2\ln n / V}\);要把峰均比压到 \(1+\varepsilon\),需要 \(V = \Theta(\ln n / \varepsilon^2)\)。Appleton 和 O’Reilly 在 Multi-probe 论文 3.2 节用 Cramér 定理给出了同样的阶。\(n = 100\)、\(\varepsilon = 0.1\) 时这个估计给出 \(V \approx 920\),而不是”\(V \ge n/\varepsilon^2 = 10^4\)“。

下图是模拟结果:对每组 \((n, V)\) 用 20 个不同的哈希种子建环,直接计算每个节点拥有的弧长(没有 key 采样噪声),取中位数。

环形一致性哈希的负载偏差随虚拟节点数的变化:左图相对标准差在对数坐标下沿 1/√V 直线下降,n=10、100、1000 三条曲线几乎重合;右图峰均比随 V 增大而下降,n 越大峰值越高,虚线是 1+√(2 ln n / V) 的近似

\(n = 100\) 时几个常见取值的结果(./chash balance):

“每节点 100 到 200 个虚拟节点”意味着最忙的节点比平均高 20% 左右,而且这个数随 \(n\) 缓慢增长:\(V = 1024\) 时,\(n = 10\) 的峰均比是 1.05,\(n = 1000\) 是 1.11。第九节会说明表中”真实配置”一列的来源。

代价:内存、构建与再均衡

环要存 \(nV\) 个(位置,节点)对。Lamping 和 Veach 实测了两种实现:用 std::map 每个点 48 字节,用排序数组加 32 位位置每个点 8 字节;每桶 1000 个点、1000 个桶时分别是 46 MB 与 7.6 MB(论文 Figure 3)。节点变化后排序数组要整体重建。

再均衡的分布也不理想。新节点的 \(V\) 个点只从 \(V\) 个邻居那里各切一段,论文举的例子是 1000 个桶、每桶 10 个点时,最多只有 10 个旧桶向新桶交出数据;如果扩容的目的是给某个热点节点减压,而新点恰好没落在它旁边,扩容对它没有作用。

三、Rendezvous 哈希(HRW)

Thaler 和 Ravishankar 在 Using Name-Based Mappings to Increase Hit Rates(IEEE/ACM Transactions on Networking, 1998)中提出最高随机权重(Highest Random Weight,HRW),也叫 rendezvous 哈希:对每个节点 \(i\) 计算 \(w_i = h(k, i)\),选权重最大的节点。

它的性质直接来自定义:删除节点 \(j\) 时,其他 key 的最大值不变,只有原本归 \(j\) 的 key 需要重新选;加入节点时,只有新节点权重最大的那些 key 移过去。只要 \(h\) 足够随机,每个节点成为最大值的概率都是 \(1/n\)。代价是每次查找要算 \(n\) 个哈希,时间 \(O(n)\)。Wang 和 Ravishankar(MONET 2009)把节点组织成树,查找降到 \(O(\log n)\),但按 Lamping 和 Veach 的评述,节点增删时只在树的最底层重新均衡,均衡性变差。

加权版本有一个简洁的构造。令 \(u_i \in (0,1)\) 为 \(h(k,i)\) 归一化后的值,打分 \(s_i = -\ln u_i / w_i\),选打分最小的节点。\(-\ln u_i\) 服从参数为 1 的指数分布,除以 \(w_i\) 后服从参数为 \(w_i\) 的指数分布;独立指数分布的最小值落在第 \(i\) 个上的概率是 \(w_i / \sum_j w_j\)。Schindelhauer 和 Schomaker(SPAA 2005)把这种形式称为 Logarithmic Method,并在定理 6 中证明了这个概率。实现只多一次对数(摘自 reproduce/chash.c,测试里验证了权重 1:2:3:4 时份额为 10%、20%、30%、40%,误差小于 0.5 个百分点):

static int hrw_weighted_lookup(const Hrw *h, const double *weight, uint64_t kh)
{
    int best = 0;
    double bs = INFINITY;
    for (int i = 0; i < h->n; i++) {
        double u = ((double)(mix64(h->nh[i] ^ kh) >> 11) + 0.5) * 0x1p-53;
        double s = -log(u) / weight[i];
        if (s < bs) { bs = s; best = i; }
    }
    return best;
}

HRW 对”key 很多”的场景是均衡的;但如果像 Maglev 那样只用它来填一张有限大小的查找表,每个节点只分到几十个表项,采样噪声就回来了。Maglev 论文 5.3 节的测量是:1000 个后端、表大小 65537 时,用 HRW 填表需要把后端超配 49.5% 才能承受最忙的那个,表大小 655373 时降到 12.3%。

四、Jump consistent hash

从线性算法到跳跃

Lamping 和 Veach(Google)2014 年的 A Fast, Minimal Memory, Consistent Hash Algorithm(arXiv:1406.2294,预印本,未经同行评审)换了一个问题:桶只用编号 \(0, 1, \ldots, n-1\) 表示,所有客户端看到的都是同一个 \(n\)。记 \(\text{ch}(k, n)\) 为 key \(k\) 在 \(n\) 个桶下的结果。要同时均衡和单调,从 \(n\) 到 \(n+1\) 时必须恰好有 \(1/(n+1)\) 的 key 跳到新桶 \(n\),其余不动。

用 key 作种子的伪随机序列逐个桶数模拟,就得到线性时间的算法。对数时间的关键是只计算”跳跃”发生在哪里。设上一次跳到了桶 \(b\)(即 \(\text{ch}(k, b+1) = b\)),下一次跳跃发生在桶数为 \(j+1\) 时(\(\text{ch}(k, j+1) = j\))。对任意 \(i > b\),\(j \ge i\) 当且仅当桶数从 \(b+1\) 增加到 \(i\) 的过程中没有跳,概率为

\[ P(j \ge i) = \frac{b+1}{b+2} \cdot \frac{b+2}{b+3} \cdots \frac{i-1}{i} = \frac{b+1}{i}. \]

取均匀随机数 \(r \in (0,1)\),令 \(j \ge i \iff r \le (b+1)/i\),即 \(i \le (b+1)/r\),于是

\[ j = \left\lfloor \frac{b+1}{r} \right\rfloor. \]

循环不断计算下一个跳跃点,直到它不小于 \(n\),上一个跳跃点就是答案:

Jump 一致性哈希只计算跳跃点:横轴为桶数 n 从 1 到 14,纵轴为 key k3 所在的桶,阶梯曲线在 n 等于 2、6、8、11 时分别跳到桶 1、5、7、10;圆圈标出循环实际访问的五个跳跃点,第六次迭代算出的下一个跳跃点不小于 14,于是返回 10

图中 k3 的取值来自论文的示例表。求 \(\text{ch}(k_3, 14)\) 时,循环依次访问 \(b = 0, 1, 5, 7, 10\),第六次算出的 \(j \ge 14\),返回 10;逐个桶数模拟则要 13 步。

代码与复杂度

下面是论文 Figure 1 的实现(原文是 C++,这里把 double(...) 改成 C 的强制转换,摘自 reproduce/chash.c):

static inline int32_t jump_hash(uint64_t key, int32_t num_buckets)
{
    int64_t b = -1, j = 0;
    while (j < num_buckets) {
        b = j;
        key = key * 2862933555777941757ULL + 1;
        j = (int64_t)((double)(b + 1) * ((double)(1LL << 31) / (double)((key >> 33) + 1)));
    }
    return (int32_t)b;
}
  • key * 2862933555777941757ULL + 1 是 64 位线性同余生成器,论文说这个乘子取自 L’Ecuyer(Mathematics of Computation, 1999)的表,高维格结构好。
  • 取高 31 位得到 \(r = ((\text{key} \gg 33) + 1) / 2^{31} \in (0, 1]\),代码里的 (double)(1LL << 31) / (double)((key >> 33) + 1) 就是 \(1/r\),乘以 \(b+1\) 后截断即 \(\lfloor (b+1)/r \rfloor\)。
  • 论文指出,整数 key 不必先哈希,因为每轮迭代都在重新混合;非整数 key 先算一个 64 位哈希。

桶数为 \(i\) 时发生跳跃的概率是 \(1/i\),所以循环次数的期望是 \(1 + \sum_{i=2}^{n} 1/i < \ln n + 1\)。./chash example 用 \(10^6\) 个随机 key 实测:\(n = 10\) 时平均 2.931 次(公式 2.929),\(n = 10^5\) 时 12.087 次(公式 12.090)。

局限

论文摘要写明了主要限制:桶必须连续编号,所以它”更适合数据存储,而不是分布式 Web 缓存”。只能增删编号最大的桶;中间的桶坏了不能直接删掉。论文的论证是存储分片本来就有副本,服务器宕机由副本接管,不引起数据重分配,只有容量变化才改变 \(n\)。如果要用它挑选服务器,就需要一层”逻辑分片编号到物理服务器”的映射,而维护这层映射本身需要协调。Jump 也不支持权重。

五、Multi-probe:把代价从内存挪到查找

Appleton 和 O’Reilly(Google)的 Multi-probe consistent hashing(arXiv:1505.00062,2015,预印本,未经同行评审)反过来做:每个节点只在环上放一个点,而把 key 哈希 \(K\) 次;对每个探测点找顺时针后继节点,选”探测点到后继距离最小”的那一个。

Multi-probe 一致性哈希:五个节点各占环上一点,key 被哈希成三个探测点 p0、p1、p2,分别顺时针找到后继 C、D、E,间距为 0.17、0.045、0.13,取间距最小的 p1,所以 key 归 D

直觉是:大弧会接住更多探测点,但落进大弧的探测点通常离后继很远,很少胜出,于是大弧的优势被抵消。论文定理 1 证明,\(2 \le K \ll \sqrt{n}/\ln n\) 时峰值负载以高概率为 \(\frac{K}{K-1}\cdot\frac{1}{n} + o(1/n)\),所以峰均比 \(1+\varepsilon\) 只需 \(K = 1 + 1/\varepsilon\) 次探测。论文 Table 3 中 \(K = 21\) 的峰均比收敛到 1.05;要让环达到同样的 1.05,Table 5 用的是每节点 \(700 \ln n\) 个虚拟节点(\(n = 100\) 时 3223 个),而不是”700 个”。

/* one point per node; K probes, pick the probe closest to its successor */
static inline int mp_lookup(const Ring *r, uint64_t kh, int k)
{
    uint64_t best_d = UINT64_MAX;
    int best = 0;
    for (int p = 0; p < k; p++) {
        uint64_t ph = mix64(kh ^ g_probe_seed[p]);
        int idx = ring_successor(r->pos, r->npoints, ph);
        uint64_t d = r->pos[idx] - ph; /* clockwise distance, wraps */
        if (d < best_d) { best_d = d; best = r->owner[idx]; }
    }
    return best;
}

d = r->pos[idx] - ph 利用无符号回绕:回绕到下标 0 时差值自动就是跨过 \(2^{64}\) 的顺时针距离。本文实现用二分查找找后继,每次查找 \(O(K \log n)\);论文第 4 节用分桶的哈希表(约每桶 6 个节点)找后继,每个探测期望 \(O(1)\),报告 \(K = 2\) 时每次查找 30 到 60 ns。

第八节的实测有一个论文没有强调的现象:\(n = 100\)、\(K = 21\) 时峰均比是 1.07,但相对标准差是 0.17,比 \(V = 100\) 的环(0.10)还大。Multi-probe 压住的是峰值,负载偏低的节点仍然很多。如果关心的是最忙节点(容量规划),这没有问题;如果关心每台机器的利用率,就不能只看峰均比。

六、Maglev:用查找表换均衡

背景:连接跟踪优先,一致性哈希兜底

Eisenbud 等人在 NSDI 2016 的 Maglev: A Fast and Reliable Software Network Load Balancer 描述了 Google 的软件网络负载均衡器。论文引言说它自 2008 年起服务 Google 的前端,承载了几乎全部入站用户流量。选择后端分两步(3.3 节):先查本机的连接跟踪表,按 5 元组哈希命中就沿用原后端;查不到时才用一致性哈希选后端并记入表中。一致性哈希要解决的是连接跟踪覆盖不到的情况:Maglev 机器本身增减(路由器前面的 ECMP 不保证连接亲和)、连接表被 SYN 洪泛写满。

在这种设定下,论文(3.4 节)明确做了与 Karger、HRW 相反的取舍:均衡最重要,否则后端必须按峰值超配;少量迁移可以容忍,因为稳态下连接对 Maglev 机器的亲和不变,查找表变化不会重置连接。

填表算法

每个后端 \(i\) 由名字算出两个哈希值,生成一个 \([0, M)\) 的排列作为偏好列表:

\[ \text{offset}_i = h_1(\text{name}_i) \bmod M,\quad \text{skip}_i = h_2(\text{name}_i) \bmod (M-1) + 1,\quad \text{perm}_i[j] = (\text{offset}_i + j \cdot \text{skip}_i) \bmod M. \]

\(M\) 取质数,保证任何 \(\text{skip}_i \in [1, M-1]\) 都与 \(M\) 互质,排列覆盖全部位置。然后各后端轮流按自己的偏好认领还空着的位置,直到表填满(论文 Pseudocode 1):

Maglev 填表过程:M 为 7,三个后端的 (offset, skip) 为 (3,4)、(0,2)、(3,1);左侧是三列排列,中间是七步轮流认领的顺序,被占的偏好位置划掉;右侧是查找表,删除 B2 前为 B2、B1、B2、B1、B3、B3、B1,删除后为 B1、B1、B1、B1、B3、B3、B3,其中槽 0 和槽 2 必须改变,槽 6 从 B1 变成 B3 属于额外迁移

图中的参数取自论文 Table 1,./chash example 算出的前后两张表与论文一致。删除 B2 后,原属 B2 的槽 0、2 必须改变;槽 6 也从 B1 变成了 B3,因为 B2 不再参与轮转,后面的认领顺序整体错位了。这就是 Maglev 不满足最小迁移的原因。

static void maglev_populate(Maglev *mg, const uint64_t *offset, const uint64_t *skip,
                            int n, uint64_t m)
{
    uint64_t *cur = xmalloc(sizeof *cur * n); /* permutation[i][next[i]] */
    mg->entry = xmalloc(sizeof *mg->entry * m);
    mg->m = m;
    for (uint64_t j = 0; j < m; j++) mg->entry[j] = -1;
    for (int i = 0; i < n; i++) cur[i] = offset[i];
    uint64_t filled = 0;
    for (;;) {
        for (int i = 0; i < n; i++) {
            uint64_t c = cur[i];
            while (mg->entry[c] >= 0) {
                c += skip[i];
                if (c >= m) c -= m;
            }
            mg->entry[c] = i;
            c += skip[i];
            if (c >= m) c -= m;
            cur[i] = c;
            if (++filled == m) { free(cur); return; }
        }
    }
}

这里用 cur[i] 增量维护 \(\text{perm}_i[\text{next}_i]\),避免每步做乘法取模。查找就是 entry[h(k) % M]。

均衡、复杂度与迁移量

论文给出的性质是:每个后端恰好占 \(\lfloor M/N \rfloor\) 或 \(\lceil M/N \rceil\) 个表项;填表平均 \(O(M \log M)\)(填第 \(m\) 个位置期望尝试 \(M/(M-m)\) 次),最坏 \(O(M^2)\),所以总是取 \(M \gg N\),实践中 \(M > 100N\),使各后端的哈希空间份额相差不超过 1%。默认 \(M = 65537\);表大小从 65537 增加到 655373 时,微基准中的建表时间从 1.8 ms 增加到 22.9 ms(5.3 节)。权重通过改变各后端认领的频率实现,论文没有给细节。

迁移量用 ./chash maglev 复现论文 Figure 12 的设定:1000 个后端,随机删 \(k\) 个,重建表,统计变化表项的比例(20 次平均)。“最优”指原本属于被删后端的表项比例:

\(M/N\) 只有 65 时,删一台后端的迁移量是最优值的 6.8 倍;\(M\) 放大 10 倍后降到 4.4 倍,同时删除多台时差距缩小。第八节 \(n = 100\)、\(M = 65537\) 的实验里,删一台迁移 1.6%(最优 1%),其中 0.6% 是存活节点之间的无谓迁移。Envoy 文档给出的是同一个方向的结论:“约两倍的 key 会移动”。

七、有界负载:直接给峰值设上限

前面的方法都只保证 key 数的期望均衡,而真实负载还取决于每个 key 的热度。Mirrokni、Thorup 和 Zadimoghaddam 在 Consistent Hashing with Bounded Loads(SODA 2018)中换了一个目标:给定 \(c = 1+\varepsilon > 1\),任何节点的负载都不超过容量 \(\lceil c\,m/n \rceil\)(\(m\) 为当前 key 或连接数)。做法是在环上加线性探测:key 顺时针走到第一个节点,满了就继续走到下一个没满的节点。

有界负载一致性哈希:四个节点容量均为 3,已放置 8 个 key,A、B、C、D 分别有 1、3、1、3 个;第 9 个 key k 的顺时针后继 B 已满,于是继续前进,放到还有空位的 C 上

硬上限的代价是迁移量。论文证明,插入或删除一个 key 或节点时,期望移动数在 \(\varepsilon \le 1\) 时是最优值的 \(O(1/\varepsilon^2)\) 倍,\(\varepsilon \ge 1\) 时是 \(1 + O(\log c / c)\) 倍。论文还提到两个工业应用:Google Cloud Pub/Sub,以及 Vimeo 的视频分发,后者的工程师认为 \(c = 1.25\) 已经足够。

这个算法进入了常用代理:

  • HAProxy 自 1.7.0 起提供 hash-balance-factor,文档说明它是 bounded-load consistent hashing,参数是相对平均并发请求数的百分比,建议 125 到 200。
  • Envoy v1.31.0 的环哈希与 Maglev 都有 hash_balance_factor,注释引用了这篇论文,并说明探测不是走到下一个主机,而是随机跳跃,以避免级联溢出(引用 Chen、Coleman、Shrivastava 的 Revisiting Consistent Hashing with Bounded Loads,AAAI 2021);注释同时提醒这是 \(O(N)\) 的算法。

八、同一口径的对比实验

环境与口径

  • 程序:reproduce/chash.c,结果原文保存在 reproduce/results.txt。编译运行:gcc -O2 -Wall -Wextra -o chash chash.c -lm,然后 ./chash test、./chash balance、./chash compare、./chash disrupt、./chash maglev、./chash example、./chash time。另用 -fsanitize=address,undefined 编译跑过 test、maglev。
  • 环境:Intel Core i9-12900K,WSL2 内核 6.6.87.2,GCC 16.1.1。
  • 哈希:节点位置、HRW 权重、探测点都用 MurmurHash3 的 64 位终结函数 fmix64 混合;key 是 splitmix64 生成的随机 64 位整数。所有种子固定,除计时外,连续两次运行输出逐字节一致。
  • ./chash test 检查了 Jump 在 \(n \to n+1\) 时的单调性、环 / HRW / Multi-probe 删除节点时存活节点之间零迁移、Maglev 每后端表项数相差不超过 1、加权 HRW 的份额。

均衡性

\(n = 100\) 个节点,\(10^7\) 个 key(./chash compare)。每个节点平均 \(10^5\) 个 key,纯采样噪声就带来约 0.3% 的相对标准差,第一行用取模哈希给出这个下限。环只用了一个哈希种子,与第二节 20 个种子的中位数有出入。

Jump、HRW、Maglev 已经达到采样噪声下限;Multi-probe 的峰均比与论文 Table 2、Table 3 在 \(n = 100\) 时的范围一致(\(K = 2\) 中位数 1.96、\(K = 21\) 中位数 1.05,90 分位 1.08)。

迁移量

\(n = 100\),\(10^6\) 个 key,每行 10 次试验的中位数(./chash disrupt)。删除时每次随机选一个节点(取模与 Jump 只能删最后一个);加入时新节点用新名字。“额外迁移”指不必要的移动:删除时是原本不在被删节点上却换了主人的 key,加入时是没有移到新节点的 key。理想值是迁移约 1%、额外迁移 0。

环的迁移量围绕 1% 波动,因为被删节点本身的份额就有 \(\pm 1/\sqrt{V}\) 的偏差。

查找耗时

计时绑定到一个逻辑 CPU(taskset -c 17),每个单元格是 \(2 \times 10^6\) 次查找的平均,程序内部取 5 次的中位数,整个程序再跑 3 次取中位数;包含一次 fmix64。机器上同时有其他负载,表中数字只看相对趋势,不能当作绝对延迟。

这是吞吐式测量:连续的独立查找可以被乱序执行重叠,Maglev 的 256 KiB 表能放进 L2,所以它和取模几乎一样快;环的二分查找有数据相关的分支,且 \(nV\) 增大后缓存未命中增多;HRW 与 \(n\) 成正比;Multi-probe 的数字来自本文的二分查找实现,不代表论文的哈希表实现。Jump 论文 Figure 4、Figure 6 的结论与此同向:环(每桶 1000 点)在数据结构超出缓存后变慢,存在内存竞争时差距进一步拉大,而 Jump 基本不受影响。Envoy 文档称,与 256K 条目的环相比,Maglev 建表约快 10 倍、选主机约快 5 倍。

九、生产系统里的实现

几条需要展开的:

Envoy 的默认环很小。 ring_hash_lb.cc 按 \(\text{scale} = \min(\lceil w_{\min} \cdot \text{min\_ring} \rceil / w_{\min}, \text{max\_ring})\) 计算环大小(\(w_{\min}\) 是最小的归一化权重),每台主机的点数约为 \(\text{scale} \times w_i\)。100 台等权主机时 \(w_{\min} = 0.01\),每台 \(\lceil 1024 / 100 \rceil = 11\) 个点,按第二节的表,峰均比中位数约 1.95。Envoy 文档自己的建议是显式设置 minimum_ring_size / maximum_ring_size,并监控 min_hashes_per_host 与 max_hashes_per_host。

Cassandra 从 256 降到 16,靠的不是随机。 若 16 个 token 随机选,按第二节模拟,相对标准差约 25%、峰均比约 1.67;4.0 在降低默认值的同时启用了按副本因子优化的 token 分配算法,所以不能用随机环的数字评价它。改动原因见 NEWS.txt 指向的官方生产部署文档(getting_started/production.html#tokens)。

Dynamo 最终放弃了”位置决定分区”。 DeCandia 等人论文第 6.2 节记录了三种策略:Strategy 1 每节点 T 个随机 token、按 token 切分,节点加入时要扫描本地存储找出要交出的 key,繁忙季节引导过程接近一天,而且分区边界随成员变化,Merkle 树要重算、归档困难;Strategy 3 把哈希空间切成 Q 个固定等大分区、每节点持有 Q/S 个,在 \(S = 30\)、\(N = 3\) 的评估里负载均衡效率最好,成员信息缩小了三个数量级。代价是成员变化时需要协调。Redis Cluster 的 16384 个 hash slot 是同一思路,见站内 Redis Cluster:16384 slot。Dynamo 论文 4.3 节还提到一个细节:用环做副本时,顺时针前 \(N\) 个位置可能属于同一台物理机,偏好列表要跳过重复的物理节点。

十、争论与开放问题

争论一:均衡与最小迁移,先保哪个

Karger 和 HRW 保证删除节点时存活节点之间零迁移,但均衡只有概率保证;Maglev 论文把两者都列为”优先最小迁移”的方案,认为它们要达到负载均衡器需要的均衡度,每个 VIP 的查找表会大到不可接受,于是反过来优先均衡。两边都有证据:Maglev 的依据是连接跟踪兜住了大部分迁移代价;Envoy 文档的实测是 Maglev 迁移约两倍,但对 Redis 等”很多应用”仍是更好的替代。没有连接跟踪、迁移直接意味着缓存失效或数据搬迁的场景,这个取舍就要反过来算。第六节的表说明,这个代价还强烈依赖 \(M/N\)。

争论二:随机放置还是协调放置

Karger 的构造不需要任何协调:知道节点名字和哈希函数就能算出映射,这正是它适合视图不一致的 Web 缓存的原因。Dynamo 的 Strategy 3、Cassandra 的 token 分配算法、Redis Cluster 的 slot 表都走向了另一端:用少量协调换取精确的均衡和以分区为单位的搬迁。Jump 处在中间:所有客户端只需要对 \(n\) 达成一致。选哪一端取决于成员信息能否可靠地同步,而不是算法本身的优劣。

争论三:均衡 key 数还是均衡负载

以上所有均衡度都是按 key 数算的。热点 key 会让任何一种一致性哈希失衡,Karger 论文标题里的”热点”本来就是靠随机树加复制解决,一致性哈希只负责定位。有界负载直接约束负载,但放弃了历史无关性:key 的位置取决于到达顺序和当前负载,节点变化时可能沿环级联迁移,Envoy 的注释也提醒它是 \(O(N)\) 的算法。

开放问题

  • 任意删除与常数内存能否兼得。 Jump 做到了 \(O(1)\) 内存和完美均衡,但只能删最后一个桶;HRW 能任意删除,但查找 \(O(n)\)。AnchorHash(Mendelson 等,IEEE/ACM ToN 2021)等后续工作在这条线上继续探索,是否存在同时满足任意删除、完美均衡、最小迁移和常数查找的方案,仍在研究中。
  • 有界负载在持续变化下的真实代价。 SODA 2018 的界是每次更新的期望移动数;在节点与连接同时高频变化、并且叠加随机跳跃探测的生产实现里,级联迁移的尾部行为缺少系统的测量。
  • 加权与异构。 Jump 不支持权重;环靠点数近似权重,低权重节点点数少、偏差大;加权 HRW 精确但 \(O(n)\)。怎样在 \(O(\log n)\) 或 \(O(1)\) 的查找里得到精确的加权均衡,是实际部署中反复出现的问题。

十一、工程陷阱与选型

按约束选:

十二、参考资料

规范与文档

  • Envoy v1.31.0, Load balancers(docs/root/intro/arch_overview/upstream/load_balancing/load_balancers.rst):Ring hash、Maglev 两节。
  • Envoy v1.31.0 API:api/envoy/config/cluster/v3/cluster.proto(RingHashLbConfig、MaglevLbConfig),api/envoy/extensions/load_balancing_policies/ring_hash/v3/ring_hash.proto(minimum_ring_size、maximum_ring_size、hash_balance_factor)。
  • HAProxy 2.8 Configuration Manual, hash-balance-factor(1.7.0 起提供)。
  • Apache Cassandra:conf/cassandra.yaml(cassandra-1.2.0、cassandra-4.0.0、cassandra-5.0.0 标签),NEWS.txt 与 CHANGES.txt(cassandra-4.0.0 标签,CASSANDRA-7032、CASSANDRA-13701)。

源码

  • Envoy v1.31.0:source/extensions/load_balancing_policies/ring_hash/ring_hash_lb.h、ring_hash_lb.cc(DefaultMinRingSize、DefaultMaxRingSize、环大小计算);source/extensions/load_balancing_policies/maglev/maglev_lb.h、maglev_lb.cc(DefaultTableSize、OriginalMaglevTable::constructImplementationInternals())。
  • nginx release-1.26.2:src/http/modules/ngx_http_upstream_hash_module.c(ngx_http_upstream_init_chash()、ngx_http_upstream_find_chash_point())。
  • RJ/ketama,commit 18cf9a7717dad0d8106a5205900a17617043fe2c:README、libketama/ketama.c。

核心论文

  • D. Karger, E. Lehman, T. Leighton, R. Panigrahy, M. Levine, D. Lewin, “Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web”, STOC 1997, pp. 654–663.
  • D. G. Thaler, C. V. Ravishankar, “Using Name-Based Mappings to Increase Hit Rates”, IEEE/ACM Transactions on Networking 6(1), 1998, pp. 1–14.
  • J. Lamping, E. Veach, “A Fast, Minimal Memory, Consistent Hash Algorithm”, arXiv:1406.2294, 2014(预印本)。
  • B. Appleton, M. O’Reilly, “Multi-probe consistent hashing”, arXiv:1505.00062, 2015(预印本)。
  • D. E. Eisenbud et al., “Maglev: A Fast and Reliable Software Network Load Balancer”, NSDI 2016.
  • V. Mirrokni, M. Thorup, M. Zadimoghaddam, “Consistent Hashing with Bounded Loads”, SODA 2018, pp. 587–604.
  • G. DeCandia et al., “Dynamo: Amazon’s Highly Available Key-value Store”, SOSP 2007, pp. 205–220.

其他论文

  • I. Stoica, R. Morris, D. Karger, M. F. Kaashoek, H. Balakrishnan, “Chord: A Scalable Peer-to-peer Lookup Service for Internet Applications”, SIGCOMM 2001.
  • C. Schindelhauer, G. Schomaker, “Weighted Distributed Hash Tables”, SPAA 2005, pp. 218–227.
  • W. Wang, C. V. Ravishankar, “Hash-Based Virtual Hierarchies for Scalable Location Service in Mobile Ad-hoc Networks”, Mobile Networks and Applications 14(5), 2009.
  • P. L’Ecuyer, “Tables of Linear Congruential Generators of Different Sizes and Good Lattice Structure”, Mathematics of Computation 68(225), 1999.
  • J. Chen, B. Coleman, A. Shrivastava, “Revisiting Consistent Hashing with Bounded Loads”, AAAI 2021.
  • G. Mendelson, S. Vargaftik, K. Barabash, D. Lorenz, I. Keslassy, A. Orda, “AnchorHash: A Scalable Consistent Hash”, IEEE/ACM Transactions on Networking 29(2), 2021.

实验

  • reproduce/chash.c:第二、六、八节全部模拟数据的来源;reproduce/results.txt 为原始输出;reproduce/plot_vnode_balance.py 生成 vnode-balance.svg。

系列导航: - 上一篇:完美哈希:从 FKS 两级表到 gperf 与现代 MPHF - 下一篇:Bloom Filter 全家族

相关阅读: - 负载均衡算法:P2C / EWMA / 平滑加权轮询 - 哈希表内部:开放寻址、链式与 Robin Hood - Redis Cluster:16384 slot 与 MOVED/ASK

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-05-29 · algorithms

局部敏感哈希:从概率保证到多探针近邻检索

从 (c,r)-ANN 与 LSH 的 rho 参数出发,推导 AND-OR 放大、随机超平面、p-stable 与 multi-probe,复现实测 SIFT1M 上召回率和候选数,并说明为什么理论保证不等于工程排名。

2026-05-05 · algorithms

负载均衡算法:P2C / EWMA / 平滑加权轮询

负载均衡看似简单,实则处处是坑。

2026-04-27 · algorithms / database

数据库缓冲池替换:LRU-K、2Q 与生产级扫描保护

从数据库缓冲池的 fix/unfix、脏页和扫描污染出发,对照 LRU-K、2Q、CLOCK-Pro 的学术脉络,以及 PostgreSQL 16 与 InnoDB 8.0 的源码实现,用可复现 trace 比较命中率和元数据开销。

2025-07-15 · algorithms

TimSort:自然 run、galloping 与从栈不变量到 Powersort 的合并策略

对照 CPython 与 OpenJDK 源码拆解 TimSort 的 run 检测、minrun、galloping 与合并,梳理 2015 年栈不变量 bug 和改用 Powersort 的原因;比较次数来自与 CPython 逐次一致的 C 移植。