Skip to content

一致性哈希减少了迁移,没有解决热点 ​

缓存集群从 10 台扩到 11 台,按 hash(key) % N 分片。扩容那一刻,命中率几乎掉到零,所有请求打到了数据库上。改成一致性哈希之后,扩容平稳了,可某一台缓存的 CPU 总是比其他的高一倍;后来又发现,一个明星用户的主页请求能把它所在的那台单独压垮。

一致性哈希是分布式系统里最常被提到的算法之一,它解决的是一个很具体的问题:节点数量变化时,尽量少地移动数据。它不直接解决负载均衡,更不解决热点。很多问题来自把这三件事混为一谈。

本文用 10 万个键,在 10 个节点上比较取模、哈希环、虚拟节点、有界负载四种做法,再加入第 11 个节点,统计负载分布和迁移比例;最后放一个占 20% 请求的热点键。所有计算都是确定性的,实验见文末。

做法最多 / 平均扩容迁移取模1.02 倍90.8%哈希环,每节点 1 个点2.26 倍12.2%虚拟节点 10 个1.46 倍7.2%虚拟节点 100 个1.19 倍8.3%虚拟节点 1000 个1.05 倍9.4%虚拟节点 10 个 + 有界负载 c=1.101.10 倍12.9%理想迁移 1/11 ≈ 9.1%热点:一个键占 20% 的请求 → 所在节点承担 27.7%,是平均的 2.77 倍热点用本地缓存、拆分热点键、限流处理,与分片方式无关
图 1 · 实测(JDK 25.0.4.1,10 万个键):取模分布最均匀,扩容时迁移 90.8%;哈希环把迁移降到 1/11 左右,但每个节点只有一个点时最多的节点是平均的 2.26 倍;虚拟节点越多越均匀;有界负载直接压住上限,代价是更多迁移。所有做法均衡的都是键的个数,一个占 20% 请求的键仍让所在节点承担 27.7% 的请求。

一、取模:负载很均匀,扩容几乎全部迁移 ​

text
取模:10 个节点 最多 10242、最少 9723,最多是平均的 1.02 倍;加入第 11 个节点后迁移 90.8%

hash(key) % N 分布非常均匀,问题在 N 变化的时候:除数从 10 变成 11,绝大多数键的余数都变了,90.8% 的键换了节点。对缓存来说,这意味着扩容瞬间几乎所有缓存都失效;对存储来说,意味着几乎所有数据都要搬家。

二、哈希环:迁移少了,负载不均了 ​

text
哈希环(每个节点 1 个点):最多 22569、最少 577,最多是平均的 2.26 倍;加节点迁移 12.2%(理想值 9.1%)

哈希环把节点和键都哈希到同一个环上,每个键归属于顺时针方向的第一个节点。加入一个节点时,只有它和前一个节点之间的那段键会移动过来,理想情况下是 1/11,约 9.1%。

但每个节点只在环上占一个点时,各节点负责的弧长完全由哈希值的随机位置决定,差别非常大:最多的节点是平均的 2.26 倍,最少的只有 577 个键。开头那台 CPU 总是高一倍的缓存,就是这个原因。

三、虚拟节点:用更多的点换均匀 ​

text
虚拟节点 10 个:最多是平均的 1.46 倍;加节点迁移 7.2%
虚拟节点 100 个:最多是平均的 1.19 倍;加节点迁移 8.3%
虚拟节点 1000 个:最多是平均的 1.05 倍;加节点迁移 9.4%

让每个物理节点在环上占多个点(虚拟节点),弧长的随机差异被平均掉,负载越来越接近均匀。代价是环更大(查找需要在更多点中二分)、节点加入时要计算更多的位置。迁移比例都在理想值 9.1% 附近波动,与负载是否均匀有关:新节点分到的份额越接近 1/11,迁移量越接近理想值。

虚拟节点还有一个用途:给配置不同的机器分配不同数量的虚拟节点,按容量分配负载。

四、有界负载:直接限制上限 ​

text
虚拟节点 10 个 + 有界负载 c=1.25:最多是平均的 1.25 倍;加节点迁移 9.2%
虚拟节点 10 个 + 有界负载 c=1.10:最多是平均的 1.10 倍;加节点迁移 12.9%

有界负载的一致性哈希给每个节点设一个容量上限(平均负载的 c 倍),键落到已满的节点时,顺着环找下一个未满的节点。虚拟节点不多时,它能把最大负载直接压到 c 倍以内;c 越小越均匀,但越多的键被挤到「不是自己首选」的节点上,节点变化时连锁移动的键也越多(c=1.10 时迁移 12.9%)。

五、热点:所有这些都按键的个数均衡 ​

text
一个键占 20% 的请求:它所在节点承担 27.7% 的请求,是平均的 2.77 倍;虚拟节点和有界负载按键的个数均衡,挡不住单个键的热度

前面所有的做法,均衡的都是键的个数。但请求不是均匀分布在键上的:一个明星用户、一个爆款商品、一个热门话题,单个键就能占到总请求的相当一部分。一个键只能落在一个节点上(或者一组副本上),无论环怎么设计,那个节点都要承担它的全部请求。

热点要用别的办法:

  • 本地缓存:在调用方进程内缓存热点键,大部分请求不再到达分片节点。
  • 拆分热点键:把一个键复制成多个副本键(key#1…key#8),读请求随机选一个,分散到多个节点;写入时要更新所有副本。
  • 热点探测:统计访问频率,自动对超过阈值的键启用上面两种做法。
  • 限流:保护那个节点不被压垮,至少不影响其他键。

热点在一个节点内部的表现(例如锁竞争、单线程瓶颈)还需要单独处理,Redis 的单线程模型下尤其明显。

六、怎样选 ​

需求做法
节点数量固定、几乎不变取模最简单,分布也最均匀
节点会增减,要少迁移一致性哈希 + 足够多的虚拟节点(几百个)
节点容量不同按容量分配虚拟节点数量
严格限制单节点负载有界负载,接受更多的迁移
单个键的请求过多本地缓存、拆分热点键、限流,与分片方式无关

另一个常被忽略的选择是预分片:先把数据分成固定数量的槽(例如 Redis Cluster 的 16384 个),再把槽分配给节点。扩容就是移动一部分槽,迁移量可控,分配也可以手动调整。它把「键到槽」和「槽到节点」分成两层,比一致性哈希更容易运维。

七、检查清单 ​

  • 先确认要解决的是迁移、负载均衡还是热点,三者用不同的办法。
  • 使用一致性哈希时配置足够多的虚拟节点,并检查实际的负载分布。
  • 需要严格上限时考虑有界负载,评估它带来的额外迁移。
  • 单独监控每个键的访问频率,热点用本地缓存、拆分或限流处理。
  • 节点数量变化频繁、需要人工控制时,考虑预分片。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权