KV存储(3):分片、路由与再均衡(hash / consistent hashing)
发布于:
这一篇聚焦 KV 存储做成“多机可扩展系统”时绕不开的第一件事:数据怎么分片、请求怎么路由、扩缩容怎么再均衡。主题看起来像“hash 环”,但工程上真正决定可用性与性能的是:
- 分片粒度:一个节点承担多少个分片(token / vnode)?热点会不会被平摊?
- 路由一致性:客户端路由表与服务端实际 ownership 不一致时会发生什么?
- 迁移策略:迁移期间读写如何保证正确性?如何避免迁移把线上打爆?
- 观测与回滚:再均衡失败如何止损?如何识别“正在形成的热点”?
本文按“模型 → 机制 → 代价 → 运维指标”组织,图用 ASCII 表示。
1. 分片的目标:让系统能横向扩展且可预测
理想目标:
- 负载均衡:不同节点的 QPS、IO、空间尽量均衡
- 可扩缩容:加/减节点时只迁移一小部分数据
- 可定位:给定 key 能快速找到负责的节点集合
- 容错域可控:机架/可用区故障不会集中打到某一段 keyspace
工程上,分片方案通常必须同时满足“数据面效率”和“控制面可运维”。
2. 最简单的 hash 分片:为什么不够用
2.1 直接 node = hash(key) % N
好处:简单、路由 O(1)、无需元数据。
问题:扩缩容代价极高。
N=4: node = h(k) % 4
N=5: node = h(k) % 5
几乎所有 key 的归属都会变化 -> 全量迁移
2.2 工程结论
只要系统需要在线扩缩容、节点数量变动不可避免,就需要“增量迁移”而不是“全量重算”。
3. 一致性哈希:让迁移变成“局部变化”
3.1 hash ring 与区间归属
把 hash 空间视作 ([0, 2^m)) 的环。每个节点在环上占若干位置(token),节点负责其 token 到下一个 token 之间的区间(或反向定义)。
示意(token 简化为 0..99):
Ring: 0 -------------------------------------- 99 (wrap)
tokens:
A: 10, 60
B: 25, 80
C: 40, 90
ownership (clockwise):
(10..25] -> B
(25..40] -> C
(40..60] -> A
(60..80] -> B
(80..90] -> C
(90..10] -> A
路由规则(典型):对 key 求 hash 得到点 p,找到“环上第一个 token >= p”的节点为 owner(wrap)。
3.2 为什么能“只迁移一部分”
新增节点 D 只会插入若干 token,影响的是这些 token 相邻的少数区间:
- 旧 owner 的一部分区间被切走
- 其余区间不变
迁移量近似与新增 token 覆盖的区间大小成比例,而不是全量。
4. 虚拟节点(vnode):解决“节点不均衡”和“热点”
4.1 单 token 的问题
如果每个节点只有 1 个 token:
- 由于 token 随机,区间大小分布不均 → 负载不均
- 节点性能差异(异构硬件)无法表达
- 热点 key 落入一个大区间会拖垮单节点
4.2 vnode 的思路
每个物理节点分配很多 token(例如 256/1024 个),让区间更细粒度:
- 大数定律让区间大小更均匀
- 热点更容易被“切碎”并摊到不同节点(仍取决于 key 分布)
- 异构节点可用不同 token 数表示权重
工程提示:
- vnode 太多会增加 ring 元数据体积与查找开销
- vnode 太少会导致均衡性差
5. 路由:客户端路由 vs 服务端路由
5.1 客户端路由(client-side routing)
客户端持有 ring/token 表,直接把请求发到目标节点。
优点:
- 少一跳,延迟更低
- 节点不做转发,降低负载
缺点(关键):
- 路由表可能过期:拓扑变化、迁移中、节点抖动
- 需要传播与一致性协议:至少保证“最终收敛”
5.2 服务端路由(server-side routing)
客户端发给任意入口(或 coordinator),由服务端转发到 owner。
优点:
- 客户端简单
- 路由表一致性由服务端承担
缺点:
- 多一跳
- coordinator 可能成为瓶颈(除非做无状态水平扩展)
5.3 常见折中:smart client + server fallback
客户端按本地路由表直连;若节点发现“请求不属于本节点”,返回重定向或进行一次转发:
Client -> NodeX (wrong owner)
NodeX -> returns "moved to NodeY" (or forwards once)
Client updates route table
工程落点是“错误路径的成本是否可控”:如果路由频繁过期,会形成大量额外 RTT 与转发压力。
6. 扩缩容与再均衡:迁移不是“复制文件”那么简单
6.1 再均衡的三个阶段(抽象)
1) 规划:哪些 token 区间从哪些节点迁到哪些节点 2) 数据迁移:把区间内的数据复制到新 owner 3) 切换 ownership:让新路由生效,并清理旧副本
关键难点集中在阶段 2/3:迁移期间有读写并发,必须定义一致性语义。
6.2 迁移期间的读写语义(两类典型做法)
做法 A:双写 + 最终收敛
- 在迁移窗口内,写入同时写新旧 owner(或写到 quorum 覆盖两侧)
- 切换后,新 owner 一定包含最新数据
代价:写放大,且需要对失败重试、幂等做严格约束。
做法 B:单写 + 转发/重试
- ownership 切换前写旧 owner
- 切换后写新 owner
- 迁移期间若请求打到错误侧,由服务端转发或客户端重试
代价:错误路径多、尾延迟抖动,且要处理“切换瞬间的并发竞态”。
6.3 一个可落地的“切换点”模型
在控制面定义一个 epoch(配置版本):
config_epoch = 42
ownership_map(epoch=42): token ranges -> owners
write(key, value, epoch=42):
node rejects if local epoch < 42
node accepts only if it is owner under epoch=42
好处:把“谁是 owner”变成可验证的条件,避免旧配置写入新 owner 或反之导致不一致。
7. 热点问题:分片解决不了“极端倾斜”
一致性哈希擅长解决“均匀随机 key”的负载均衡,但真实业务常见:
- 某些 key(或 key 前缀)超级热
- 某些租户/分区规模远大于其他
对策通常需要超出分片本身:
- key 打散:把热点 key 拆成多个子 key(需要应用语义配合)
- 分层缓存:把热点读挡在 cache
- 热点检测 + 动态复制:对热点分片临时增加副本或读副本
- 限流:保护系统稳定性
8. 运维与观测:再均衡是否安全,靠指标说话
建议最少监控:
- 节点维度:QPS、CPU、磁盘带宽、读写延迟(P50/P99/P999)、错误率
- 分片维度:每 token/分片的 key 数、数据量、读写 QPS、热点 topN
- 迁移维度:迁移速率(MB/s)、迁移 backlog、失败重试次数、切换次数
- 路由错误:重定向/转发比例,配置 epoch 不匹配次数
再均衡的常见失败模式:
- 迁移太快导致后台 IO 抢占前台(尾延迟飙升)
- 迁移太慢导致长时间双写/错误路径,整体成本过高
- 热点未被切碎,迁移后仍集中在某节点
9. 小结
分片与路由的“正确打开方式”不是画一个 hash 环,而是把它当成一套工程系统:
- vnode/权重解决均衡与异构
- 客户端/服务端路由决定错误路径成本
- 迁移与切换点(epoch)决定一致性与尾延迟
- 热点需要额外手段处理
下一篇会把“分片之后的复制与一致性”补齐:读写如何走 quorum、写入如何在副本间收敛、以及常见的故障与修复路径。