KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 在线扩容:槽是怎么搬家的,ASK 与 MOVED 有何不同 — keel 龙骨
集群真正的价值不只是"能分片",而是能在线重新分片。这一章把槽迁移的五步完整跑一遍(含真实输出),重点讲迁移进行中请求会碰到什么——那个与 MOVED 长得像、语义却完全不同的 ASK——以及迁移中断后怎么收拾残局。
集群真正的价值不只是"能分片",而是能在线重新分片。这一章把槽迁移的五步完整跑一遍(含真实输出),重点讲迁移进行中请求会碰到什么——那个与 MOVED 长得像、语义却完全不同的 ASK——以及迁移中断后怎么收拾残局。
一、现场:加了机器,容量却没变
运维给集群加了一台新机器:CLUSTER NODES 里能看到它,CLUSTER INFO 也正常。但内存分布完全没变——新节点上空空如也,老节点依旧吃紧。
原因:新节点加入集群时不持有任何槽,槽不会自动重新分配。这是刚接触集群时最容易误解的一点——
加机器:把节点拉进集群(--cluster add-node) → 它还不管任何数据
扩容 :把槽从旧节点迁到新节点(--cluster reshard) → 数据和压力才真的过去
两个动作,缺一不可。
二、直觉模型:搬一个槽要在两边各挂一块牌子
搬一个槽时,源节点和目标节点要各自进入一个"中间状态",这样它们才知道该怎么回答迁移期间到来的请求:
| 状态 | 设在谁身上 | 含义 |
|---|---|---|
MIGRATING(迁出中) |
源节点 | "这个槽我正在往外搬。槽里还有的 key 我照常处理,没有的一律让客户端去问目标" |
IMPORTING(迁入中) |
目标节点 | "这个槽我正在接收。正常情况下没我的事,只有带着 ASKING 前缀的命令我才处理" |
两块牌子都摘掉(用 CLUSTER SETSLOT <slot> NODE <目标>)时,槽的归属正式变更,并通过 gossip 广播给全集群。
三、一次完整运行:手工搬一个槽(真实输出)
下面把槽 1649(第 05 章测过,user:1000 就在它里面)从 7000 搬到 7001。生产上这一步由 --cluster reshard 自动完成,但手工做一遍才能看懂自动化在干什么。
第 1 步:目标节点进入 IMPORTING
127.0.0.1:7001> CLUSTER SETSLOT 1649 IMPORTING f155ef94f02bb9633e0fae90bbe03172e7817f9f
OK
第 2 步:源节点进入 MIGRATING
127.0.0.1:7000> CLUSTER SETSLOT 1649 MIGRATING 6eed45e7ac37a6cd9f86a3cd4c3b176b080cccdd
OK
第 3 步:此时访问这个槽里的 key,得到的是 ASK 而不是 MOVED
127.0.0.1:7000> SET user:1000 v1
ASK 1649 127.0.0.1:7001
这一行是整个迁移机制的核心,下一节专门拆。注意:这次 SET 没有成功——源节点在 MIGRATING 状态下不给自己已经不负责的槽创建新 key,它只是指路。
第 4 步:把槽里已有的 key 搬过去
127.0.0.1:7000> MIGRATE 127.0.0.1 7001 "" 0 2000 KEYS user:1000
NOKEY
NOKEY 表示"这些 key 我这儿没有"——正好印证上一步:那次 SET 被 ASK 挡掉了,key 根本没写进来。真实迁移中这一步会返回 OK。
批量搬迁的标准循环是这两个命令:
127.0.0.1:7000> CLUSTER GETKEYSINSLOT 1649 100 # 取这个槽里最多 100 个 key
127.0.0.1:7000> MIGRATE 127.0.0.1 7001 "" 0 5000 KEYS <刚取到的那些 key>
一直循环到 CLUSTER GETKEYSINSLOT 返回空,说明这个槽搬完了。
第 5 步:正式变更归属
127.0.0.1:7001> CLUSTER SETSLOT 1649 NODE 6eed45e7ac37a6cd9f86a3cd4c3b176b080cccdd
OK
127.0.0.1:7000> CLUSTER SETSLOT 1649 NODE 6eed45e7ac37a6cd9f86a3cd4c3b176b080cccdd
OK
验证:槽段真的变了
127.0.0.1:7000> CLUSTER NODES
f155ef94... 127.0.0.1:7000@17000 myself,master - 0 1790917359000 1 connected 0-1648 1650-5460
6eed45e7... 127.0.0.1:7001@17001 master - 0 1790917360408 4 connected 1649 5461-10922
ee93b89b... 127.0.0.1:7002@17002 master - 0 1790917361505 3 connected 10923-16383
7000 原本连续的槽段被挖掉了一个 1649(0-1648 与 1650-5460),7001 多了一段 1649。
客户端的路由表也跟着更新(redis-py 实测,注意 1649 是单独一段):
0-1648 -> {'primary': ('127.0.0.1', 7000), 'replicas': []}
1650-5460 -> {'primary': ('127.0.0.1', 7000), 'replicas': []}
1649-1649 -> {'primary': ('127.0.0.1', 7001), 'replicas': []}
四、ASK 与 MOVED:一字之差,处理完全不同
这是本章最需要记住的对比:
| MOVED | ASK | |
|---|---|---|
| 含义 | 这个槽已经归别人了 | 这个槽还归我,但你找的这个 key 已经搬到目标了 |
| 持久性 | 永久:客户端应更新路由表 | 一次性:只针对这一条命令,不要更新路由表 |
| 客户端后续动作 | 直接向新节点重发命令 | 必须先发一条 ASKING,再发命令 |
| 出现时机 | 路由表过期、迁移完成后 | 迁移进行中 |
用一张图看迁移期间请求的各条路径:
flowchart TD
C["客户端:SET user:1000 v1"] --> S{"源节点 7000<br/>槽 1649 = MIGRATING"}
S -->|"key 还在本地"| OK1["正常执行,返回结果"]
S -->|"key 已迁走 / 不存在"| ASK["返回 ASK 1649 127.0.0.1:7001"]
ASK --> A["客户端:不更新路由表"]
A --> ASKING["向 7001 发 ASKING"]
ASKING --> CMD["再发一次 SET user:1000 v1"]
CMD --> T{"目标节点 7001<br/>槽 1649 = IMPORTING"}
T -->|"带了 ASKING"| OK2["执行并返回"]
T -->|"没带 ASKING"| MOVED["返回 MOVED 1649 127.0.0.1:7000<br/>把客户端踢回真正的所有者"]
MOVED --> A2["客户端:更新路由表,重新发往 7000"]
OK1 --> DONE["迁移完成:SETSLOT NODE 后<br/>归属正式变更,此后一律 MOVED"]
OK2 --> DONE
S -.->|"迁移中断<br/>MIGRATING 状态残留"| FIX["CLUSTER SETSLOT ... STABLE<br/>或 redis-cli --cluster fix"]
图里"没带 ASKING 就被踢回源节点"这条分支解释了为什么 ASKING 不是可选项:目标节点在 IMPORTING 状态下默认不认为自己拥有这个槽,只有收到 ASKING 才破例处理一次。这是防止"客户端拿着过期路由表乱写"的保护。
一句话记忆:MOVED 是"你地图错了,改地图";ASK 是"地图没错,但这个东西临时在隔壁,去拿一下就回来"。
五、生产工具:reshard / rebalance / check
手工五步的意义是理解机制,生产上用官方工具(它内部就是这五步):
# 加节点(先以"不持有槽"的身份加入)
redis-cli --cluster add-node 127.0.0.1:7003 127.0.0.1:7000
# 迁槽(交互模式会问迁多少、从哪来到哪去)
redis-cli --cluster reshard 127.0.0.1:7000
# 非交互写法
redis-cli --cluster reshard 127.0.0.1:7000 \
--cluster-from <源节点ID> --cluster-to <目标节点ID> \
--cluster-slots 2000 --cluster-yes
# 检查(上线前后都该跑)
redis-cli --cluster check 127.0.0.1:7000
check 的输出里必须有 All 16384 slots covered;同时用它确认各节点的槽数大致均衡(--cluster rebalance 可自动做均衡)。
六、失败注入:迁移中途搞破坏
破坏一:迁移进行中杀掉执行进程
现象:部分槽停在 MIGRATING / IMPORTING 状态,--cluster check 会提示有"open slot"(迁移未完成的槽)。
处理:
redis-cli --cluster fix 127.0.0.1:7000 # 自动把未完成的槽收敛到确定状态
# 或者对具体槽手工清理中间状态
redis-cli -p 7000 CLUSTER SETSLOT 1649 STABLE
STABLE 的作用是清除 MIGRATING/IMPORTING 标记(槽的归属不变)。它不会帮你搬 key——清理后要重新确认这个槽里的 key 到底在谁那儿,必要时重跑迁移。
破坏二:迁移中途目标节点宕机
现象:源节点停在 MIGRATING,已迁过去的 key 在目标上、未迁的还在源上。此时访问该槽的 key,一部分返回 ASK(已迁走的),一部分正常返回(还在源上的)。
处理:先恢复目标节点(它会自动重新加入集群),再 fix 或重跑迁移。"同一槽的 key 分散在两个节点"就是迁移中间态的本质,这也解释了为什么客户端必须正确处理 ASK——否则会有一段时间的 key 读不到。
破坏三:迁移期间的大 key
MIGRATE 是同步阻塞的:搬一个大 key 会让源节点和目标节点都短暂阻塞。有大 key 的集群要在低峰期迁移,并把每批的 key 数量调小。
七、缩容与下线节点
顺序必须反着来:先把槽迁空,再删节点。
redis-cli --cluster reshard 127.0.0.1:7000 \
--cluster-from <要下线的节点ID> --cluster-to <接收的节点ID> \
--cluster-slots <该节点持有的全部槽数> --cluster-yes
redis-cli --cluster del-node 127.0.0.1:7000 <要下线的节点ID>
del-node 对还持有槽的节点会直接报错拒绝——这是安全设计,防止你把数据连锅端走。
八、误判澄清
| 误解 | 核对 | 结论 |
|---|---|---|
| "ASK 是 MOVED 的一种,处理一样就行" | ASK 不更新路由表、还要先发 ASKING |
处理错会导致命令反复失败或被踢回源节点 |
| "迁移期间这个槽不可用" | 源和目标都能答(靠 ASK/ASKING 配合) | 只要客户端支持集群协议,迁移对业务透明 |
| "加机器就等于扩容" | 新节点初始不持有任何槽 | 必须再执行 reshard |
| "迁移中断了重跑就行" | 残留的 MIGRATING/IMPORTING 状态会让后续请求行为异常 | 先 fix / STABLE 清理,再核对 key 归属 |
| "迁移没有代价" | MIGRATE 同步阻塞;大 key 会卡住两个节点 |
低峰期做、小批量做、先排查大 key |
九、生产环境怎样替换
| 教学替身 | 生产替换 | 要点 |
|---|---|---|
| 手工五步 | --cluster reshard |
手工版用于理解与排障;生产用工具,但要能读懂它卡在哪一步 |
| 一次搬一个槽 | 分批(--cluster-slots)+ 每批之间观察 |
每批之后看延迟与错误率,不对就停 |
| 无监控的迁移 | 盯 cluster_state、cluster_slots_ok、MOVED/ASK 计数、两端延迟 |
迁移是真实的线上变更,不是后台任务 |
| 3 主 0 从 | 每主至少 1 副本 | 无副本时迁移过程中主节点故障 = 数据丢失 |
| 单机器多实例 | 跨机器、跨机架/可用区 | 主与副本必须在不同机器上 |
一条实用的容量规则:不要让任何单节点的使用率长期超过 60%——迁移期间源节点要同时承担"未迁走的 key"和搬迁开销,目标节点则在快速写入,两端都会临时变紧。
十、练习与验收
练习 1:按第 03 节的五步手工搬一个槽,中途用普通 redis-cli 观察 ASK,用 CLUSTER NODES 对比迁移前后的槽段。
练习 2:在迁移进行中杀掉执行进程,观察残留状态;分别用 fix 和 SETSLOT ... STABLE 清理,记录差异。
练习 3:完成一次 add-node + reshard 后逐项确认:cluster_state:ok、16384 槽全覆盖、各节点槽数均衡、客户端路由表已更新、业务错误率无变化。
验收点:不看资料,说出槽迁移五步、ASK 与 MOVED 的三点区别、为什么目标节点要求先发 ASKING、以及缩容时为什么必须先迁空槽。
现在能解释什么:加机器与扩容是两个动作;槽迁移的五步与两个中间状态;ASK 的语义与客户端正确处理方式;迁移中断的清理手段;缩容的正确顺序。
下一步:第 07 章收口——监控指标、故障速查,以及四类 Redis 角色在三套形态下的落地要点。