1. 本文不是 Maglev 四层负载均衡器本身,而是其内部使用的一种一致性哈希算法,通常称为 Maglev Hash。
  2. 本文默认哈希函数为 MurmurHash64

本文沿「背景 →算法核心 → 哈希表开放寻址 → 数论原理 → 算法实现 → 复杂度分析与 ketama 对比 → 参考附录」的主线展开。

背景

Maglev

Google 在 2016 年发表了 Maglev: A Fast and Reliable Software Network Load Balancer。论文内部提出了一种一致性哈希算法的新实现方案,后被微服务网关 Envoy 与 Google 的 gRPC 采用为工业级方案。

回顾负载均衡

负载均衡算法有「族」的概念,其中之一叫做映射粘性族。它回答的是:同一个 key 怎么永远落入同一台机器。它宁愿牺牲一部分均衡性,来换取其它族没有的性质——粘性。

为什么需要粘性

后端机器存储了某个客户端的热点缓存,客户端当然希望一直打到这台后端命中本地缓存,否则就是缓存 miss + 回源。又比如,会话状态保存在后端内存里,用户下一次落到同一台机器,登录状态才不会丢;如果落到其它机器,又要重新登录。

这种需求下,再追求均衡反而是破坏性的:把各个用户均匀分散,缓存命中率低、会话容易断。映射粘性族直接不追均衡,转而追求 key -> upstream_peer 的稳定映射。

朴素的映射:IP 哈希

IP哈希:请求的某个 key(比如客户端 IP),哈希一下,对后端数取模,hash(key) % N,落哪台就哪台。同一个 IP,只要后端数 N 不变,每次算出来都是同一台。粘性达成了,O(1),无状态。

致命问题
负载极度不均衡, 高峰期用户量全打向同一机器, 单机瞬间瘫痪。
取模对 N 极度敏感,N一变化(增加机器或者机器宕机), 缓存大量失效集体回源打崩数据库。

从公式上,hash(key) % N,N 一变,几乎所有 key 的归属同时变。3 台变 4 台,hash % 3hash % 4 的结果对绝大多数 key 都不同——理论上只有约 1/N 的 key 还落在原位,其余全部重新洗牌。

缓存场景下这是灾难:加一台机器扩容, 结果导致几乎全量缓存失效, 根因就是哈希取模跟后端机器绑定太死。
一致性哈希就是来解开这个绑定。

IP 哈希现在已经退居幕后而不是彻底废弃。
在L4负载均衡中。 系统拿不到上层的CookieSession,只能看到IP。当系统遭受大流量 DDoS 攻击时,安全网关在最外层用 IP Hash 将特定 IP 段的流量固定分流,进行行为审计和限流,避免污染其它干净的服务器。
IP 哈希属于 映射粘性族, 一致性哈希具有固定映射和负载均衡的能力。

朴素一致性哈希

一致性哈希需要将keyN解绑, 思路就是建一个环。把哈希值空间(比如 0 ~ 2^31 - 1) 首尾相接成一个圆。

把每个后端哈希映射到环上一点(比如hash(节点标识)); 把每个key也哈希到环上一点。 一个key属于哪个机器? 顺时针遇到的第一个节点就是它的归属后端。

举一个例子, 有三台后端 A B C

hash_ring:  0 ----A---- 0.33 ----B---- 0.66 ----C---- 1 
request1:    hash(key) = 0.4  -->  落在 B 的区间  -->  路由到 B
request2:    hash(key) = 0.6  -->  落在 B 的区间  -->  路由到 B

加一台机器, 比如 D 插入到AB这个区间

hash_ring:  0 ----A---- 0.33 ----B---- 0.5 ----D---- 0.66 ----C---- 1 
request1:    hash(key) = 0.4  -->  保持在 B 区间  -->  路由到 B
request2:    hash(key) = 0.6  -->  变更到 C 区间  -->  路由到 C

只要中间新增的一段区间的所有节点划到新机器上。 根本上全量更新到局部调整, 这样缓存大量失效就变成来局部缓存失效,这是可接受的扰动。

朴素一致性哈希的问题: 分布不均匀。 这正是ketama 一致性哈希要解决的问题。

ketama 一致性哈希

哈希的均匀性有两个前提, 良好的哈希和函数和大量的输入。
朴素哈希环的毛病在于值域很大但输入很少,存在AB离得近,C离得远,于是C的前面一段区间全归C管。 节点少的时候这种倾斜特别明显, 重映射有保证但退化到了IP哈希的场景,均衡性和缓存又崩了。

方案是人为制造输入,用虚拟节点技术。即一台后端可以放k个虚拟节点, 分别用hash(节点标识 + i)算出K个分散的位置。物理机器就是这哈希环上的k个虚拟节点。物理后端节点A就被分散在环里。

带权实现,通常用权重 * 每权重虚拟节点个数 算出真实的虚拟节点个数。

好的哈希函数同样重要, 通常用MurmurHash3分布均匀且雪崩性好。

保障均衡性的方案

映射粘性族的ketama一致性哈希已经不错了, 均衡性也有了。 那为什么有其它一致性哈希算法呢?
映射粘性族看的是静态负载不是动态负载。 ketama 不关心真实的运行时负载, 它在静态时把key均衡的分给后端。
ketama的缺点在于概率, 讨论一下最坏情况。 虚拟节点K多少合适?K小了随机分布不均匀, 退化到朴素一致性哈希甚至IP哈希的场景。

具像化到算法实现上, ketama的哈希环本质就是虚拟节点哈希值排序的数组, 虚拟节点多了废内存, 查找速度O(log(N∗k))O(log(N*k))O(log(Nk)) 加虚拟节点也拖累查找。

扰动的不可控,基于概率的保证, 哈希落点不可控, 重映射代价分布是随机和倾斜的。

ketama:均匀性,内存/查找开销,增删扰动可控性 – 这些都被K的这个旋钮捆死了。
如果说ketama是概率派,那么Maglev就是构造派, 后者是结构性的设计不依赖概率。
注:
上面说明的ketama缺点并不表示一定劣于Maglev, 没有免费的午餐。 两者的对比后文再说明。

Maglev 算法思想

先简单说明 Maglev 的核心思路, 其采用一个固定大小的查找表(lookup table), 表的大小是质数M(论文中为65537)。
每个后端通过两个哈希函数计算一个偏好序列, 然后所有后端按轮次依次填表。

for backend i, compute:
  offset[i] = h1(id) mod M
  skip[i] = h2(id) mod (M - 1) + 1

每个后端都有offset表示它在查找表的初始位置, skip表示它每次跳跃的步长(必须 大于0且小于M)
然后是偏好序列的计算

backend i permutation:
  permutation[i][j] = (offset[i] + j * skip[i]) mod M

核心:填充算法

// 伪代码实现, 以 A B C 三个 backend 举例
func maglev_populate(backends, M):
  table <- [0] * M // 初始化填充表
  filled <- 0 // 当前查找表填充的个数
  while filled < M :
    // 轮询 backends [A, B, C, A, B, C, ...] 依次填表
    for backend in backeds:
      //先找到第一个表没被占的偏好数位置
      do {
        // pos 为当前后端的偏好数
        pos = (backend.offset + backend.next * backend.skip) % M
        backend.next++;
      } while table[pos] != nil;
      table[pos] = backend.peer
      filled++
      if filled == M:
        break // 整个表填完了。
  return table
  

看看均匀性, 假设M = 7,A B C

  • 服务器 A: (Offset = 3, Skip = 2)

  • 服务器 B: (Offset = 0, Skip = 3)

  • 服务器 C: (Offset = 5, Skip = 1)

  • A 的偏好顺序: [3, 5, 0, 2, 4, 6, 1]

  • B 的偏好顺序: [0, 3, 6, 2, 5, 1, 4]

  • C 的偏好顺序: [5, 6, 0, 1, 2, 3, 4]
    顺序 A占3, B占0,C占5, 无冲突。 那么第一轮结束了,第二轮回到A, A发现5被C占了,去它的下一个0,0被B占了,再去下一个2,拿到了; 同理B发现3被A占了,去占下一个6, 拿到了; C从6开始,6刚被B占了,去0发现也被B占了,去占1拿到了, 第二轮结束。
    第三轮从A开始, A从4开始拿到4了, 此时查找表已经填完了, 结束。
    最终结果[0:B,1:C,2:A,3:A,4:A,5:C,6:B]
    这个例子不太好但是能理解填充表的过程, 当M过大时不会出现上述AAA连续填的情况, 实现完美的均匀。
    每个后端机器的分布数量都至少都大于等于 M/N 的向下取整数。AB,C都大于等于 7 / 3 向下取整数2, 其中A由于轮班在前面且M必定是质数, 所以A是M/N的向上取整数(这里是3)。
    总之,从算法角度,当M过大时每台后端机器是可以认为拥有M/N的均匀分布。

M为什么一定是质数?
这种填充法为什么保证不会漏表, 比如有一个位置索引没有在任何后端节点的偏好序列上。
前文的offset,skip的设计原理?偏好序列的作用是?

偏好序列原理:哈希表开放寻址法

上述的查找表本质就是哈希表, 查询过程哈希一下就可以O(1)O(1)O(1)取后端节点了。

这源于哈希表的两种常见实现方式之一的开放寻址法,另一种常见是链地址法。
开放寻址有多种实现技术,比如线形探测,二次探测, 这次涉及的是另外一种双重散列

双重散列
给予定义:
h(k,i)=(h1(k)+ih2(k))mod m h(k,i) = (h_1(k) + ih_2(k)) mod\ m h(k,i)=(h1(k)+ih2(k))mod m
对比上文: permutation[i][j] = (offset[i] + j * skip[i]) mod M
offset=T[h1(k)]offset = T[h_1(k)]offset=T[h1(k)]决定探测的起点
skip=T[h2(k)]skip = T[h_2(k)]skip=T[h2(k)]决定探测的步长
这本质就是双重散列公式, 偏好序列本质就是双重散列的探测序列。
Maglev填表是同一件事的"集体版本", 每个后端预先计算下一个填表序列, 没有冲突(其它后端没填这个位置)填表, 否则继续探测。

为什么offsetoffsetoffset,skipskipskip不采用线性探测那样的固定步长1?
开放寻址的经典坑,聚集。 一个连续槽被占,顺序遍历查找表, 一路往下滑, 插入和查找都恶化了。 双重散列把步长stepstepstep交给h2h_2h2哈希函数决定, 不同后端步长各异, 探测序列错开几乎不聚集。

事实上,h1和h2可以采用同一哈希函数+不同种子, 比如h1种子数为0,h2种子数为黄金分割数。 h1(id,0)h1(id, 0)h1(id,0), h2(id,0x9e3779b9U)h2(id, 0x9e3779b9U)h2(id,0x9e3779b9U), 哈希函数通常是Murmurhash3Murmurhash3Murmurhash3

上文的公式skip[i] = h2(id) mod (M - 1) + 1, 保证步长大于0且小于M, 它是不漏查找表能遍历全表的前提。
为什么M为质数,一定不会漏表呢?见下文数学原理。

数学原理:欧几里得

M是7,A,B,C的偏好序列在单周期M都完整覆盖了[0, 6]。 这是意外吗?还是数学的必然?
"不漏表"本质要求就是走M步,每步互不相同。满足这个已经满足[0, m-1]全覆盖

用反证法
假设走X步和走Y步同余
(Offset+x×Skip)≡(Offset+y×Skip)(modM)(Offset + x \times Skip) \equiv (Offset + y \times Skip) \pmod M(Offset+x×Skip)(Offset+y×Skip)(modM)
消去 OffsetOffsetOffset 并移项,得到:
(x−y)×Skip≡0(modM)(x - y) \times Skip \equiv 0 \pmod M(xy)×Skip0(modM)
如果成立, (x−y)×Skip(x - y) \times Skip(xy)×Skip必须是 MMM 的整数倍。
M有两种讨论
讨论一: 假设M是质数, 那么它的约数只有1和它本身。
M能整除(x−y)×Skip(x - y) \times Skip(xy)×Skip

根据欧几里得引理(Euclid’s Lemma):如果一个质数能整除两个整数的乘积,那么这个质数至少能整除其中一个因数。

以下两个条件中必须有一个成立:

  1. MMM 能整除 (x−y)(x - y)(xy),记作 M∣(x−y)M \mid (x - y)M(xy)
  2. MMM 能整除 SkipSkipSkip,记作 M∣SkipM \mid SkipMSkip

然而,已知:

  • (x−y)(x - y)(xy) 的范围是 [1, M-1],比 MMM 小。
  • SkipSkipSkip 的范围是 [1, M-1],也比 MMM 小。

一个质数绝对不可能整除任何比它自身小的正整数!

因此,条件 1 和条件 2 统统不成立,从而产生了矛盾。这说明最初的假设(即同余碰撞)完全错误,不会发生。

结论一(质数情况):
MMM 是质数时,服务器走 MMM 步的每一步余数都绝对互不相同。既然每步都不同,走完 MMM 步就必然完美覆盖 [0, M-1] 的所有位置。数学的必然。

讨论二:假设 M 是合数

如果 MMM 是合数,意味着它除了 1 和自身外,还可以分解为其他因数的乘积。我们设 M=d1×d2M = d_1 \times d_2M=d1×d2(其中 d1,d2>1,d1和d2可能相同d_1, d_2 > 1, d_1和d_2可能相同d1,d2>1,d1d2可能相同)。

因为 Maglev 的 SkipSkipSkip 是由哈希函数随机生成的,如果某台服务器算出来的步长刚好是 Skip=d1Skip = d_1Skip=d1(或者 d1d_1d1 的倍数)。

此时,当服务器跑了 d2d_2d2 步时(因为 d2<Md_2 < Md2<M,这在 MMM 步之内必然会发生),相乘的结果:
(x−y)×Skip=d2×d1=M(x - y) \times Skip = d_2 \times d_1 = M(xy)×Skip=d2×d1=M

这个乘积变成了 MMM 的 1 倍( MMM 的整数倍)!
同余方程 (x−y)×Skip≡0(modM)(x - y) \times Skip \equiv 0 \pmod M(xy)×Skip0(modM) 成立。服务器还没跑满 MMM 步,就已经踩到了之前踩过的格子,这意味着死循环,没到的格子永远不会到了也就漏掉了。

结论二(合数情况):
MMM 是合数时,算法在跑满 MMM 步前就提早发生同余碰撞并陷入局部死循环,无法全覆盖,这就是“漏表”灾难。

唯一因子分解定理或欧几里德引理证明自行查阅。

C++ 算法实现

接下来的代码是我实际项目中的实现Maglev,忽略其它细节比如RCU,指纹验证
这是从整个负载均衡算法完整代码中截取出的Maglev,不能直接跑通,理解真实实现后自行改写即可。

Maglev算法分为建表和查询。 初次建表或者重建表(增加机器或者机器宕机), 另一种是查表(哈希函数打桶)
查找表: 一个数组, 由填充算法建立。另一字段是指纹验证技术的字符串(自行了解)

  struct LookupTable {
    std::vector<std::shared_ptr<UpstreamPeer>> entries;
    std::string fingerprint;
  };

后端游标的, peer表示真实的后端节点描述, offset是起点,skip表示步长,next表示下一个所在偏好序列的下标。

  struct BackendCursor {
    std::shared_ptr<UpstreamPeer> peer;
    std::size_t offset;
    std::size_t skip;
    std::size_t next{0};
  };

建表的代码, 先看完整。

  std::shared_ptr<const LookupTable> BuildTable(
    const std::vector<std::shared_ptr<UpstreamPeer>>& peers,
    uint64_t now_ms) const {
    auto table = std::make_shared<LookupTable>();

    std::vector<BackendCursor> backends;
    table->fingerprint.reserve(peers.size() * 32);
    for (const auto& peer : peers) {
      if (!peer->AvailableAt(now_ms)) continue;

      table->fingerprint += peer->config().name;
      table->fingerprint += ':';
      table->fingerprint += std::to_string(peer->config().weight);
      table->fingerprint += ';';

      const int weight = std::max(peer->weight(), 1);
      for (int replica = 0; replica < weight; ++replica) {
        std::string id = peer->config().name;
        id += '#';
        id += std::to_string(replica);

        const uint64_t h1 = runtime::ds::MurmurHash64(id, 0);
        const uint64_t h2 = runtime::ds::MurmurHash64(id, 0x9e3779b9U);

        backends.push_back(BackendCursor{
          peer,
          static_cast<std::size_t>(h1 % table_size_),
          static_cast<std::size_t>((h2 % (table_size_ - 1)) + 1),
          0,
        });
      }
    }

    if (backends.empty()) {
      return table;
    }

    table->entries.assign(table_size_, nullptr);
    std::size_t filled = 0;
    while (filled < table_size_) {
      for (auto& backend : backends) {
        std::size_t pos = 0;
        do {
          pos = (backend.offset + backend.next * backend.skip) % table_size_;
          ++backend.next;
        } while (table->entries[pos] != nullptr);

        table->entries[pos] = backend.peer;
        if (++filled == table_size_) break;
      }
    }

    return table;
  }

填表过程的具像化,
最外层循环的终止条件是查找表填满。
内部分别是轮询后端机器, 然后找到空位填表。

table->entries.assign(table_size_, nullptr);
    std::size_t filled = 0;
    while (filled < table_size_) {
      for (auto& backend : backends) {
        std::size_t pos = 0;
        do {
          pos = (backend.offset + backend.next * backend.skip) % table_size_;
          ++backend.next;
        } while (table->entries[pos] != nullptr);

        table->entries[pos] = backend.peer;
        if (++filled == table_size_) break;
      }
    }

查询过程
下游客户端请求到了, 比如用客户端ip做键值算出哈希值, 然后对查找表长度取模。
找出固定的后端节点。

  static std::shared_ptr<UpstreamPeer> Lookup(const LookupTable& table, uint64_t hash) {
    if (table.entries.empty()) {
      return nullptr;
    }
    return table.entries[hash % table.entries.size()];
  }

所以相当于手写一个多后端双重散列哈希表?
对, 这也是笔者首次写开放寻址的哈希表。

复杂度分析

Maglev 不是全面碾压 ketama
负载均衡 <-> 增删扰动
Maglev:

  1. 查找O(1)O(1)O(1), 不必在环上二分
  2. 均匀性更好,不依赖概率和虚拟节点数量。 根因在设计理念。
  3. 代价在增删扰动: 增加/删除后端需要整表重建, 重映射的key比例高于ketama一致性哈希的1/n1/n1/n
    换言之, Maglev用非最小扰动换近乎完美的均匀性。
维度 ketama(环 + 虚拟节点) Maglev(查找表)
查找复杂度 O(log(n·K)),哈希环上二分查找 O(1),直接 table[hash(key) % M]
构建复杂度 O(n·K·log(n·K)),生成并排序虚拟节点 O(M·logM)(实践中约 O(M),M ≫ n)
均匀性 概率均匀,靠虚拟节点数 K 逼近,有可见偏差 构造均匀,各后端槽数至多差一个,不依赖 K
增删扰动 ≈ 1/n 的 key 重映射(理论最小),但落点不均、可能集中 远大于 1/n,整表重建;靠大 M 压低绝对量
空间 O(n·K),K 默认 160 (通常范围在100~200之间) O(M),M 通常取质数 65537
调参旋钮 K(虚拟节点数):牵动均匀性、内存、查找 M(表大小):主要影响扰动与精度

工业采用上,Envoy 提供 maglev 负载均衡策略,Katran(Facebook)使用 Maglev 哈希。

一致性哈希算法的方案选择

映射粘性族(缓存亲和)
+-- 是 --> 后端频繁增删 / 最怕缓存失效?
            +-- 是 --> 一致性哈希(虚拟节点)   # 扰动最小
            +-- 否 --> Maglev               # 均匀+O(1),代价是扰动大
+-- 否 --> 根据需求选择(静态族/动态族)...

代码完整实现

这里只贴了代码,仅供参考

// Maglev hashing: precomputed lookup table for stable O(1) hash selection.
//
// Unlike ketama, the hot path is a single table lookup:
//   MurmurHash64(key) % table_size -> peer
//
// The table is rebuilt only when the available peer fingerprint changes.
// This implementation supports weights by expanding each peer into "weight"
// logical Maglev backends.
class MaglevHashLB : public LoadBalancer {
public:
  static constexpr std::size_t kDefaultTableSize = 65537;
  static constexpr std::size_t kMinTableSize = 257;
  static constexpr std::size_t kMaxTableSize = 1048583;

  enum class HashOn : uint8_t {
    kClientIp,
    kUri,
    kUserId,
    kSessionId,
  };

  explicit MaglevHashLB(std::size_t table_size = kDefaultTableSize,
    std::string_view hash_on = "client_ip")
  : table_size_(NextPrime(std::clamp(table_size, kMinTableSize, kMaxTableSize))),
    hash_on_(ParseHashOn(hash_on)) {}

  std::shared_ptr<UpstreamPeer> Select(Upstream& upstream, const RequestContext ctx = {} ) override {
    const auto& peers = upstream.peers();
    const uint64_t now_ms = detail::SteadyClockMs();
    auto fp = ComputeFingerprint(peers, now_ms);
    if (fp.empty()) return nullptr;

    auto table = table_.load(std::memory_order_acquire);
    if (table && table->fingerprint == fp) {
      return Lookup(*table, runtime::ds::MurmurHash64(HashKey(ctx)));
    }

    std::lock_guard lk{rebuild_mutex_};
    table = table_.load(std::memory_order_acquire);
    if (!table || table->fingerprint != fp) {
      table = BuildTable(peers, now_ms);
      table_.store(table, std::memory_order_release);
    }
    return Lookup(*table, runtime::ds::MurmurHash64(HashKey(ctx)));
  }

private:
  struct LookupTable {
    std::vector<std::shared_ptr<UpstreamPeer>> entries;
    std::string fingerprint;
  };

  struct BackendCursor {
    std::shared_ptr<UpstreamPeer> peer;
    std::size_t offset;
    std::size_t skip;
    std::size_t next{0};
  };

  static std::string ComputeFingerprint(const std::vector<std::shared_ptr<UpstreamPeer>>& peers,
                                        uint64_t now_ms) {
    std::string fp;
    fp.reserve(peers.size() * 32);
    for (const auto& peer : peers) {
      if (!peer->AvailableAt(now_ms)) continue;
      fp += peer->config().name;
      fp += ':';
      fp += std::to_string(peer->config().weight);
      fp += ';';
    }
    return fp;
  }

  std::shared_ptr<const LookupTable> BuildTable(
    const std::vector<std::shared_ptr<UpstreamPeer>>& peers,
    uint64_t now_ms) const {
    auto table = std::make_shared<LookupTable>();

    std::vector<BackendCursor> backends;
    table->fingerprint.reserve(peers.size() * 32);
    for (const auto& peer : peers) {
      if (!peer->AvailableAt(now_ms)) continue;

      table->fingerprint += peer->config().name;
      table->fingerprint += ':';
      table->fingerprint += std::to_string(peer->config().weight);
      table->fingerprint += ';';

      const int weight = std::max(peer->weight(), 1);
      for (int replica = 0; replica < weight; ++replica) {
        std::string id = peer->config().name;
        id += '#';
        id += std::to_string(replica);

        const uint64_t h1 = runtime::ds::MurmurHash64(id, 0);
        const uint64_t h2 = runtime::ds::MurmurHash64(id, 0x9e3779b9U);

        backends.push_back(BackendCursor{
          peer,
          static_cast<std::size_t>(h1 % table_size_),
          static_cast<std::size_t>((h2 % (table_size_ - 1)) + 1),
          0,
        });
      }
    }

    if (backends.empty()) {
      return table;
    }

    table->entries.assign(table_size_, nullptr);
    std::size_t filled = 0;
    while (filled < table_size_) {
      for (auto& backend : backends) {
        std::size_t pos = 0;
        do {
          pos = (backend.offset + backend.next * backend.skip) % table_size_;
          ++backend.next;
        } while (table->entries[pos] != nullptr);

        table->entries[pos] = backend.peer;
        if (++filled == table_size_) break;
      }
    }

    return table;
  }

  static HashOn ParseHashOn(std::string_view s) {
    if (s == "uri") return HashOn::kUri;
    if (s == "user_id") return HashOn::kUserId;
    if (s == "session_id") return HashOn::kSessionId;
    return HashOn::kClientIp;
  }

  const std::string& HashKey(const RequestContext& ctx) const {
    static const std::string empty;
    switch (hash_on_) {
      case HashOn::kUri:
        if (!ctx.uri.empty()) return ctx.uri;
        break;
      case HashOn::kUserId:
        if (!ctx.user_id.empty()) return ctx.user_id;
        break;
      case HashOn::kSessionId:
        if (!ctx.session_id.empty()) return ctx.session_id;
        break;
      case HashOn::kClientIp:
        break;
    }
    return ctx.client_ip.empty() ? empty : ctx.client_ip;
  }

  static std::shared_ptr<UpstreamPeer> Lookup(const LookupTable& table, uint64_t hash) {
    if (table.entries.empty()) {
      return nullptr;
    }
    return table.entries[hash % table.entries.size()];
  }

  static bool IsPrime(std::size_t num) {
    if (num < 2) return false;
    if (num == 2 || num == 3) return true;
    if (num % 2 == 0 || num % 3 == 0) return false;
    for (std::size_t d = 5; d <= num / d; d += 6) {
      if (num % d == 0 || num % (d + 2) == 0) return false;
    }
    return true;
  }

  static std::size_t NextPrime(std::size_t num) {
    if (num <= 2) return 2;
    if (num % 2 == 0) num++;
    while (!IsPrime(num)) num += 2;
    return num;
  }

  std::size_t table_size_;
  HashOn hash_on_;
  std::atomic<std::shared_ptr<const LookupTable>> table_;
  std::mutex rebuild_mutex_;
};

参考文献

  • Eisenbud et al., Maglev: A Fast and Reliable Software Network Load Balancer, NSDI 2016
  • Karger et al., Consistent Hashing and Random Trees, STOC 1997
  • Mirrokni, Thorup & Zadimoghaddam, Consistent Hashing with Bounded Loads, SODA 2018
  • gRPC Load Balancing — grpc.io/blog/grpc-load-balancing/ (Accessed: 2026-06)
  • Envoy Load Balancing — envoyproxy.io 文档,Upstream / Load Balancing (Accessed: 2026-06)

教科书(算法导论第三版)

  • Cormen, Leiserson, Rivest & Stein, Introduction to Algorithms (CLRS), 3rd/4th ed.
    • 第 11 章 Hash Tables,§11.4 Open Addressing(开放寻址、双重散列)——本文"偏好序列 = 双重散列探测序列"
    • 第 31 章 Number-Theoretic Algorithms,§31.1–31.2(模运算、GCD 与欧几里得算法)——本文"gcd(skip, M)=1 保证遍历全表"

延伸阅读(超出本文,同一领域)

  • Lamping & Veach, A Fast, Minimal Memory, Consistent Hash Algorithm (Jump Hash), 2014
  • Thaler & Ravishankar, Rendezvous (HRW) Hashing, 1998
  • Mitzenmacher, The Power of Two Choices in Randomized Load Balancing, 1996
  • Azar et al., Balanced Allocations, SIAM J. Comput. 1999
  • Google SRE Book, Ch.20: Load Balancing in the Datacenter

更多推荐