Redis Cluster 从六节点扩到九节点,运维执行完 reshard 之后告诉业务方"扩容完成"。业务方观察了一周,发现吞吐几乎没变,某些请求反而更慢了。
问题不在迁移有没有做完,而在迁移完成不等于负载均衡。槽是迁移过去了,但热键跟着槽走,而热点往往集中在少数几个键上——槽分散了,热点没有分散。
1. 槽迁移做的是什么
| 动作 | 结果 | 不改变什么 |
|---|---|---|
| 分配槽到新节点 | 键空间被重新划分 | 键的访问热度 |
| 迁移键数据 | 新节点持有数据 | 客户端路由缓存 |
| 更新集群元数据 | 节点间达成一致 | 应用侧连接池分布 |
槽的数量是固定的(16384),迁移只改变"槽归谁",不改变"槽有多少"。所以扩容的收益上限由能否把槽均匀分给更多节点决定:节点数越多,每节点槽越少,单节点承担的热键概率下降——但如果热键本身没被拆分,它所在的节点仍然是瓶颈。
第三行最容易被忽略:客户端会缓存槽映射,迁移后不刷新就一直打旧节点,旧节点返回 MOVED 后客户端才更新。迁移期间大量 MOVED 与 ASK 重定向会让延迟上升,而这个上升常被误读为"新节点性能差"。
2. 四个会让扩容失效的原因
| 原因 | 机制 | 观察方式 |
|---|---|---|
| 热键集中 | 少数键占了大部分访问 | 按 key 前缀统计命中 |
| 槽分配不均 | 迁移了数量但没均衡权重 | 比较各节点 keys 与 QPS |
| 客户端连接未分散 | 池按旧拓扑建立 | 看各节点连接数 |
| 单键大 value | 迁移慢且该键始终在一个节点 | --bigkeys |
四个原因里有三个属于"分布问题"而不是"容量问题"。这也是为什么扩容常被视为无效:它只在容量问题上是解法。
第一项最常见。扩容解决的是"槽分布",不解决"键热度"。如果一个键占了 30% 的访问,无论它在哪个节点,那个节点都会是瓶颈。
3. 热键的三种处置
| 处置 | 效果 | 代价 |
|---|---|---|
| 本地缓存 | 挡住绝大多数的读 | 一致性窗口 |
| 键拆分(加后缀分片) | 把热度分散到 N 个键 | 读时要合并,写时要扇出 |
| 读写分离(副本承担读) | 读压力分散 | 副本延迟带来不一致 |
第三种(副本承担读)在集群里有个前提:读副本要显式开启(READONLY),否则客户端仍只读主节点。很多人以为加了副本就自动分担读,实际上没有这行配置,副本的读容量完全没被用上。
// 让客户端可以读副本:连接建立后必须显式声明
redis-cli -h 10.0.0.12 -p 6379 -c readonly
// 或用客户端配置 readFrom=REPLICA / SLAVE,按延迟或轮询选择
// 注意:副本读到的可能是旧数据,业务必须能接受这个窗口
第一项在只读热点上效果最好,第二项适合计数与累加类。
// 键拆分:把单键热度分散到 N 个分片
int shard = ThreadLocalRandom.current().nextInt(8);
String key = "hot:counter:" + shard; // 8 个物理键摊到不同槽
redis.incr(key);
// 读时合并 8 个分片;写时只写一个分片,成本被摊薄
long total = IntStream.range(0, 8)
.mapToLong(i -> Long.parseLong(redis.get("hot:counter:" + i)))
.sum();
4. 迁移期间的正确姿势
| 阶段 | 操作 | 风险 |
|---|---|---|
| 迁移前 | 记录各节点键数与 QPS 基线 | 无基线则无法判断是否均衡 |
| 迁移中 | 按槽分批,控制速率 | 大批量迁移造成延迟尖刺 |
| 迁移中 | 观察 MOVED/ASK 比例 |
客户端未刷新会持续重定向 |
| 迁移后 | 对比各节点 QPS 与延迟 | 只看"迁移完成"会漏掉不均 |
| 迁移后 | 检查大键与热键是否转移 | 热点可能只是换了个节点 |
// 迁移前后各取一次分布:均衡与否要用数据说话
for n in $(redis-cli -c cluster nodes | awk '{print $2}' | cut -d@ -f1); do
echo -n "$n keys="
redis-cli -h ${n%:*} -p ${n#*:} dbsize
done
// 各节点 keys 相差超过 20% → 槽分配不均,需要 reshard 而不是"完成"
5. 什么时候该扩,什么时候不该
| 症状 | 扩容有效 | 更该做的事 |
|---|---|---|
| 各节点 QPS 均匀且都高 | 是 | 扩节点 |
| 单节点 QPS 高、其余低 | 否 | 处理热键或槽不均 |
| 各节点 CPU 高但 QPS 不高 | 部分 | 查大键与慢命令 |
| 延迟高但资源低 | 否 | 查网络与客户端 |
| 内存接近上限 | 是(加内存优先) | 扩内存而非扩节点 |
第二行是扩容最常见的无效场景:问题在分布而不是总量。
迁移还应当有明确的前置检查与事后核对,这两张单子比迁移脚本本身更重要:
// 迁移前:五项,缺一项就别开始
1. 各节点内存余量 ≥ 30%(迁移期间双份数据共存)
2. 网络带宽基线(迁移速率上限由它决定)
3. 业务低峰窗口与负责人
4. 客户端拓扑刷新机制已验证
5. 回退方案:槽可迁回,且试过一次
// 迁移后:四项核对
1. 各节点 keys 与 QPS 差异 ≤ 20%
2. MOVED/ASK 比例回落到基线
3. 大键与热键未集中到单节点
4. 延迟 P99 回到迁移前水平
6. 排错:五种"扩容了没效果"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 吞吐没变 | 热键或槽不均 | 看 key 分布与各节点 QPS |
| 延迟反而上升 | 客户端持续重定向 | 刷新拓扑、检查客户端版本 |
| 某节点内存仍满 | 大键未迁移或集中 | --bigkeys 定位后拆分 |
| 新节点 CPU 低 | 流量没打过去 | 看连接分布与槽映射 |
| 迁移完成后再次不均 | 新键持续写入集中 | 检查键命名是否带时间前缀 |
这类问题的通用形态是"键名的高位包含低基数的维度"。日期、状态、类型这些取值少的维度放在键名前部,会让同一取值的所有键落进同一段槽区间。可行做法是把一个高基数维度(如用户 ID 的后几位)提到前面当作散列前缀。
第五项很实际:如果键名带日期前缀(如 stats:20261110),当天的写入全部集中在一个槽。这类设计在单实例下没问题,在集群下就是天然热点。
// 键名分布检查:是否存在明显的时间或 ID 前缀集中
redis-cli --scan --pattern 'stats:*' --count 1000 | cut -d: -f2 | sort | uniq -c | sort -rn | head
// 单日前缀占比过高 → 键设计需要在前面加散列前缀
7. 边界:什么情况不必关心均衡
| 场景 | 为什么 | 做法 |
|---|---|---|
| 数据量小、单节点可承载 | 不需要集群 | 用主从即可 |
| 访问本就均匀(按用户 ID 分片) | 天然均衡 | 迁移后简单核对 |
| 只读为主且有本地缓存 | 热点已被挡住 | 关注缓存命中率 |
| 短生命周期缓存(分钟级) | 不均的影响被时间摊平 | 不做精细迁移 |
还有一条运维边界的提醒:槽迁移本身是高风险操作。它涉及数据搬迁与元数据变更,中途失败会留下部分迁移状态;所以它应当有明确的前置检查(内存余量、网络带宽、业务低峰)与回退方案(把槽迁回),而不是在业务高峰顺手做一次。
判断口径一句话:瓶颈在"总量"还是"分布"。总量不够就扩,分布不均就先修分布——顺序反了,扩了也没用。
8. 自测
- 槽迁移完成了哪三件事?它不改变什么?
- 为什么"迁移完成不等于负载均衡"?热键为什么不受槽分布影响?
- 客户端缓存槽映射会带来什么现象?它常被误读为什么?
- 键拆分解决了什么问题?它的读写成本各是什么?
- 什么症状说明"扩容无效、该先修分布"?
- 键名带日期前缀为什么在集群下会成为天然热点?