摘要
"扩一个节点会搬多少数据"这个问题,多数团队的回答来自直觉:取模分片是"会重排吧",一致性哈希是"只搬一点"。本文把两种分片在本机跑成可复现的数字: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% |
三点解读:
- 取模的搬迁比例接近"全部重排" :6→7 时 85.8% 的 key 换属主,和
1-1/7的随机期望一致。它不是"部分变化",而是"除少数巧合外全变"。 - 一致性哈希贴着下界跑:V=128 时三轮实测与理论下限的差在 0~1.2 个百分点(6→7 为 14.3%~15.0% vs 14.3%;4→5 为 20.4%~20.8% vs 20.0%)。多出来的那部分是虚拟节点分布不均导致的,见第三节。
- "一次加 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 遍历顺序(历史上)、以及任何非确定性来源都不能进分片函数。分片函数必须满足:纯函数、跨语言可复现、版本可固定。
八、避坑指南
- 不要用内置
hash()做分片 。实测同一批 key 在PYTHONHASHSEED=0/1/2/random下归属完全不同。用blake2b/md5/crc32/MurmurHash3这类明确、可复现、跨语言一致的哈希,并把实现版本写进配置。 - 不要用
hash(key) % N做需要扩容的分片 。6→7 实测 85.8% 的 key 换属主。要留一致性哈希或范围分片;N一旦可变,取模就是定时炸弹。 - 别只用"迁移率"评价虚拟节点数 。V=1 的三轮迁移率 12.7%~34.6%,最低那次比 V=128 还低,但最大分片达到均值的 3.14 倍(同轮最大与最小相差 25 倍)。必须同时看倾斜(最大/均值)和迁移率的波动区间,不看单轮最优值。
- 环尾回绕别漏。二分结果越界时必须回绕到环首,漏了这个分支,落在最大虚拟节点之后的 key 会全部找不到属主(或被丢到默认分片),表现为"少数 key 永远不命中",最难查。
- 热路径不要现算环。实测环形二分是取模的 3.8~4.0 倍。变更时一次性预算属主表(20000 key 3.96~4.74ms),请求路径只查表。
- 改组数=改数据分布,要按发布流程走。规则分组路由 6→7 要改写 86.0% 的路由项;这种变更必须有单调版本号、旧快照引用计数归零后再回收、以及"同一规则同一时刻只属一个组"的一致性检查,否则就是重复执行或漏执行。
- 一次扩到位。4→6 搬 34.5%~36.0%,而 4→5 再 5→6 两轮各搬一次。分步扩容的累计搬迁量和累计抖动窗口都比一次到位大。
- 虚拟节点数取 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。