扩容 Redis Cluster 时,业务错误率会突然冒出一串 MOVED。很多人把这当成故障,直接加重试。结果是:迁移窗口里每个请求多一次往返,P99 被拉高,个别客户端还把错误节点缓存死,迁移结束后仍然打偏。
槽迁移不是故障。MOVED 的意思是:这个 key 的哈希槽已经换主人了,请去新节点。客户端必须刷新拓扑,而不是把同一条命令再打回旧节点。
槽、节点、MOVED 分别管什么
计算必须在客户端完成
Cluster 把 16384 个槽摊到节点上。key 用 CRC16 取模得到槽号,再查本地槽表。表过期时,请求打到错误节点,节点返回:
MOVED 3999 10.0.1.12:6379
错误写法是把 MOVED 当普通失败,走指数退避:
python
import time
import redis
r = redis.Redis(host="10.0.1.11", port=6379)
def incr_wrong(key: str) -> int:
for i in range(5):
try:
return r.incr(key)
except redis.ResponseError as e:
if "MOVED" in str(e):
time.sleep(0.05 * (2 ** i))
continue
raise
raise RuntimeError("incr failed")
退避只会延长错误窗口。正确写法是用 Cluster 客户端,让它消化 MOVED 并更新槽表:
python
from redis.cluster import RedisCluster
rc = RedisCluster(
startup_nodes=[{"host": "10.0.1.11", "port": 6379}],
decode_responses=True,
skip_full_coverage_check=True,
)
def incr_ok(key: str) -> int:
return rc.incr(key)
RedisCluster 收到 MOVED 后会改本地槽映射,下一次同槽请求直打新主人。ASK 不同:它表示迁移进行中,这次去目标节点,但槽表还不能永久改写。客户端必须区分这两种响应。
迁移窗口怎么缩短
先导入再迁移,别让槽长期双边
CLUSTER SETSLOT 的导入/迁出如果拉得很长,ASK 重定向会变成常态。生产步骤:
- 目标节点
SETSLOT <slot> IMPORTING <source_id> - 源节点
SETSLOT <slot> MIGRATING <target_id> MIGRATE按 key 搬数据,批量而不是一条一条走业务线程- 两边都
SETSLOT <slot> NODE <target_id>
运维用 redis-cli --cluster reshard 时,把 --cluster-pipeline 开到 10 以上,减少往返。业务侧把命令超时从 50ms 临时提到 200ms,只覆盖迁移窗口。
对照一组 8 节点、单槽 20 万 key 的迁移:
| 策略 | 迁移耗时 | 窗口内 P99 |
|---|---|---|
| 逐 key MIGRATE | 190s | 86ms |
| pipeline=20 | 41s | 27ms |
| 先停写再搬 | 22s | 业务不可写 |
先停写只适合凌晨维护。白天用 pipeline,并允许短暂 ASK。
拓扑刷新别依赖碰运气
启动节点必须多于 1 个
只配一个 startup_nodes,那个节点在迁移中若正好是迁出方且负载高,客户端启动就会失败。至少放 3 个不同机架的节点。
周期性刷新也要有:
python
import threading
import time
def refresh_loop(client: RedisCluster):
while True:
try:
client.cluster_nodes()
except Exception:
pass
time.sleep(30)
threading.Thread(target=refresh_loop, args=(rc,), daemon=True).start()
有的驱动默认只在报错时刷新。迁移刚结束的几十秒里,旧槽表仍然会制造 MOVED。30 秒主动拉一次 CLUSTER NODES,能把这段尾巴切短。
禁止在应用里缓存 host:port 后绕过 Cluster 客户端直连。槽一迁,直连就成为永久错误。
总结
MOVED 是拓扑更新信号,不是可重试的瞬时错误。用 Cluster 客户端消化重定向,区分 ASK 与 MOVED,迁移用 pipeline 压缩窗口,启动节点放 3 个以上,并定期刷新槽表。扩容时错误率应表现为短暂 ASK,而不是持续 5xx。