Redis Cluster:100 节点无感扩容的核心机制
一句话结论(30s)
无感扩容的本质是 slot 粒度的在线迁移 + 客户端重定向:因为 key 被 CRC16 哈希到 16384 个 slot 且每个 slot 是独立迁移单元,所以扩容无需停服;迁移中用 ASK 临时重定向、迁完后用 MOVED 永久重定向,cluster-aware 客户端自动更新路由表,应用层完全零感知。
核心原理(2min)
扩容 = 新节点 add-node(不分配 slot、QPS=0)+ reshard 在线迁移。迁移一个 slot 时源节点标 MIGRATING、目标节点标 IMPORTING,用 MIGRATE 逐个 key 原子搬迁(源 DEL + 目标 RESTORE 在同一原子操作内)。客户端请求落到错节点时:key 已迁走返回 MOVED(永久更新 slot→node 映射并重试),key 还在迁移中返回 ASK(需先发 ASKING 命令让目标节点临时处理)。Jedis/Lettuce 等客户端缓存 slot→node 映射,收到 MOVED 自动更新缓存并重试,所以业务代码只看到一个 Redis 连接。
底层深入(5-10min)
Redis Cluster 的基础:16384 个 Slot
Redis 将 key 的哈希空间分为 16384 个 hash slot。每个 master 负责一部分 slot。
key → CRC16(key) % 16384 → slot X → 负责 slot X 的 master node
集群扩容的本质就是重新分配 slot。
思考:为什么把迁移单元定在 slot,而不是单个 key 或整个节点?若按 key 迁移,上亿个 key 逐个协商路由会爆炸;若按整个节点迁移,粒度太粗、负载无法精细均衡。slot 是「哈希空间的分区单位」,key→slot 的映射是稳定的(CRC16 取模),所以迁移 slot 等价于「把一组 key 批量换主」,客户端只需更新「slot→node」这一层映射,key 本身不用动,这正是「无感」的前提。
扩容流程:3 主 → 10 主(业务无感)
Step 1:新节点加入集群
redis-cli --cluster add-node 192.168.1.36:7006 192.168.1.30:7000
新节点加入集群但不分配 slot——QPS = 0。
Step 2:重新分片(在线迁移)
redis-cli --cluster reshard 192.168.1.30:7000
# 交互: How many slots? 1638 (16383/10)
# Receiving node ID? <new_node_id>
# Source node? all
迁移过程:每个 slot 是一个独立迁移单元。一个 slot 正在迁移期间:
slot 100 正在从 Master1 → Master4:
Master1: "该 slot 的 key 可能在我这也可能在 Master4"
Master4: "该 slot 的 key 可能在我这也可能在 Master1"
客户端请求 key=“user:1001”(slot 100)→ 先发到 Master1 → Master1 检查:这个 key 已经搬迁了 → 返回 MOVED → 客户端重定向到 Master4。
MOVED vs ASK
MOVED(永久重定向):slot 已完全迁移到新节点。
客户端 → Master1: GET user:1001
Master1 → 客户端: -MOVED 3999 192.168.1.36:7006
客户端 → Master4: GET user:1001 (自动永久更新路由表)
ASK(临时重定向):slot 正在迁移,key 可能在新节点也可能还在旧节点。
客户端 → Master1: GET user:1002
Master1 → 客户端: -ASK 3999 192.168.1.36:7006
客户端 → Master4: ASKING → GET user:1002
// ASKING 命令告诉 Master4:"我知道这个 slot 理论上不属于我,但它在迁移中,请临时处理"
思考:为什么 ASK 重定向要先发一条 ASKING,而 MOVED 不用?顺着想——MOVED 是「slot 永久归我了」,目标节点放心处理即可;ASK 是「slot 理论上还不属于我,只是迁移期间临时帮忙」,如果不加 ASKING,目标节点会因为「这个 slot 不在我负责范围」而再次拒绝,陷入重定向死循环。ASKING 就是一句「我知道,临时放行」的授权标记,且它只对下一条命令生效。
Step 3:验证
redis-cli --cluster check 192.168.1.30:7000
# 输出: All 16384 slots covered.
# 每个 master: 1638 slots, ~2,000,000 keys
迁移期间的数据一致性
迁移一个 slot 的流程:
1. Master1: CLUSTER SETSLOT 100 MIGRATING → "正在迁出"
2. Master4: CLUSTER SETSLOT 100 IMPORTING → "正在迁入"
3. Master1: MIGRATE 逐个 key 发送到 Master4(RESTORE 原子)
4. Master1: CLUSTER SETSLOT 100 NODE <Master4_ID> → "迁完了"
5. Master4: CLUSTER SETSLOT 100 NODE <Master4_ID> → "收到"
MIGRATE 操作是原子的——源节点 DEL key + 目标节点 RESTORE key 在同一个原子操作内完成。迁移过程中对特定 key 的访问通过 ASK/MOVED 重定向保证一致。
思考:MIGRATE 为什么必须原子(源 DEL + 目标 RESTORE 一次完成)?反着想——若先 DEL 再 RESTORE 分两步,中间窗口 key 会「消失」,请求要么拿不到要么写丢;若先 RESTORE 再 DEL,key 会短暂存在于两个节点,可能读到双份甚至写冲突。原子搬迁保证 key 在任意时刻只属于一个节点,这是迁移期间数据一致性的根基。
业务无感的实现
Jedis/Lettuce 等 cluster-aware 客户端缓存 slot → node 映射表。收到 MOVED 后自动更新缓存并重试。应用层代码中只看到一个 Redis 连接,完全感知不到后端的 slot 迁移。
应用代码:
redisCluster.get("user:1001")
→ Jedis: 计算 CRC16 → slot 100 → 缓存查 node → Master1
→ Master1: MOVED → Master4
→ Jedis: 更新缓存 slot100→Master4, 重试 get
→ Master4: 返回值
// 应用层零感知
总结
无感扩容的两个核心机制:slot 粒度的在线迁移(不中断服务)和 MOVED/ASK 重定向(让客户端自动适应路由变化)。扩容完成后所有 slot 均匀分布,新老节点负载均衡。
章末提问
Q1:MOVED 和 ASK 重定向有什么区别?客户端分别怎么处理?
回答思路:结论——MOVED 是「slot 已永久迁移」的永久重定向,客户端要更新本地 slot→node 映射并重试;ASK 是「slot 正在迁移中」的临时重定向,客户端只对本次请求临时跳转、且必须先发 ASKING,不更新路由表。因为:ASK 场景下 key 可能还在源节点也可能已迁到目标节点,目标节点本不负责该 slot,只有 ASKING 才能让它临时放行。
Q2:迁移一个 slot 时 MIGRATE 为什么是原子的?不原子会有什么问题?
回答思路:结论——MIGRATE 把「源节点 DEL + 目标节点 RESTORE」封装成一次原子操作,保证 key 任意时刻只属于一个节点。因为:若分两步,DEL 后 RESTORE 前的窗口 key 会「消失」导致读不到/写丢;若先 RESTORE 后 DEL,key 会短暂同时存在于两节点,产生双份读或写冲突。原子搬迁是迁移期间数据一致性的根基。
Q3:无感扩容为什么对业务「零感知」?如果客户端不支持 cluster 模式会怎样?
回答思路:结论——因为 key→slot→node 的路由解析和重定向都由 cluster-aware 客户端(Jedis/Lettuce)自动完成,应用层只看到一个连接。因为:客户端缓存 slot→node 映射,收到 MOVED 自动更新缓存并重试,业务代码无需感知 slot 迁移。反过来,若用普通客户端,它不会处理 MOVED/ASK,一旦 key 被迁走就会报错或写错节点,所以 Cluster 必须配 cluster-aware 客户端。