一致性哈希到底省了多少搬迁?本机实测 3 轮:取模 6→7 搬 85.8%,V=128 只搬 14.3%,虚拟节点太少会被倾斜骗

摘要

"扩一个节点会搬多少数据"这个问题,多数团队的回答来自直觉:取模分片是"会重排吧",一致性哈希是"只搬一点"。本文把两种分片在本机跑成可复现的数字:20000 个 key、hashlib.blake2b 定位环上位置、虚拟节点 1/4/16/64/128/256 六档,Python 3.11.5 纯标准库,独立跑 3 轮(seed=1/2/3,每轮换一组节点名后缀)。结果:6→7 扩容时取模搬迁 17154 个 key(85.8%) ,一致性哈希(V=128)搬 2855~2995 个(14.3%~15.0%) ,理论下限 14.3%;把分片号当规则分组号用时,一次 6→7 改组要重写 5159 条路由项(86.0%) 。另一个反直觉的点:虚拟节点很少时迁移率可能更低(V=1 三轮 12.7%~34.6%),但负载倾斜很严重(最大分片达到均值的 3.14 倍 ,同轮最大与最小相差 25 倍)------迁移率和均匀度是两个独立目标,只看一个必然被骗。最后一个坑最贵:用 Python 内置 hash() 做分片,同一批 key 在 PYTHONHASHSEED 不同的进程里归属完全不同。


一、实验边界

  • 全部只用标准库(hashlib.blake2b、bisect),没有第三方依赖;哈希是确定的,同一 key 在任何机器、任何进程都落在同一点,所以轮次之间的差异只来自节点命名后缀(每轮随机换一个后缀,模拟真实环境里 Pod 名/主机名各不相同------虚拟节点落点因此变化,三轮才给出区间而不是同一个数)。
  • key 是 20000 个订单号 ORD-2026-000001...,规则 key 是 6000 个 R-00000...,都是脚本生成的合成数据。"搬迁比例"这个指标可以直接外推,"耗时"不可以:本机单进程 CPython、没有网络、没有真实 value,环形查找的绝对耗时只能当量级参考。
  • 负载均匀度用 5000 key 样本测量(三轮一致的部分是 key 集,变化的是环)。
  • 文中没有出现未在本机运行的 Java/Go 片段。
python 复制代码
import hashlib
from bisect import bisect_right

def make_ring(nodes, vnodes, salt):
    """每个物理节点放 vnodes 个虚拟节点,落点由 名称#序号#salt 哈希决定。"""
    ring = []
    for node in nodes:
        for v in range(max(vnodes, 1)):
            h = hashlib.blake2b(f"{node}#{v}#{salt}".encode(), digest_size=8).digest()
            ring.append((int.from_bytes(h, "big"), node))
    ring.sort()
    return ring, [p for p, _ in ring]

def ch_owner(key, robj, H):
    ring, points = robj
    idx = bisect_right(points, H[key])   # 找顺时针方向的第一个虚拟节点
    if idx == len(ring):
        idx = 0                          # 环尾回绕到环首,这一步不能漏
    return ring[idx][1]

二、迁移量:扩容/缩容时到底搬多少

理论下限很好算:扩容 N→N+k 时下界是新增容量占比 k/(N+k);缩容 N→N-1 时下界是 1/N(只有被下线节点上的 key 必须走)。20000 个 key,三轮实测:

场景 取模搬迁 一致性哈希 V=128 理论下限
4→5 扩容 16047(80.2%) 40774157(20.4%20.8%) 20.0%
6→7 扩容 17154(85.8%) 28552995(14.3%15.0%) 14.3%
8→9 扩容 17709(88.5%) 22492462(11.2%12.3%) 11.1%
4→6 扩容 13336(66.7%) 68927196(34.5%36.0%) 33.3%
6→8 扩容 14979(74.9%) 48775053(24.4%25.3%) 25.0%
6→5 缩容 16712(83.6%) 33883866(16.9%19.3%) 16.7%
8→7 缩容 17532(87.7%) 23662560(11.8%12.8%) 12.5%

三点解读:

  1. 取模的搬迁比例接近"全部重排" :6→7 时 85.8% 的 key 换属主,和 1-1/7 的随机期望一致。它不是"部分变化",而是"除少数巧合外全变"。
  2. 一致性哈希贴着下界跑:V=128 时三轮实测与理论下限的差在 0~1.2 个百分点(6→7 为 14.3%~15.0% vs 14.3%;4→5 为 20.4%~20.8% vs 20.0%)。多出来的那部分是虚拟节点分布不均导致的,见第三节。
  3. "一次加 2 个"与"分两次各加 1 个"的差别在抖动窗口数:4→6 一次到位实测搬 34.5%~36.0%(下界 33.3%),4→5 搬 20.4%~20.8%(下界 20.0%)。分两步走要经历两轮再均衡与两次预热,一次到位只有一轮;本文没有单独测 5→6 这一步,所以只对"窗口数从 2 变 1"下结论,不宣称总搬迁量更省。

#mermaid-svg-unWybgG5jFqEyasT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-unWybgG5jFqEyasT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-unWybgG5jFqEyasT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-unWybgG5jFqEyasT .error-icon{fill:#552222;}#mermaid-svg-unWybgG5jFqEyasT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-unWybgG5jFqEyasT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-unWybgG5jFqEyasT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-unWybgG5jFqEyasT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-unWybgG5jFqEyasT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-unWybgG5jFqEyasT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-unWybgG5jFqEyasT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-unWybgG5jFqEyasT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-unWybgG5jFqEyasT .marker.cross{stroke:#333333;}#mermaid-svg-unWybgG5jFqEyasT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-unWybgG5jFqEyasT p{margin:0;}#mermaid-svg-unWybgG5jFqEyasT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-unWybgG5jFqEyasT .cluster-label text{fill:#333;}#mermaid-svg-unWybgG5jFqEyasT .cluster-label span{color:#333;}#mermaid-svg-unWybgG5jFqEyasT .cluster-label span p{background-color:transparent;}#mermaid-svg-unWybgG5jFqEyasT .label text,#mermaid-svg-unWybgG5jFqEyasT span{fill:#333;color:#333;}#mermaid-svg-unWybgG5jFqEyasT .node rect,#mermaid-svg-unWybgG5jFqEyasT .node circle,#mermaid-svg-unWybgG5jFqEyasT .node ellipse,#mermaid-svg-unWybgG5jFqEyasT .node polygon,#mermaid-svg-unWybgG5jFqEyasT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-unWybgG5jFqEyasT .rough-node .label text,#mermaid-svg-unWybgG5jFqEyasT .node .label text,#mermaid-svg-unWybgG5jFqEyasT .image-shape .label,#mermaid-svg-unWybgG5jFqEyasT .icon-shape .label{text-anchor:middle;}#mermaid-svg-unWybgG5jFqEyasT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-unWybgG5jFqEyasT .rough-node .label,#mermaid-svg-unWybgG5jFqEyasT .node .label,#mermaid-svg-unWybgG5jFqEyasT .image-shape .label,#mermaid-svg-unWybgG5jFqEyasT .icon-shape .label{text-align:center;}#mermaid-svg-unWybgG5jFqEyasT .node.clickable{cursor:pointer;}#mermaid-svg-unWybgG5jFqEyasT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-unWybgG5jFqEyasT .arrowheadPath{fill:#333333;}#mermaid-svg-unWybgG5jFqEyasT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-unWybgG5jFqEyasT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-unWybgG5jFqEyasT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-unWybgG5jFqEyasT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-unWybgG5jFqEyasT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-unWybgG5jFqEyasT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-unWybgG5jFqEyasT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-unWybgG5jFqEyasT .cluster text{fill:#333;}#mermaid-svg-unWybgG5jFqEyasT .cluster span{color:#333;}#mermaid-svg-unWybgG5jFqEyasT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-unWybgG5jFqEyasT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-unWybgG5jFqEyasT rect.text{fill:none;stroke-width:0;}#mermaid-svg-unWybgG5jFqEyasT .icon-shape,#mermaid-svg-unWybgG5jFqEyasT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-unWybgG5jFqEyasT .icon-shape p,#mermaid-svg-unWybgG5jFqEyasT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-unWybgG5jFqEyasT .icon-shape .label rect,#mermaid-svg-unWybgG5jFqEyasT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-unWybgG5jFqEyasT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-unWybgG5jFqEyasT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-unWybgG5jFqEyasT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 一致性哈希环:新增节点只吃掉一段弧
环 0..2^64
新增节点插入2个虚拟点
仅落在新弧段上的 key 换属主
取模:6 分片 → 7 分片
ORD-000123
h%6=3
h%7=5 变了

三、虚拟节点数:均匀度和迁移率是两件事

6 个物理节点、5000 key 测负载,再用同环比 6→7 的迁移率:

每节点虚拟节点数 V 最小分片 最大分片 最大/均值(三轮区间) 6→7 迁移率(三轮区间)
1(不用虚拟节点) 103~144 1404~2618 1.685~3.142 12.7%~34.6%
4 199~411 1250~1590 1.500~1.908 5.5%~11.8%
16 438~692 1014~1241 1.217~1.489 11.7%~17.9%
64 645~784 902~965 1.082~1.129 12.8%~15.3%
128 688~721 903~1009 1.084~1.211 14.3%~15.0%
256 748~793 871~952 1.045~1.142 13.8%~15.2%

这张表是本文最想让人记住的部分:

  • V=1 不是"迁移少",是"完全不可控" :三轮迁移率 12.7%、34.6%、26.9%,跨度 22 个百分点;同时最大分片达到均值的 3.14 倍 (seed=1 那一轮:最大 2618、最小 103,而均值是 833,最大是最小的 25 倍)。用 V=1 的环做缓存分片,你会同时得到热点节点和随机的再均衡代价。
  • V=4 出现了 5.5% 的漂亮迁移率 ,但那是运气:新节点插入的两个虚拟点恰好落在小分片的弧段里。同一轮它的负载倾斜仍是 1.5 倍。迁移率低于理论下界是假象------低的那部分 key 只是换了个更挤的属主。
  • 均匀度随 V 单调改善,迁移率在中段抖动:V≥64 后倾斜稳定在 1.08~1.13,迁移率稳定在 13%~15%(即贴着 14.3% 下界)。V 从 128 到 256,倾斜从 1.084~1.211 收到 1.045~1.142,改善已经很 marginal,但环上点数翻倍、二分查找的数组也翻倍。

取舍建议:V=64~128 是本机数据里"倾斜 ≤1.13 且迁移率贴下界"的区间。生产上真正的约束是环的内存占用和建环耗时(节点数 × V × 集群分片数),不是理论精度。

四、把迁移率换算成命中率

假设变更后所有换属主的 key 都要回源重建(缓存全命中 → 扩容瞬间):

6→7 扩容 仍命中的 key 需回源的 key
取模 2846(14.2%) 17154(85.8%)
一致性哈希 V=1 1308717460(65.4%87.3%) 2540~6913(波动 21.9 个百分点)
一致性哈希 V=16 1641617664(82.1%88.3%) 2336~3584
一致性哈希 V=128 1700517145(**85.0%85.7%**) 2855~2995

一致性哈希把"扩容后命中率跌到 14%"变成"跌到 15% 需要回源"------注意这两行说的是同一件事的两面:取模后仍有 14.2% 命中,一致性哈希后仍有 85.0% 命中。V=1 命中率波动最大(65.4%~87.3%),意味着同一段代码在两个集群上的扩容表现可以差 20 多个百分点,这比"命中率低"更可怕,因为它不可预测、容量预案做不了。

五、规则分组路由:一次改组,86% 的路由项要重写

分片号常被当成"业务分组号"复用------规则引擎里尤其常见:用 hash(rule_id) % 组数 把规则分到 N 个执行组,各组独立热更新。6000 条规则,改组数时实测:

变更 取模路由需改写的条目 一致性哈希路由需改写
组数 6→7 5159 条(86.0%) 834~924 条(13.9%~15.4%)
组数 6→5 4930 条(82.2%) ---

这意味着一次"从 6 组改到 7 组"的规则发布,在取模路由下等价于重写整张路由表:所有组都要重新加载规则、重新编译、重新预热。规则引擎热更新最常见的事故就出在这里------旧快照还在执行、新路由已经生效,同一批规则在两个组里各执行一次(重复动作),或者两边都以为对方负责(漏执行)。一致性哈希把这个动作缩到 15% 的条目,改写窗口和需要加锁的范围都小一个量级。

六、热路径成本:环形查找不是免费的

5000 次查属主,time.perf_counter() 实测(三轮区间):

做法 耗时
取模 h % 6 0.94~1.00ms,即 0.19~0.20 µs/次
环上二分(768 点,bisect_right) 3.67~3.90ms,即 0.73~0.78 µs/次
预计算 20000 个 key 的属主表(dict) 3.96~4.74ms(一次性)

环形查找是取模的 3.8~4.0 倍 ,绝对值 0.78 µs------单次可忽略,但如果在每次请求的热路径上对成千上万个 key 现算,就成了实际开销。对策就是第三行:变更时一次性算出属主表(4ms 级),请求路径只做 dict 查表,把环形查找留给再均衡那一瞬间。这个结构还顺带解决了灰度问题:属主表是可 diff 的数据,能预先算好新表、对比出受影响的 key 集合、按批切流。

七、最贵的坑:内置 hash() 不可复现

同一批 key,在不同 PYTHONHASHSEED 的进程里跑 hash(k) % 6(本机 subprocess 起真实子进程实测):

PYTHONHASHSEED 前 4 个 key 的分片归属
0 5,1,4,4
1 0,3,2,2
2 0,4,4,2
random 2,0,2,0 / 0,2,4,5 / 0,5,1,0(每次启动都不同)
改用 blake2b 3,2,3,3(任何进程、任何启动参数都一样)

这是多副本服务最阴的事故来源:网关 A 用它的 hash、网关 B 用它的 hash,同一个订单被判给不同分片;写走 3 号读走 5 号,缓存永远不命中,而所有单测都在同一个进程里跑,全绿。CPython 对 str 的 hash() 默认加盐,每个进程启动时盐不同,这是设计行为不是 bug。

同理,id()、dict 遍历顺序(历史上)、以及任何非确定性来源都不能进分片函数。分片函数必须满足:纯函数、跨语言可复现、版本可固定。

八、避坑指南

  1. 不要用内置 hash() 做分片 。实测同一批 key 在 PYTHONHASHSEED=0/1/2/random 下归属完全不同。用 blake2b/md5/crc32/MurmurHash3 这类明确、可复现、跨语言一致的哈希,并把实现版本写进配置。
  2. 不要用 hash(key) % N 做需要扩容的分片 。6→7 实测 85.8% 的 key 换属主。要留一致性哈希或范围分片;N 一旦可变,取模就是定时炸弹。
  3. 别只用"迁移率"评价虚拟节点数 。V=1 的三轮迁移率 12.7%~34.6%,最低那次比 V=128 还低,但最大分片达到均值的 3.14 倍(同轮最大与最小相差 25 倍)。必须同时看倾斜(最大/均值)和迁移率的波动区间,不看单轮最优值。
  4. 环尾回绕别漏。二分结果越界时必须回绕到环首,漏了这个分支,落在最大虚拟节点之后的 key 会全部找不到属主(或被丢到默认分片),表现为"少数 key 永远不命中",最难查。
  5. 热路径不要现算环。实测环形二分是取模的 3.8~4.0 倍。变更时一次性预算属主表(20000 key 3.96~4.74ms),请求路径只查表。
  6. 改组数=改数据分布,要按发布流程走。规则分组路由 6→7 要改写 86.0% 的路由项;这种变更必须有单调版本号、旧快照引用计数归零后再回收、以及"同一规则同一时刻只属一个组"的一致性检查,否则就是重复执行或漏执行。
  7. 一次扩到位。4→6 搬 34.5%~36.0%,而 4→5 再 5→6 两轮各搬一次。分步扩容的累计搬迁量和累计抖动窗口都比一次到位大。
  8. 虚拟节点数取 64~128 起步,别取 1。本实测里 V≥64 才把倾斜压到 1.08~1.13;V 再往上收益 marginal 但环更大、建环更慢。

九、结论

  • 取模分片在节点数变化时等于全量重排(实测 85.8% 搬迁,命中率只剩 14.2%);一致性哈希在 V=128 下能把搬迁压到 14.3%~15.0%,与理论下界 14.3% 贴合。
  • 虚拟节点的作用不是"减少迁移",而是同时控制迁移率和负载倾斜、并把波动收敛:V=128 时命中率稳定在 85.0%~85.7%,而 V=1 时同一操作的结果在 65.4%~87.3% 之间跳。
  • 分片函数必须是跨进程、跨语言可复现的纯哈希。内置 hash() 加盐这一条,足以让"扩容后缓存不命中"这种问题查上几周。
  • 需要频繁改分组数的场景(规则引擎执行组、多租户路由),一致性哈希的收益主要体现在路由表改写量(86.0% → 13.9%~15.4%)和可控的灰度窗口,而不只是命中率。
  • 如果既要多节点伸缩、又要灰度发布,最优结构是"环上定位 + 属主表落地":环负责算,表负责查,版本号负责切。

完整脚本与三轮运行输出见 demos/shard_rebalance_lab.py、demos/shard_rebalance_lab.transcript.txt。

相关推荐
code_slave(码畜)1 小时前
微服务架构落地:OpenFeign 服务远程调用 + 线程池隔离架构实战 —— 超时控制、重试机制、线程池隔离,防雪崩
spring boot·spring cloud·微服务·云原生·架构·openfeign
Yan-英杰1 小时前
【论文精读】 基于 TensorFlow 对卷积神经网络的优化研究:异构式 CPU+GPU 协同计算 + ReLU-Swish 激活函数
python·卷积神经网络
小天源1 小时前
智叟云集定时采集与消息推送架构——实现可恢复的订阅通知流程
架构·sass·权限管理·多租户·服务商 api·ai采集
鶴哥只手遮天1 小时前
Spring Framework 7.0.9 深度剖析-03-IoC 容器核心:BeanFactory 与 BeanDefinition 体系
java·后端·开源
tianbin9111 小时前
大模型流式输出异常排查,API 中转站层面的问题定位思路
后端·云计算
ydjs678899-bot1 小时前
如何在Java中打印pdf静默打印和打印对话框选择
java·python·pdf
Shaoshing1 小时前
存储引擎与架构
java·开发语言·架构
whyutianict_vv1 小时前
AI 大模型技术架构解析:从推理链路到训练流程
人工智能·ai·架构·开发
鶴哥只手遮天1 小时前
Spring Framework 7.0.9 深度剖析-01-整体架构与设计哲学
java·后端·开源