第 20 章
Cluster 故障转移、扩容与数据迁移
第20章 Cluster 故障转移、扩容与数据迁移
Redis Cluster 的运维涵盖故障自动恢复、在线扩缩容、数据迁移三大核心操作。本章以完整操作手册的形式,详解 pfail/fail 判定机制、从节点选举算法、手动 failover 三种模式、在线扩缩容的逐步命令,以及槽迁移期间 ASK 协议的完整交互流程。
20.1 pfail 与 fail 判定流程
pfail(主观失败)
单个节点自行判断对端节点不可达:
/* cluster.c */
void clusterHandleConfigEpochCollision(clusterNode *sender) { ... }
/* 在 clusterCron() 中(每100ms执行):*/
if (now - node->link->last_recv_time > server.cluster_node_timeout &&
now - node->ping_sent > server.cluster_node_timeout/2) {
/* 超过 cluster-node-timeout 没收到响应 → 标记 pfail */
clusterNodeSetAsFailing(node);
}
pfail 不会触发 failover,仅作为本地记录。
fail(客观失败)的判定
多个节点共同确认某主节点不可达:
流程:
1. 节点 A 将 M(主节点)标记为 pfail
2. A 在 PING/PONG 的 Gossip 数据中携带"M is pfail"报告
3. 其他节点收到后,记录"A 报告 M pfail"到 M 的 fail_reports 列表
4. 任意节点检查 M 的 fail_reports:
- 收到 >= quorum 个(过半 master)的 pfail 报告
- 且这些报告在 2 × cluster-node-timeout 时间窗口内
→ 将 M 标记为 fail,并广播 FAIL 消息
5. FAIL 消息传播:
- 收到 FAIL 消息的节点立即将 M 标记为 fail
- 不需要等待 Gossip 自然传播(快速广播,确保及时性)
配置参数
# redis.conf(集群节点)
cluster-node-timeout 15000 # 15秒(生产推荐,默认15000ms)
cluster-require-full-coverage yes # 是否要求所有槽都有节点(yes=有槽不可用时拒绝所有请求)
cluster-slave-no-failover no # 从节点是否参与 failover
cluster-allow-reads-when-down no # 集群部分失败时是否允许读取
fail 判定的 quorum 计算
quorum = 集群中 master 节点总数 / 2 + 1(向下取整后+1)
示例:
3个 master → quorum = 2(3/2=1, 1+1=2)
5个 master → quorum = 3
7个 master → quorum = 4
20.2 从节点选举算法
触发条件
- 从节点检测到自己的主节点被标记为 fail
- 该从节点的主节点负责至少1个槽(有槽才需要 failover)
- 距上次 failover 超过规定时间(防止频繁 failover)
- 从节点与主节点的复制数据较新(rank 计算)
选举延迟算法
从节点不立即发起选举,而是等待一段时间后再发起,数据最新的从节点等待最短:
/* cluster.c - clusterHandleSlaveFailover() */
mstime_t auth_timeout, auth_retry_time;
/* delay 计算 */
mstime_t delay = (server.cluster->failover_auth_time - mstime()) +
500 + /* 固定延迟 */
random() % 500 + /* 随机抖动(0~500ms)*/
data_age * 0.1 + /* 数据陈旧惩罚 */
my_rank * 1000; /* rank 惩罚(核心) */
/* rank 计算:按 replication offset 排序,offset 最大的 rank=0 */
/* rank=0:offset 最大(数据最新)→ delay 最小 → 优先当选 */
/* rank=1:offset 第二 → 等待1秒 → 次选 */
/* rank=N:等待 N 秒 */
示例:
主节点 M fail,有3个从节点:
- Slave-A: offset=10000, rank=0 → delay = 500 + rand(500) + 0*1000 ≈ 750ms
- Slave-B: offset=9500, rank=1 → delay = 500 + rand(500) + 1*1000 ≈ 1750ms
- Slave-C: offset=9000, rank=2 → delay = 500 + rand(500) + 2*1000 ≈ 2750ms
结果:Slave-A 最先发起选举,通常在其他从节点发起之前已获得足够票数
选举投票过程
1. Slave-A 等待结束,发送 FAILOVER_AUTH_REQUEST 给所有 master 节点:
- 携带自己的 currentEpoch + 1(新 epoch)
- 主节点 ip/port/slots 信息
2. 其他 master 节点收到请求后投票条件检查:
✓ 请求的 epoch > 自己的 currentEpoch
✓ 在本 epoch 内还未投票(防止双投)
✓ 发起选举的从节点的主节点确实被标记为 fail
→ 投票(发送 FAILOVER_AUTH_ACK)
3. Slave-A 统计选票:
- 获得超过 master 数量一半的票 → 当选
- 超时未获足够票 → 等待后重试(epoch+1再次发起)
当选后的操作
/* cluster.c - clusterHandleSlaveFailover() */
void clusterHandleSlaveFailover(void) {
if (server.cluster->failover_auth_count >= needed_quorum) {
/* 当选为新主库 */
serverLog(LL_WARNING, "Failover election won: I'm the new master.");
/* 1. 更新 configEpoch 为当选 epoch */
clusterSetNodeAsMaster(myself);
/* 2. 接管主节点的所有槽 */
clusterBumpConfigEpochWithoutConsensus();
/* 3. 广播 PONG,通知全集群新的槽分配 */
clusterBroadcastPong(CLUSTER_BROADCAST_ALL);
/* 4. 重置复制:成为独立主库 */
replicationUnsetMaster();
}
}
20.3 手动 failover
三种模式
# 模式1:优雅切换(推荐)
# 流程:从库通知主库暂停接受写入 → 从库追上主库 offset → 发起选举
# 零数据丢失,但需要主库在线
redis-cli -h slave_host -p 7003 CLUSTER FAILOVER
# 模式2:强制切换(FORCE)
# 不等待从库追上主库 offset,直接发起选举
# 可能有少量数据丢失(从库未追上的部分)
# 适用:主库响应慢但仍在线
redis-cli -h slave_host -p 7003 CLUSTER FAILOVER FORCE
# 模式3:绕过投票(TAKEOVER)
# 从库直接提升自己为主库,不走 Raft 投票
# 适用:主库完全不可达,且集群没有足够的 master 节点进行投票
# 风险:可能产生配置冲突,需谨慎使用
redis-cli -h slave_host -p 7003 CLUSTER FAILOVER TAKEOVER
优雅切换的内部流程
T=0: redis-cli 向 Slave-A 发送 CLUSTER FAILOVER
T=0: Slave-A 向主库 M 发送 MANUELFAILOVER_PAUSE_CLIENTS_ON
T=0: M 停止接受写入请求(返回 -LOADING 或直接暂停)
T=0~?: Slave-A 等待自己的 offset 追上 M 的 offset
T=X: offset 一致,Slave-A 发起选举
T=X+1s: Slave-A 当选新主,M 降级为从库
T=X+1s: 整个过程完成,写入恢复(新主接受请求)
20.4 在线扩容操作手册
准备工作
# 确认现有集群状态健康
redis-cli --cluster check 192.168.1.1:7000
# 输出示例(健康状态):
# [OK] All nodes agree about slots configuration.
# [OK] All 16384 slots covered.
步骤1:启动新节点
# 新主节点配置
cat > /etc/redis/redis-7006.conf << 'EOF'
port 7006
bind 0.0.0.0
daemonize yes
logfile /var/log/redis/redis-7006.log
dir /var/lib/redis/7006
cluster-enabled yes
cluster-config-file /etc/redis/cluster/nodes-7006.conf
cluster-node-timeout 15000
cluster-announce-ip 192.168.1.4
cluster-announce-port 7006
cluster-announce-bus-port 17006
appendonly yes
appendfsync everysec
maxmemory 8gb
maxmemory-policy allkeys-lru
EOF
redis-server /etc/redis/redis-7006.conf
# 新从节点(配置类似,端口 7007)
redis-server /etc/redis/redis-7007.conf
步骤2:将新主节点加入集群
# add-node <new_node> <existing_node>
redis-cli --cluster add-node 192.168.1.4:7006 192.168.1.1:7000
# 输出:
# [OK] New node added correctly.
# 验证节点已加入(但还没有槽)
redis-cli -c -p 7000 CLUSTER NODES | grep 7006
# <node-id> 192.168.1.4:7006@17006 master - 0 ... connected
步骤3:分配槽(reshard)
# 交互式 reshard
redis-cli --cluster reshard 192.168.1.1:7000
# 交互提示:
# How many slots do you want to move (from 1 to 16384)? 4096
# What is the receiving node ID? <7006的node-id>
# Please enter all the source node IDs.
# Type 'all' to use all nodes as source nodes for the hash slots.
# Type 'done' once you entered all the source nodes IDs.
# Source node #1: all
# ... 显示迁移计划 ...
# Do you want to proceed with the proposed reshard plan (yes/no)? yes
# 非交互式(自动化脚本用):
redis-cli --cluster reshard 192.168.1.1:7000 \
--cluster-from all \
--cluster-to <7006-node-id> \
--cluster-slots 4096 \
--cluster-yes
步骤4:添加从节点
# 为新主节点添加从节点
redis-cli --cluster add-node 192.168.1.4:7007 192.168.1.1:7000 \
--cluster-slave \
--cluster-master-id <7006-node-id>
# 验证从节点已绑定
redis-cli -c -p 7006 INFO replication
# role:master
# connected_slaves:1
# slave0:ip=192.168.1.4,port=7007,...
步骤5:验证扩容结果
# 验证槽均匀分布
redis-cli --cluster info 192.168.1.1:7000
# 192.168.1.1:7000 (xxxxx) -> 50000 keys | 4096 slots | 1 slaves.
# 192.168.1.2:7001 (yyyyy) -> 48000 keys | 4096 slots | 1 slaves.
# 192.168.1.3:7002 (zzzzz) -> 49000 keys | 4096 slots | 1 slaves.
# 192.168.1.4:7006 (wwwww) -> 0 keys | 4096 slots | 1 slaves. ← 新节点(刚迁入,key 为0正常)
# [OK] All 16384 slots covered.
# 监控迁移过程中的错误率
watch -n1 "redis-cli -p 7000 info stats | grep -E 'rejected|errors'"
20.5 槽迁移期间的 ASK 协议详解
迁移状态设置
# 假设将 slot 100 从节点 A(7000)迁移到节点 B(7006)
# 在目标节点 B 设置 importing 状态
redis-cli -p 7006 CLUSTER SETSLOT 100 IMPORTING <A-node-id>
# 在源节点 A 设置 migrating 状态
redis-cli -p 7000 CLUSTER SETSLOT 100 MIGRATING <B-node-id>
逐 key 迁移:MIGRATE 命令
# MIGRATE host port key destination-db timeout [COPY] [REPLACE] [KEYS key1 key2...]
redis-cli -p 7000 MIGRATE 192.168.1.4 7006 "" 0 5000 KEYS key1 key2 key3
# MIGRATE 的原子性:
# 1. 在源节点执行 DUMP key → 序列化数据
# 2. 向目标节点发送 RESTORE key TTL serialized-value
# 3. 目标节点确认 → 源节点执行 DEL key
# 步骤3失败时可能出现 key 同时存在于两个节点(REPLACE 参数处理)
迁移期间的请求处理逻辑
客户端 → GET user:1
目标槽 = 100(正在迁移)
情况1:key 在源节点 A(尚未迁移):
A 直接返回结果(正常响应,不重定向)
情况2:key 已迁移到 B,客户端仍请求 A:
A(migrating 状态)发现 key 不在本节点:
-ASK 100 192.168.1.4:7006
客户端:
→ 连接 B
→ ASKING
→ GET user:1
→ B 返回结果(因为有 ASKING,允许访问 importing 槽)
情况3:客户端直接访问 B(正确路由)但 key 尚未迁移:
B(importing 状态)查找 key:
- 无 ASKING 标记 → 返回 -MOVED 100 A(重定向回源节点)
- 有 ASKING 标记 → 查找本地,找不到则报 key 不存在
↑ 正常,因为客户端应先去 A 找,A 再 ASK 过来
完成迁移
# 迁移全部 key 后,通知集群槽归属变更
# 向源节点和目标节点都发送 SETSLOT node 命令
redis-cli -p 7000 CLUSTER SETSLOT 100 NODE <B-node-id>
redis-cli -p 7006 CLUSTER SETSLOT 100 NODE <B-node-id>
# 状态变更:
# A 的 migrating 标记清除,槽 100 从 A 的位图中移除
# B 的 importing 标记清除,槽 100 加入 B 的位图
# B 的 configEpoch 递增,广播新的槽分配
# 其他节点通过 Gossip 收到新的槽分配,更新路由表
自动化脚本(生产级迁移)
#!/bin/bash
# migrate_slot.sh - 迁移单个槽的所有 key
SRC_HOST=192.168.1.1
SRC_PORT=7000
DST_HOST=192.168.1.4
DST_PORT=7006
SLOT=100
DST_NODE_ID=$(redis-cli -p $DST_PORT CLUSTER MYID)
SRC_NODE_ID=$(redis-cli -p $SRC_PORT CLUSTER MYID)
# 设置迁移状态
redis-cli -p $DST_PORT CLUSTER SETSLOT $SLOT IMPORTING $SRC_NODE_ID
redis-cli -p $SRC_PORT CLUSTER SETSLOT $SLOT MIGRATING $DST_NODE_ID
# 循环迁移所有 key
while true; do
# 获取槽中的 key(每次最多100个)
KEYS=$(redis-cli -p $SRC_PORT CLUSTER GETKEYSINSLOT $SLOT 100)
if [ -z "$KEYS" ]; then
break # 迁移完成
fi
# 批量迁移
redis-cli -p $SRC_PORT MIGRATE $DST_HOST $DST_PORT "" 0 60000 KEYS $KEYS
if [ $? -ne 0 ]; then
echo "Migration failed for slot $SLOT, retrying..."
sleep 1
fi
done
# 完成迁移
redis-cli -p $SRC_PORT CLUSTER SETSLOT $SLOT NODE $DST_NODE_ID
redis-cli -p $DST_PORT CLUSTER SETSLOT $SLOT NODE $DST_NODE_ID
echo "Slot $SLOT migrated successfully"
20.6 缩容流程
完整缩容步骤
# 目标:下线节点 7006(192.168.1.4:7006)
# 节点 7006 目前负责 slot 12288-16383
# 步骤1:将 7006 的槽迁移到其他节点
redis-cli --cluster reshard 192.168.1.1:7000 \
--cluster-from <7006-node-id> \
--cluster-to <7000-node-id> \
--cluster-slots 4096 \
--cluster-yes
# 步骤2:确认 7006 已无槽
redis-cli -p 7006 CLUSTER INFO | grep cluster_slots_assigned
# cluster_slots_assigned:0 ← 已无槽
redis-cli --cluster check 192.168.1.1:7000
# 步骤3:移除 7006 的从节点(先移除从节点)
redis-cli --cluster del-node 192.168.1.1:7000 <7007-slave-node-id>
# 步骤4:移除 7006 主节点
redis-cli --cluster del-node 192.168.1.1:7000 <7006-node-id>
# [OK] Node 192.168.1.4:7006 removed from cluster.
# 步骤5:下线节点服务
redis-cli -h 192.168.1.4 -p 7006 SHUTDOWN
redis-cli -h 192.168.1.4 -p 7007 SHUTDOWN
20.7 集群扩缩容期间的业务影响
影响来源分析
影响1:MIGRATE 期间 key 短暂不可访问
- MIGRATE 是阻塞命令,在源节点执行期间,该 key 被锁定
- 批量迁移(KEYS参数)时,多个 key 同时被锁定
- 影响时间:通常 <1ms/key,大 value 可能更长
缓解:
# 限制每次迁移的 key 数量
CLUSTER GETKEYSINSLOT slot 10 # 每次最多10个
# 控制迁移速率(在脚本中添加 sleep)
MIGRATE ... && sleep 0.01
影响2:路由表更新延迟
槽迁移完成后,客户端的本地路由表需要通过 MOVED 重定向来更新:
- 迁移完成后,客户端第一次访问迁移槽 → 收到 MOVED → 更新本地缓存
- 只有第一次请求有额外的一次重定向(<1ms)
- 大多数智能客户端支持后台更新路由表
监控重定向次数:
redis-cli -p 7000 info stats | grep redirected
影响3:全量同步对新从节点的影响
新从节点加入后,需要与主节点进行全量同步:
- 全量同步期间:新从节点不对外提供读服务
- 主节点 BGSAVE 期间:主节点内存增加(COW),响应延迟可能升高
建议:
# 在低峰期执行扩容
# 监控主节点内存和延迟
watch -n1 "redis-cli -p 7006 info memory | grep used_memory_human"
watch -n1 "redis-cli -p 7006 info stats | grep instantaneous_ops"
20.8 集群运维常用命令速查
# 集群状态检查
redis-cli --cluster check <any-node-host>:<port>
# 查看集群信息
redis-cli -c -p 7000 CLUSTER INFO
# 查看所有节点
redis-cli -c -p 7000 CLUSTER NODES
# 查看特定槽的负责节点
redis-cli -c -p 7000 CLUSTER KEYSLOT <key>
# 查看某个槽的 key 列表
redis-cli -c -p 7000 CLUSTER GETKEYSINSLOT <slot> <count>
# 查看节点的槽分配
redis-cli -c -p 7000 CLUSTER SLOTS
# 重置节点(危险!谨慎使用)
redis-cli -p 7006 CLUSTER RESET SOFT # 软重置(不清数据)
redis-cli -p 7006 CLUSTER RESET HARD # 硬重置(清空数据)
# 修复集群(槽分配异常时)
redis-cli --cluster fix <any-node-host>:<port>
# 均衡槽分配(不指定具体迁移)
redis-cli --cluster rebalance <any-node-host>:<port>
redis-cli --cluster rebalance <any-node-host>:<port> --cluster-use-empty-masters
# 查看集群各节点的 key 数量
redis-cli --cluster info <any-node-host>:<port>
20.9 生产故障场景处理
场景1:集群出现 cluster_state:fail
# 诊断
redis-cli -p 7000 CLUSTER INFO | grep cluster_state
# cluster_state:fail
redis-cli -p 7000 CLUSTER NODES | grep fail
# 找到被标记为 fail 的节点
# 处理方案:
# 1. 若节点可恢复,重启节点,等待自动 failover
# 2. 若节点不可恢复,且其从节点正常,等待从节点自动当选
# 3. 若没有从节点,手动添加新节点并迁移槽
# 临时允许读取(即使集群不完整):
redis-cli -p 7000 config set cluster-require-full-coverage no
场景2:节点脑裂(网络分区)
# 症状:集群出现两个 master 负责相同槽
redis-cli -p 7000 CLUSTER NODES | grep -v slave | awk '{print $9}'
# 发现有重复槽范围
# 处理:configEpoch 大的节点为合法主节点,epoch 小的被自动降级
# 确认 epoch 值:
redis-cli -p 7000 CLUSTER NODES | awk '{print $1, $7}'
# 手动修复(极端情况):
redis-cli --cluster fix 192.168.1.1:7000 --cluster-fix-with-unreachable-masters
场景3:大量 ASK 重定向(迁移过慢)
# 监控 ASK 重定向次数
redis-cli -p 7000 info stats | grep -E "moved|ask"
# total_commands_processed:1000000
# moved_redirect:0
# ask_redirect:5000 ← 大量 ASK 说明迁移在进行中
# 加速迁移:调大每次迁移的 key 数量,减少 sleep
# 或使用 redis-cli --cluster reshard 的自动速率控制
本章小结
| 操作 | 核心命令/机制 | 注意事项 |
|---|---|---|
| 故障检测 | pfail → fail(quorum 过半) | cluster-node-timeout 调优 |
| 自动 failover | 从节点 rank 延迟选举 | offset 最大的从节点优先 |
| 手动 failover | CLUSTER FAILOVER [FORCE|TAKEOVER] | 生产优先用无参数(优雅切换) |
| 在线扩容 | add-node → reshard → add-node(从) | 低峰期操作,监控延迟 |
| 槽迁移 | MIGRATE + SETSLOT + ASK 协议 | 批量迁移,控制每批 key 数量 |
| 缩容 | reshard 清空槽 → del-node | 先移从节点,再移主节点 |
| 迁移期间影响 | MIGRATE 短暂阻塞,MOVED 重定向 | 智能客户端自动处理 |
Redis Cluster 是 Redis 在超大规模场景下的标准方案。掌握故障转移算法和在线扩缩容操作手册,能够在生产中自信地进行容量规划和故障处置,是高级 Redis 运维工程师的必备技能。