Redis-12 Cluster 集群
1. 为什么需要 Cluster
哨兵解决了高可用,但没解决扩展性:
- 存储受限于单机内存:所有数据都在一个主节点上,超过 10GB 就开始有 fork 慢、恢复慢、全量同步压力大的问题;
- 写入受限于单节点:所有写请求集中在一个主节点,达到几万 QPS 就是瓶颈;
- 单点的所有风险都被放大:一个大实例出问题影响全部业务。
1.1 三种分片方案的演进
方案一:客户端分片(Client-side Sharding)
客户端自己算 hash(key) % N 决定用哪个 Redis 实例
- 优点:无中间层、性能最好、实现简单;
- 缺点:扩缩容极其痛苦(N 变了,几乎所有 key 的归属都变了,要全量迁移数据);每个客户端都要实现一遍分片逻辑;无法自动故障转移。
- 用一致性哈希能缓解扩缩容问题(只影响相邻节点的数据),但仍需要客户端库统一实现。
方案二:代理分片(Proxy)
客户端 → Proxy(Twemproxy / Codis / predixy / redis-cluster-proxy)→ Redis 实例
- 优点:客户端无感知(就像连单个 Redis)、分片逻辑集中在代理层、Codis 支持在线扩容;
- 缺点:多一跳网络延迟(通常增加 0.1~1ms);代理本身要做高可用;代理是性能瓶颈和运维负担;Twemproxy 不支持在线扩容。
方案三:服务端分片(Redis Cluster,3.0+)
客户端 → 任意 Redis 节点(节点会告诉客户端正确的节点在哪)
- 优点:官方方案、无中间层、内置高可用和在线扩缩容;
- 缺点:客户端必须支持集群协议(处理重定向);功能有限制(跨 slot 操作、多 db)。
Redis Cluster 是现在的标准答案,本篇讲的就是它。
2. Cluster 架构
2.1 基本结构
┌───────── 集群总线(Cluster Bus)互相通信 ─────────┐
│ │
┌─────▼─────┐ ┌───────────┐ ┌───────────┐ │
│ Master A │◀───▶│ Master B │◀───▶│ Master C │◀─────────┘
│slot 0-5460│ │5461-10922 │ │10923-16383│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Slave A1 │ │ Slave B1 │ │ Slave C1 │
└───────────┘ └───────────┘ └───────────┘
核心概念:
| 概念 | 说明 |
|---|---|
| 槽(slot / hash slot) | 整个键空间被划分为 16384 个槽,每个 key 通过 CRC16 映射到一个槽 |
| 分片(shard) | 一个主节点 + 它的若干从节点,负责一部分槽 |
| 集群总线(Cluster Bus) | 节点之间通信的通道,端口是 数据端口 + 10000(如 6379 → 16379) |
| Gossip 协议 | 节点之间通过总线交换状态信息的协议 |
| 配置纪元(configEpoch) | 每个节点的配置版本号,用于解决冲突 |
去中心化设计:Cluster 没有中心节点/协调节点,所有节点地位平等,每个节点都知道整个集群的完整拓扑(哪个槽在哪个节点)。这跟哨兵架构(有独立的哨兵进程做协调)有本质区别——Cluster 把哨兵的功能内置到了每个节点里。
2.2 为什么是 16384 个槽
这是最经典的 Cluster 面试题。antirez 在 GitHub issue #2576 里亲自回答过,理由有三点:
(1)心跳包的大小
节点之间的 Gossip 心跳包(clusterMsg)里带有一个位图(bitmap),用来表示"发送方负责哪些槽"。
- 16384 个槽 →
16384 / 8 = 2048 字节 = 2KB; - 如果是 65536 个槽 →
65536 / 8 = 8192 字节 = 8KB。
因为每个节点每秒都要向多个节点发送心跳,8KB 相比 2KB 会让集群总线的流量增加 4 倍。antirez 认为 2KB 是可以接受的开销,8KB 就太浪费了。
(2)集群的节点数上限设计为 1000
Redis 集群的设计目标是不超过 1000 个节点(超过这个规模 Gossip 的消息量会变得不可控)。
16384 / 1000 ≈ 16 个槽每节点,这个粒度足够均衡地分配数据。如果槽太少(比如 1024),1000 个节点时每节点只有 1 个槽,分配就很难均衡了。
(3)位图的压缩率
心跳包里的槽位图会用游程编码(RLE)压缩。位图越稀疏(节点数越多、每节点的槽越少),压缩效果越差。
如果用 65536 个槽而节点数不多(比如 3 个节点,每个节点约 21845 个槽),位图会非常稀疏(大量连续的 0 和 1),压缩后其实不大。但 antirez 的考虑是:在节点数少的常见情况下,16384 已经足够,没必要为了极端情况付出 4 倍的常态开销。
记忆要点:2KB 的位图大小 + 设计上不超过 1000 节点 + 压缩率考虑。
2.3 key 到槽的映射
slot = CRC16(key) mod 16384
- CRC16:一种 16 位循环冗余校验算法(Redis 用的是 XMODEM 变种),计算快且分布均匀;
- mod 16384:实际实现是
CRC16(key) & 16383(因为 16384 是 2 的幂,位运算比取模快)。
# 查看某个 key 属于哪个槽
127.0.0.1:6379> CLUSTER KEYSLOT user:1001
(integer) 8722
127.0.0.1:6379> CLUSTER KEYSLOT foo
(integer) 12182
2.4 hash tag
问题:MGET k1 k2 要求所有 key 在同一个槽,但 k1 和 k2 的 CRC16 通常不同,会落在不同槽 → 报错:
(error) CROSSSLOT Keys in request don't hash to the same slot
解决:hash tag。如果 key 中包含 {...},只用第一对 {} 里的内容计算 CRC16。
127.0.0.1:6379> CLUSTER KEYSLOT '{user1001}:name'
(integer) 5798
127.0.0.1:6379> CLUSTER KEYSLOT '{user1001}:age'
(integer) 5798 # 相同!都用 "user1001" 算哈希
127.0.0.1:6379> CLUSTER KEYSLOT '{user1001}:profile'
(integer) 5798
# 所以这些 key 一定在同一个节点,可以做多 key 操作
127.0.0.1:6379> MGET {user1001}:name {user1001}:age
hash tag 的精确规则(容易记错):
- 找到第一个
{; - 从它之后找第一个
}; - 如果两者之间有至少一个字符,就只用这部分算哈希;
- 否则(没有
{、没有}、或{}之间为空),用整个 key 算哈希。
CLUSTER KEYSLOT 'foo{}{bar}' # {} 之间为空 → 用整个 "foo{}{bar}"
CLUSTER KEYSLOT '{}{bar}' # 同上
CLUSTER KEYSLOT 'foo{{bar}}' # 取第一个 { 到第一个 } 之间 → "{bar"
CLUSTER KEYSLOT 'foo{bar}{baz}' # 只取第一对 → "bar"
CLUSTER KEYSLOT '{bar}' # → "bar"
hash tag 的最大风险:数据倾斜。
如果用了范围太大的 tag(比如 {order}:1、{order}:2… 全部用 order 做 tag),所有订单数据会挤在同一个槽、同一个节点上,完全失去了分片的意义,还会造成:
- 那个节点内存爆满,其他节点空闲;
- 那个节点成为热点,QPS 打满;
- 无法通过扩容解决(因为一个槽不能被拆分到两个节点)。
正确用法:tag 的粒度要足够细,通常是用户 ID、订单 ID 这种高基数的标识:
# ✅ 好:粒度细,分布均匀
{user:1001}:profile, {user:1001}:orders, {user:1001}:cart
{order:88888}:info, {order:88888}:items
# ❌ 坏:粒度太粗,所有数据挤在一个槽
{user}:1001:profile, {user}:1002:profile
{orders}:1, {orders}:2
3. 搭建集群
3.1 配置文件
# 每个节点都要配(只改端口和目录)
port 7000
cluster-enabled yes # 【必须】开启集群模式
cluster-config-file nodes-7000.conf # 集群配置文件(节点自动维护,不要手改)
cluster-node-timeout 15000 # 节点超时(毫秒),故障判定的关键参数
dir /data/redis/7000
# 数据持久化
appendonly yes
appendfilename "appendonly-7000.aof"
# 密码(集群里所有节点密码必须相同)
requirepass mypassword
masterauth mypassword # 【必须】节点之间复制要用
# ---------- 重要的集群专属配置 ----------
# 当有槽不可用时,整个集群是否停止服务
cluster-require-full-coverage no # 【生产建议 no】
# 从节点是否允许在与主节点失联时提供读服务
cluster-allow-replica-migration yes # 从节点自动迁移(副本均衡)
# 从节点故障转移的最大数据落后时间(乘以 node-timeout)
cluster-replica-validity-factor 10
# 一个主节点至少要保留几个从节点才允许它的从节点迁移走
cluster-migration-barrier 1
# 容器/NAT 环境必配(见 3.5)
cluster-announce-ip 1.2.3.4
cluster-announce-port 7000
cluster-announce-bus-port 17000
# 7.0+ 允许在集群模式下发布分片 pub/sub
# 7.0+ 是否允许读取处于 importing/migrating 状态的槽
cluster-allow-reads-when-down no
cluster-allow-pubsubshard-when-down yes
注意 cluster-enabled yes 之后的限制:
- 只能用 db0(
SELECT 1会报错); - 不支持跨槽的多 key 操作。
3.2 一键创建集群
# 先启动 6 个节点(3 主 3 从)
for port in 7000 7001 7002 7003 7004 7005; do
mkdir -p /data/redis/$port
# 生成配置文件...
redis-server /data/redis/$port/redis.conf
done
# 用 redis-cli --cluster create 创建集群
# --cluster-replicas 1 表示每个主节点配 1 个从节点
redis-cli -a mypassword --cluster create \
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1
# 输出会显示分配方案,确认后输入 yes
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:7004 to 127.0.0.1:7000
Adding replica 127.0.0.1:7005 to 127.0.0.1:7001
Adding replica 127.0.0.1:7003 to 127.0.0.1:7002
Can I set the above configuration? (type 'yes' to accept): yes
...
[OK] All 16384 slots covered.
redis-cli --cluster 会自动做"主从错开分配"——尽量把主节点和它的从节点放在不同的机器上(通过 IP 判断)。如果所有节点都在同一台机器上(测试环境),它会警告但仍然创建。
3.3 验证集群
# 【必须加 -c】开启集群模式,客户端才会跟随重定向
redis-cli -c -p 7000 -a mypassword
# 集群状态
127.0.0.1:7000> CLUSTER INFO
cluster_state:ok # 【关键】ok 表示集群可用
cluster_slots_assigned:16384 # 已分配的槽数,必须是 16384
cluster_slots_ok:16384
cluster_slots_pfail:0 # 疑似下线的槽
cluster_slots_fail:0 # 已下线的槽
cluster_known_nodes:6
cluster_size:3 # 有槽的主节点数量(分片数)
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_sent:1234
cluster_stats_messages_received:1234
# 节点列表
127.0.0.1:7000> CLUSTER NODES
07c37df... 127.0.0.1:7000@17000 myself,master - 0 0 1 connected 0-5460
67ed2db... 127.0.0.1:7001@17001 master - 0 1785657600000 2 connected 5461-10922
292f8b3... 127.0.0.1:7002@17002 master - 0 1785657600000 3 connected 10923-16383
6ec2392... 127.0.0.1:7003@17003 slave 292f8b3... 0 1785657600000 3 connected
824fe11... 127.0.0.1:7004@17004 slave 07c37df... 0 1785657600000 1 connected
5eb0e0d... 127.0.0.1:7005@17005 slave 67ed2db... 0 1785657600000 2 connected
CLUSTER NODES 每行的字段:
<节点ID> <IP:端口@总线端口> <标志> <主节点ID或-> <上次PING时间> <上次PONG时间> <配置纪元> <连接状态> <负责的槽>
标志(flags):myself(自己)、master、slave、fail?(疑似下线 PFAIL)、fail(已下线 FAIL)、handshake、noaddr、nofailover。
# 更友好的槽分布查看
127.0.0.1:7000> CLUSTER SLOTS # 旧命令
127.0.0.1:7000> CLUSTER SHARDS # 7.0+ 推荐,按分片组织
127.0.0.1:7000> CLUSTER MYID # 自己的节点 ID
127.0.0.1:7000> CLUSTER COUNTKEYSINSLOT 8722 # 某个槽有多少 key
127.0.0.1:7000> CLUSTER GETKEYSINSLOT 8722 10 # 列出某槽的 key
# 集群健康检查(运维必用)
redis-cli -a mypassword --cluster check 127.0.0.1:7000
redis-cli -a mypassword --cluster info 127.0.0.1:7000
3.4 手动创建集群(理解原理)
一键创建屏蔽了细节,手动做一遍能理解集群是怎么形成的:
# 1. 节点互相认识(握手)
# 只需要让一个节点认识其他所有节点,Gossip 会自动扩散
redis-cli -p 7000 CLUSTER MEET 127.0.0.1 7001
redis-cli -p 7000 CLUSTER MEET 127.0.0.1 7002
redis-cli -p 7000 CLUSTER MEET 127.0.0.1 7003
redis-cli -p 7000 CLUSTER MEET 127.0.0.1 7004
redis-cli -p 7000 CLUSTER MEET 127.0.0.1 7005
# 稍等几秒,确认所有节点都互相认识
redis-cli -p 7000 CLUSTER NODES # 应该看到 6 个节点
# 2. 分配槽
redis-cli -p 7000 CLUSTER ADDSLOTS {0..5460}
redis-cli -p 7001 CLUSTER ADDSLOTS {5461..10922}
redis-cli -p 7002 CLUSTER ADDSLOTS {10923..16383}
# 7.0+ 有更方便的范围版本
redis-cli -p 7000 CLUSTER ADDSLOTSRANGE 0 5460
# 3. 设置主从关系(用节点 ID,不是 IP)
redis-cli -p 7003 CLUSTER REPLICATE <7002的节点ID>
redis-cli -p 7004 CLUSTER REPLICATE <7000的节点ID>
redis-cli -p 7005 CLUSTER REPLICATE <7001的节点ID>
# 4. 确认集群 state 变成 ok
redis-cli -p 7000 CLUSTER INFO | grep cluster_state
注意:cluster_state 只有在 16384 个槽全部被分配后才会变成 ok(除非 cluster-require-full-coverage no)。
3.5 容器/云环境必配 announce
和哨兵一样,Cluster 在 NAT/容器环境下有地址问题,而且更严重:
节点通过 Gossip 交换彼此的地址,如果一个节点广播的是自己的容器内网 IP,其他节点和客户端都连不上它。
cluster-announce-ip <宿主机IP或可路由的IP>
cluster-announce-port 7000 # 数据端口(映射后的)
cluster-announce-bus-port 17000 # 【别忘了总线端口】
这是容器化 Redis Cluster 最常见的坑。如果没配,集群会出现节点互相 fail?、客户端收到无法连接的重定向地址等诡异问题。
另外注意:集群总线端口(数据端口 + 10000)也必须在防火墙/安全组里放开,很多人只开了 6379 却忘了 16379。
4. 重定向机制
客户端连到任意节点执行命令,如果 key 不属于这个节点,会发生重定向。
4.1 MOVED 重定向
# 用普通模式连接(不加 -c)
$ redis-cli -p 7000
127.0.0.1:7000> SET foo bar
(error) MOVED 12182 127.0.0.1:7002
# ↑槽号 ↑正确的节点
# 用集群模式连接(加 -c),客户端自动跟随重定向
$ redis-cli -c -p 7000
127.0.0.1:7000> SET foo bar
-> Redirected to slot [12182] located at 127.0.0.1:7002
OK
127.0.0.1:7002> # 注意提示符变了,客户端已经切到 7002
MOVED 的含义:「这个槽永久属于另一个节点,请更新你的槽映射缓存,以后直接去那个节点」。
客户端的正确行为:
1. 收到 MOVED → 更新本地的 slot → node 映射表
2. 向新节点重发命令
3. 之后同一个槽的请求直接发给正确的节点(不再需要重定向)
智能客户端(smart client)的工作方式:
1. 启动时向任意节点执行 CLUSTER SLOTS(或 7.0+ 的 CLUSTER SHARDS)
→ 获取完整的槽映射表,缓存在本地
2. 每次请求前,本地计算 CRC16(key) & 16383 得到槽号,
查缓存直接连到正确的节点 → 【零重定向开销】
3. 只有在扩缩容/故障转移导致映射变化时才会收到 MOVED,
此时刷新映射表
所以在稳定状态下,Redis Cluster 的请求是"一跳直达"的,没有代理的额外延迟——这是它相比 Proxy 方案的核心优势。
4.2 ASK 重定向
ASK 只在槽迁移过程中出现,语义与 MOVED 完全不同。
127.0.0.1:7000> GET mykey
(error) ASK 8722 127.0.0.1:7002
ASK 的含义:「这个槽正在迁移中,你要的这个 key 已经被迁到目标节点了,请这一次去那个节点问一下。但槽的归属还没变,不要更新你的映射表」。
客户端处理 ASK 的正确流程(比 MOVED 复杂):
1. 收到 ASK <slot> <target>
2. 【不更新槽映射表】
3. 连接到 target 节点
4. 【先发送 ASKING 命令】 ← 关键!
5. 再重发原命令
为什么必须先发 ASKING:
目标节点的这个槽还处于 IMPORTING 状态,它默认会拒绝该槽的请求(返回 MOVED 指回源节点,否则就死循环了)。ASKING 是一个"一次性通行证",告诉目标节点"我是被源节点介绍来的,请破例处理这一次请求"。
ASKING 只对紧接着的下一条命令有效。
4.3 MOVED vs ASK 对比
| MOVED | ASK | |
|---|---|---|
| 含义 | 槽永久属于目标节点 | 槽正在迁移,这个 key 暂时在目标节点 |
| 客户端是否更新槽映射 | 是 | 否 |
| 是否需要先发 ASKING | 否 | 是 |
| 发生时机 | 客户端的映射表过期(扩缩容后、故障转移后、首次连错节点) | 仅在槽迁移过程中 |
| 持续性 | 永久(直到下次拓扑变化) | 一次性 |
记忆:MOVED 是"搬家了,记住新地址",ASK 是"这次先去那边找找,但户口还没迁"。
5. 在线扩缩容
这是 Cluster 相比客户端分片和 Twemproxy 最重要的优势。
5.1 扩容(添加节点)
# ---------- 第 1 步:启动新节点 ----------
# 准备 7006(新主)和 7007(新从)的配置并启动
# ---------- 第 2 步:把新节点加入集群 ----------
# 加为主节点(先加进来,此时它还没有任何槽)
redis-cli -a pwd --cluster add-node 127.0.0.1:7006 127.0.0.1:7000
# ↑新节点 ↑集群里任意已有节点
# 加为从节点(--cluster-slave)
redis-cli -a pwd --cluster add-node 127.0.0.1:7007 127.0.0.1:7000 \
--cluster-slave --cluster-master-id <7006的节点ID>
# 确认加入成功
redis-cli -c -p 7000 CLUSTER NODES
# ---------- 第 3 步:迁移槽(reshard)----------
# 交互式
redis-cli -a pwd --cluster reshard 127.0.0.1:7000
# 会依次询问:
# How many slots do you want to move? → 4096(16384/4,4 个分片各 4096)
# What is the receiving node ID? → <7006的节点ID>
# Source node #1: → all(从所有节点均匀取)或指定节点ID,done 结束
# 非交互式(脚本化)
redis-cli -a pwd --cluster reshard 127.0.0.1:7000 \
--cluster-from <源节点ID1>,<源节点ID2>,<源节点ID3> \
--cluster-to <7006的节点ID> \
--cluster-slots 4096 \
--cluster-yes
# ---------- 第 4 步:验证 ----------
redis-cli -a pwd --cluster check 127.0.0.1:7000
# 应该显示 [OK] All 16384 slots covered,且各节点槽数均衡
# ---------- 可选:重新均衡 ----------
redis-cli -a pwd --cluster rebalance 127.0.0.1:7000
# 自动计算并均衡各节点的槽数
redis-cli -a pwd --cluster rebalance 127.0.0.1:7000 --cluster-use-empty-masters
5.2 槽迁移的底层流程
理解这个流程是理解 ASK 重定向的前提。迁移是以槽为单位、逐个 key 进行的:
迁移槽 S 从节点 A(源)到节点 B(目标):
1. 在 B 上标记:CLUSTER SETSLOT <S> IMPORTING <A的节点ID>
→ B 准备接收槽 S
2. 在 A 上标记:CLUSTER SETSLOT <S> MIGRATING <B的节点ID>
→ A 准备迁出槽 S
3. 循环搬移 key:
├─ 在 A 上执行 CLUSTER GETKEYSINSLOT <S> <count>,拿到一批 key
├─ 对每个 key 执行:
│ MIGRATE <B的IP> <B的端口> <key> 0 <timeout>
│ (MIGRATE 内部 = DUMP 序列化 + 网络传输 + 目标节点 RESTORE + 源节点 DEL,
│ 整个过程是【原子的】,且是【同步阻塞】的)
└─ 直到 CLUSTER COUNTKEYSINSLOT <S> 返回 0
4. 【迁移期间的请求处理】(这是 ASK 存在的原因):
客户端向 A 请求槽 S 里的 key:
├─ 如果 key 还在 A 上 → A 【正常处理】
└─ 如果 key 已经不在 A 上 → A 返回 【ASK <S> <B>】
→ 客户端去 B,先发 ASKING 再发命令
(B 因为槽处于 IMPORTING 状态,只接受带 ASKING 的请求)
5. 迁移完成,更新槽的归属:
├─ 在 B 上:CLUSTER SETSLOT <S> NODE <B的节点ID>
├─ 在 A 上:CLUSTER SETSLOT <S> NODE <B的节点ID>
└─ 【最好在所有主节点上都执行】,加快配置扩散
→ 此后客户端访问槽 S 会收到 MOVED 指向 B
关键认识:
- 迁移期间集群仍然可以正常读写(这就是"在线"扩容),代价是那些正在迁移的 key 会多一次 ASK 重定向;
MIGRATE是同步阻塞的——迁移一个大 key 时,源节点和目标节点都会阻塞。这是"大 key 会让扩容极其痛苦"的原因(见 5.4);- 一个槽不能被拆分到两个节点——这决定了 hash tag 用错会导致无法通过扩容解决倾斜问题。
5.3 缩容(删除节点)
顺序很重要:必须先迁走槽,才能删除节点。
# ---------- 第 1 步:把要删除节点的槽全部迁走 ----------
redis-cli -a pwd --cluster reshard 127.0.0.1:7000 \
--cluster-from <要删除的节点ID> \
--cluster-to <接收节点ID> \
--cluster-slots 4096 \
--cluster-yes
# 确认它已经没有槽了
redis-cli -c -p 7000 CLUSTER NODES | grep <要删除的节点ID>
# 该行末尾不应该有槽范围
# ---------- 第 2 步:先删从节点,再删主节点 ----------
redis-cli -a pwd --cluster del-node 127.0.0.1:7000 <从节点ID>
redis-cli -a pwd --cluster del-node 127.0.0.1:7000 <主节点ID>
# del-node 内部做的事:
# 1. 向集群里所有其他节点发送 CLUSTER FORGET <节点ID>
# 2. 关闭被删除的节点
注意 CLUSTER FORGET 的 60 秒黑名单:
执行 CLUSTER FORGET <nodeID> 后,该节点会被加入一个60 秒的黑名单,期间不会通过 Gossip 重新认识它。
这个设计是必需的:如果没有黑名单,你在节点 A 上 forget 了节点 X,但节点 B 还认识 X,B 就会通过 Gossip 把 X 重新介绍给 A,导致 forget 失败。60 秒的窗口保证你有足够时间在所有节点上执行 forget。
所以要在 60 秒内对所有节点执行 CLUSTER FORGET(--cluster del-node 会自动帮你做这件事)。
5.4 大 key 让扩容变成灾难
这是生产环境的真实痛点,必须理解。
MIGRATE 命令是【同步阻塞】的:
├─ 源节点:DUMP 序列化整个 key(一个 1GB 的 hash 要序列化 1GB)
├─ 网络:传输序列化后的数据
├─ 目标节点:RESTORE 反序列化并构建数据结构
└─ 整个过程中,源节点和目标节点的【主线程都被阻塞】
一个 1GB 的大 key 迁移可能阻塞几十秒,期间:
- 源节点和目标节点都无法响应任何请求;
- 客户端大面积超时;
- 哨兵/集群可能误判节点下线,触发不必要的故障转移;
MIGRATE本身可能超时失败,导致迁移中断、槽处于不一致的中间状态。
所以扩容前必须先处理大 key(第 15 篇讲排查手法):
redis-cli --bigkeys # 找大 key
redis-cli --memkeys # 按内存排序
MEMORY USAGE <key> # 单个 key 的内存
大 key 的处理:拆分(把一个大 hash 拆成多个小 hash)、迁到独立实例、或者改用其他存储。
另外可以调大 MIGRATE 的 timeout 参数,但那只是让它别失败,阻塞该发生的还是会发生。
6. Gossip 协议与集群通信
6.1 集群总线
每个节点除了监听数据端口(如 6379),还监听一个集群总线端口 = 数据端口 + 10000(如 16379)。
- 总线用二进制协议(不是 RESP),为了高效;
- 节点之间两两建立 TCP 连接(全连接网状拓扑)。所以 N 个节点有
N*(N-1)/2条连接——这是集群规模不能太大(设计上 <= 1000 节点)的原因之一。
6.2 五种消息类型
| 消息 | 用途 |
|---|---|
| MEET | 邀请一个新节点加入集群(CLUSTER MEET 触发) |
| PING | 心跳,携带自己的信息和随机几个其他节点的信息(gossip 部分) |
| PONG | 对 PING/MEET 的回复,也用于主动广播配置变更 |
| FAIL | 广播"某个节点已被判定为客观下线(FAIL)" |
| PUBLISH | 转发 pub/sub 消息(7.0 前会全集群广播,见第 9 篇) |
7.0+ 还有 MODULE、PUBLISHSHARD(分片 pub/sub)等。
6.3 Gossip 的工作方式
每个节点每秒随机选取部分节点发送 PING:
每 100ms 执行一次 clusterCron:
1. 从所有已知节点里【随机选 5 个】,挑其中 PONG 回复时间最久的那个发 PING
→ 这保证了"最久没联系的节点优先被检查"
2. 【强制机制】遍历所有节点,如果某个节点超过
cluster-node-timeout / 2 没收到过它的 PONG,
【立即】向它发 PING(不等随机选中)
→ 这保证了故障检测的及时性上界
PING/PONG 消息里携带的 gossip 信息:
除了发送方自己的状态(节点 ID、负责的槽位图、配置纪元、是主还是从等),还会随机挑选大约 1/10 的已知节点的信息捎带过去(每个 gossip entry 约 104 字节)。
这就是 Gossip(流言)协议的精髓:信息像流言一样在节点间自然扩散,不需要中心节点广播。一条信息通常在 O(logN) 轮内传遍全集群。
消息大小估算(回到"为什么是 16384 个槽"):
clusterMsg 头部 ≈ 2KB(其中槽位图占 2048 字节)
+ gossip 部分:节点数/10 × 104 字节
所以 1000 个节点的集群,一条 PING 消息约 2KB + 100 × 104B ≈ 12KB。如果槽是 65536 个,头部就变成 8KB,总消息变成 18KB——增长 50%,而每个节点每秒要发好几条。
6.4 故障检测:PFAIL 与 FAIL
Cluster 的故障检测机制和哨兵的 SDOWN/ODOWN 概念类似,但实现完全不同(去中心化)。
PFAIL(Possible Failure,疑似下线)
节点 A 向节点 B 发 PING,超过 cluster-node-timeout 没收到 PONG
→ A 在自己的视角把 B 标记为 PFAIL
这相当于哨兵的 SDOWN,是单个节点的主观判断。
CLUSTER NODES 里会显示 fail? 标志
67ed2db... 127.0.0.1:7001@17001 master,fail? - ...
FAIL(客观下线)
1. 节点 A 把 B 标记为 PFAIL
2. A 通过 gossip 消息把"我认为 B 是 PFAIL"的信息传播出去
3. 其他节点收到后,在自己的 B 节点记录里追加一条【故障报告】
(每条报告带时间戳,超过 cluster-node-timeout * 2 的旧报告会被清理)
4. 【任意一个主节点】发现:
持有 B 的 PFAIL 报告的【主节点数量】>= 集群中主节点总数的一半 + 1
→ 把 B 标记为 FAIL
5. 该节点【广播一条 FAIL 消息】给全集群
6. 所有节点收到 FAIL 消息后立即把 B 标记为 FAIL(不需要自己判断)
三个关键点:
- 只有主节点的投票算数(从节点的 PFAIL 报告不计入统计);
- 需要超过半数的主节点同意——这是防止网络分区的少数派误判并触发故障转移;
- FAIL 是通过专门的广播消息传播的(不是靠 gossip 慢慢扩散),所以判定后能快速让全集群达成一致。
对从节点的 PFAIL 不会升级为 FAIL——因为从节点下线不需要任何自动处理(只是标记为不可用),和哨兵的设计一致。
6.5 从节点自动故障转移
主节点被标记 FAIL 后,它的从节点会自动竞选成为新主节点:
1. 【资格检查】从节点检查自己是否有资格:
├─ 与主节点的断开时间是否过长?
│ 如果 断开时间 > cluster-node-timeout × cluster-replica-validity-factor(默认10)
│ → 【放弃竞选】(数据太旧了,不配当主节点)
└─ 自己是否配置了 cluster-replica-no-failover?→ 放弃
2. 【延迟计算】不同从节点等待不同的时间才发起选举:
delay = 500ms(固定,等 FAIL 状态在集群中扩散)
+ random(0~500ms)(避免同时发起)
+ rank × 1000ms
↑ rank 是【按复制偏移量 offset 排名】:offset 最大的 rank=0,第二大 rank=1...
→ 【数据最新的从节点最先发起选举,最可能当选】
3. 【发起选举】延迟结束后:
├─ 把集群的 currentEpoch + 1
└─ 向所有【主节点】广播 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
4. 【主节点投票】每个主节点在每个 epoch 里只投一票:
├─ 检查请求方的主节点是否真的是 FAIL 状态
├─ 检查自己在这个 epoch 是否已投过票
├─ 检查请求方的 configEpoch 是否合法(不能比自己记录的更旧)
└─ 通过检查 → 回复 FAILOVER_AUTH_ACK
5. 【当选】从节点收到的票数 > 主节点总数/2 → 当选:
├─ 把自己的 configEpoch 设为这次选举的 currentEpoch
├─ 执行 REPLICAOF NO ONE,变成主节点
├─ 接管原主节点的所有槽
└─ 【广播一个 PONG 消息】宣告自己是这些槽的新主人
(其他节点因为它的 configEpoch 更大而接受这个声明)
6. 原主节点恢复后,发现槽已经属于别人(configEpoch 更大)
→ 自动变成新主节点的从节点
这个流程与哨兵的对比:
| 哨兵 | Cluster | |
|---|---|---|
| 谁做故障检测 | 独立的哨兵进程 | 每个节点自己(去中心化) |
| 谁投票 | 哨兵 | 主节点 |
| 谁执行转移 | 选出的哨兵 Leader | 候选从节点自己 |
| 怎么保证顺序 | Raft 选 Leader | rank 延迟机制(offset 大的先发起) |
| 需要额外进程 | 是(3+ 个哨兵) | 否(功能内置) |
Cluster 的 rank 延迟机制很巧妙:不需要像哨兵那样先选 Leader 再由 Leader 选主,而是让数据最新的从节点先发起选举,它通常能率先拿到多数票。这简化了流程也减少了一轮通信。
6.6 configEpoch 冲突解决
每个主节点有一个 configEpoch,用来解决"两个节点都声称拥有同一个槽"的冲突:configEpoch 更大的胜出。
但在极端情况下(比如管理员手动操作、或者网络分区恢复后),可能出现两个节点有相同的 configEpoch。Redis 有一个自动修复机制:
节点检测到另一个节点的 configEpoch 与自己相同,且对方的节点 ID 更大(字典序)
→ 自己把 currentEpoch + 1 并设为自己的新 configEpoch
→ 打破平局
6.7 cluster-require-full-coverage
cluster-require-full-coverage no # 【生产建议 no】(默认是 yes)
yes(默认):如果有任何一个槽不可用(负责它的主节点和所有从节点都挂了),整个集群停止服务——所有节点对所有请求返回:
(error) CLUSTERDOWN The cluster is down
no:只有访问那些不可用的槽时才报错,其他槽正常服务。
为什么默认是 yes:为了数据一致性——如果部分槽不可用还继续服务,客户端可能以为"这个 key 不存在",进而做出错误的业务决策(比如缓存穿透去写了一条错误数据)。
为什么生产建议 no:可用性考虑。一个分片挂掉导致整个集群 100% 不可用通常比"只有 1/3 的 key 不可用"糟糕得多。大多数缓存场景更希望部分可用。
这个选择要根据业务性质决定:纯缓存 → no;数据一致性关键 → 保持 yes 并确保每个分片都有足够的从节点。
7. Cluster 的限制
这些限制必须在架构设计阶段就知道,否则迁移时会很痛苦。
7.1 只能使用 db0
127.0.0.1:7000> SELECT 1
(error) ERR SELECT is not allowed in cluster mode
原因:多 db 与槽的概念冲突(一个槽在一个节点上,但 db 是节点内的划分,两者交叉会让路由逻辑极其复杂)。
影响:如果现有代码用了多 db 做业务隔离,迁移到 Cluster 时必须改成 key 前缀方案(第 1 篇就建议不要用多 db,原因之一就是这个)。
7.2 跨槽的多 key 操作受限
127.0.0.1:7000> MGET user:1 user:2
(error) CROSSSLOT Keys in request don't hash to the same slot
127.0.0.1:7000> SINTERSTORE dst set1 set2
(error) CROSSSLOT Keys in request don't hash to the same slot
受影响的命令:MGET、MSET、MSETNX、SINTER/SUNION/SDIFF(及其 STORE 版本)、ZUNIONSTORE/ZINTERSTORE、RENAME、SMOVE、BITOP、PFMERGE、PFCOUNT(多 key)、LMOVE/RPOPLPUSH、COPY、GEORADIUS ... STORE、SORT ... STORE、XREAD 多 stream、以及任何涉及多个 key 的 Lua 脚本。
解决方案:
| 方案 | 说明 | 代价 |
|---|---|---|
| hash tag | {user1001}:name、{user1001}:age 强制同槽 |
数据倾斜风险 |
| 客户端拆分并行请求 | 把 MGET 按节点分组,并行发多个请求再合并 |
成熟客户端(go-redis、Lettuce)自动做,MGET 可以照常用 |
| 改用 pipeline | 拆成单 key 命令放进 pipeline | 客户端要按节点分组 |
| 重新设计数据模型 | 把需要一起操作的数据放进一个 hash | 最彻底 |
重要提示:大部分成熟客户端已经透明处理了 MGET/MSET —— 它们会把 key 按槽分组,向各节点并行发送请求,再合并结果。所以在 go-redis / Lettuce 里 MGET 跨槽是能用的(但底层是多个请求)。只有直接用 redis-cli 或简单客户端时才会报 CROSSSLOT。
7.3 Lua 脚本必须所有 key 同槽
-- 脚本里所有通过 KEYS 传入的 key 必须在同一个槽
EVAL "return redis.call('GET', KEYS[1]) .. redis.call('GET', KEYS[2])" 2 k1 k2
-- 如果 k1 和 k2 不同槽 → CROSSSLOT 错误
而且7.0+ 会检查脚本是否访问了没在 KEYS 里声明的 key:
(error) ERR ... Script attempted to access a non local key in a cluster node
这就是第 8 篇强调"key 必须通过 KEYS 传入"的硬性原因。
7.0 提供了一个逃生舱:脚本声明 allow-cross-slot-keys 标志可以访问跨槽 key,但这样做很危险(脚本会在错误的节点上操作不属于它的 key),只在明确知道后果时用。
7.4 事务受限
MULTI/EXEC 里的所有 key 必须在同一个槽。WATCH 也一样。
7.5 pub/sub 的问题
- 7.0 之前:
PUBLISH会广播到集群所有节点(保证订阅者连任意节点都能收到),造成流量放大(N 个节点 = N 倍流量); - 7.0+:引入
SPUBLISH/SSUBSCRIBE分片 pub/sub,消息只在负责该槽的分片内传播。代价是订阅者必须连到正确的节点。
7.6 其他限制
| 限制 | 说明 |
|---|---|
KEYS/SCAN 只扫当前节点 |
要遍历全部数据必须对每个主节点分别执行 |
DBSIZE 只是当前节点的 |
集群总 key 数要把所有主节点加起来 |
FLUSHALL 只清当前节点 |
redis-cli --cluster call <node> FLUSHALL 才能全清 |
不能跨槽的 RENAME |
因为要移动数据 |
| 客户端必须支持集群协议 | 老客户端/自研客户端可能只支持单机 |
| 一个槽不可拆分 | 单个槽的数据量过大(hash tag 用错)无法通过扩容解决 |
| 不支持"读己之写" | 从节点异步复制,和主从一样有延迟问题 |
8. 运维实战
8.1 常用运维命令
# ---------- 健康检查 ----------
redis-cli -a pwd --cluster check 127.0.0.1:7000 # 检查槽覆盖和一致性
redis-cli -a pwd --cluster info 127.0.0.1:7000 # 简要信息
redis-cli -a pwd --cluster fix 127.0.0.1:7000 # 修复槽不一致(谨慎!)
# ---------- 对所有节点执行命令(非常有用)----------
redis-cli -a pwd --cluster call 127.0.0.1:7000 DBSIZE
redis-cli -a pwd --cluster call 127.0.0.1:7000 INFO memory
redis-cli -a pwd --cluster call 127.0.0.1:7000 CONFIG SET maxmemory 4gb
# 只对主节点执行
redis-cli -a pwd --cluster call --cluster-only-masters 127.0.0.1:7000 DBSIZE
# ---------- 手动故障转移 ----------
# 在【从节点】上执行,让它变成主节点(用于计划内维护)
redis-cli -c -p 7003 CLUSTER FAILOVER
# 变体:
CLUSTER FAILOVER FORCE # 主节点不可达时也强制转移(不等主节点确认)
CLUSTER FAILOVER TAKEOVER # 【最危险】不需要主节点集群授权,单方面接管
# 只在灾难恢复时用,可能造成数据丢失和脑裂
# ---------- 槽管理 ----------
CLUSTER ADDSLOTS 1 2 3
CLUSTER ADDSLOTSRANGE 0 5460 # 7.0+
CLUSTER DELSLOTS 1 2 3
CLUSTER DELSLOTSRANGE 0 100 # 7.0+
CLUSTER SETSLOT <slot> NODE <nodeID>
CLUSTER SETSLOT <slot> MIGRATING <nodeID>
CLUSTER SETSLOT <slot> IMPORTING <nodeID>
CLUSTER SETSLOT <slot> STABLE # 清除 migrating/importing 状态
CLUSTER FLUSHSLOTS # 清空本节点的所有槽(危险)
# ---------- 节点管理 ----------
CLUSTER MEET <ip> <port>
CLUSTER FORGET <nodeID> # 60 秒黑名单
CLUSTER REPLICATE <masterNodeID> # 让本节点成为指定节点的从节点
CLUSTER RESET [HARD|SOFT] # 重置节点(HARD 会改变节点 ID)
CLUSTER SLAVES <nodeID> # 查看某主节点的从节点(旧名)
CLUSTER REPLICAS <nodeID> # 5.0+ 新名
# ---------- 备份与迁移 ----------
redis-cli -a pwd --cluster backup 127.0.0.1:7000 /backup/dir # 备份所有节点的 RDB
redis-cli -a pwd --cluster import <目标集群> --cluster-from <源单机> # 从单机导入
8.2 从单机/主从迁移到 Cluster
# 方案一:用 redis-cli --cluster import(适合小数据量)
redis-cli -a pwd --cluster import 127.0.0.1:7000 \
--cluster-from 192.168.1.10:6379 \
--cluster-from-askpass
# 原理:SCAN 源实例的所有 key,逐个 MIGRATE 到集群
# 缺点:慢、期间源实例的新写入不会同步(需要停写)
# 方案二:redis-shake(推荐,阿里开源,支持全量 + 增量)
# 支持单机→集群、集群→集群、且能持续同步增量,实现平滑迁移
# 方案三:双写 + 逐步切读(业务改造,最可控)
# 1. 业务同时写老 Redis 和新集群
# 2. 用工具把老数据全量导入新集群
# 3. 逐步把读流量切到新集群,观察
# 4. 完全切换后下线老实例
8.3 监控指标
# ---------- 集群整体 ----------
CLUSTER INFO
cluster_state:ok # 【最关键】必须是 ok
cluster_slots_assigned:16384 # 必须是 16384
cluster_slots_ok:16384
cluster_slots_pfail:0 # 【告警】> 0 说明有节点疑似下线
cluster_slots_fail:0 # 【告警】> 0 说明有槽不可用
cluster_known_nodes:6
cluster_size:3 # 分片数(有槽的主节点数)
cluster_current_epoch:6
cluster_stats_messages_sent # 集群总线消息量
cluster_stats_messages_received
# ---------- 单节点 ----------
INFO replication # 主从关系是否正常
INFO memory # 各节点内存是否均衡
INFO clients
INFO stats | grep -E 'instantaneous_ops|keyspace_hits'
# ---------- 数据均衡性检查 ----------
redis-cli -a pwd --cluster call --cluster-only-masters 127.0.0.1:7000 DBSIZE
# 各节点的 key 数应该接近,差异大说明:
# 1. hash tag 用错导致倾斜
# 2. 槽分配不均(用 rebalance 修复)
# 3. 有大 key
redis-cli -a pwd --cluster call --cluster-only-masters 127.0.0.1:7000 INFO memory | grep used_memory_human
告警项:
| 指标 | 阈值 |
|---|---|
cluster_state |
!= ok 立即告警 |
cluster_slots_assigned |
!= 16384 立即告警 |
cluster_slots_pfail / cluster_slots_fail |
> 0 立即告警 |
cluster_known_nodes |
变化时告警(有节点加入/离开) |
cluster_size |
变化时告警 |
| 各节点内存偏差 | > 30% 告警(数据倾斜) |
| 主从关系 | 某主节点没有从节点时告警 |
8.4 常见故障排查
问题一:cluster_state:fail
# 原因 1:有槽没被分配
redis-cli -c -p 7000 CLUSTER INFO | grep slots_assigned # 是否 16384
redis-cli -a pwd --cluster check 127.0.0.1:7000 # 看哪些槽缺失
redis-cli -a pwd --cluster fix 127.0.0.1:7000 # 尝试自动修复
# 原因 2:有分片完全挂了(主从都挂)且 cluster-require-full-coverage yes
redis-cli -c -p 7000 CLUSTER NODES | grep fail
# 原因 3:网络分区,当前节点在少数派一侧
问题二:节点之间互相 fail?
# 排查
# 1. 集群总线端口(数据端口+10000)是否被防火墙拦了
telnet <node_ip> 17000
# 2. 容器/NAT 环境是否配了 announce
redis-cli -p 7000 CONFIG GET cluster-announce-ip
# 3. cluster-node-timeout 是否太小
redis-cli -p 7000 CONFIG GET cluster-node-timeout
# 同机房建议 15000,跨机房 30000+
# 4. 节点是否有阻塞(fork、慢命令、swap)
redis-cli -p 7000 INFO stats | grep latest_fork_usec
redis-cli -p 7000 SLOWLOG GET 10
问题三:槽处于 migrating/importing 中间状态(迁移中断)
redis-cli -a pwd --cluster check 127.0.0.1:7000
# [WARNING] Node 127.0.0.1:7000 has slots in migrating state (8722).
# 处理方式一:让工具自动修复
redis-cli -a pwd --cluster fix 127.0.0.1:7000
# 处理方式二:手动处理
# 1. 确认这个槽的 key 在哪个节点
redis-cli -c -p 7000 CLUSTER COUNTKEYSINSLOT 8722
redis-cli -c -p 7002 CLUSTER COUNTKEYSINSLOT 8722
# 2. 要么继续完成迁移,要么回退
CLUSTER SETSLOT 8722 STABLE # 在两个节点上都执行,清除中间状态
CLUSTER SETSLOT 8722 NODE <确定的归属节点ID> # 在所有主节点上执行
问题四:数据倾斜
# 1. 看各节点的 key 数和内存
redis-cli -a pwd --cluster call --cluster-only-masters 127.0.0.1:7000 DBSIZE
# 2. 如果是槽分配不均 → rebalance
redis-cli -a pwd --cluster rebalance 127.0.0.1:7000
# 3. 如果是 hash tag 用错导致某个槽超大 → 【无法通过扩容解决】
# 找出最大的槽
for slot in $(seq 0 16383); do
n=$(redis-cli -c -p 7000 CLUSTER COUNTKEYSINSLOT $slot)
[ "$n" -gt 100000 ] && echo "slot $slot: $n keys"
done
# 只能改数据模型(换更细粒度的 hash tag)并迁移数据
# 4. 如果是大 key → 拆分(见第 15 篇)
redis-cli --bigkeys
9. Cluster vs 哨兵 vs Proxy
| 维度 | 主从 + 哨兵 | Redis Cluster | Proxy(Codis/Twemproxy) |
|---|---|---|---|
| 数据分片 | ❌ 单主节点 | ✅ 16384 槽 | ✅ |
| 写扩展 | ❌ | ✅ | ✅ |
| 存储扩展 | ❌ 受单机内存限制 | ✅ | ✅ |
| 高可用 | ✅ 哨兵 | ✅ 内置 | 依赖底层的主从+哨兵 |
| 在线扩容 | ❌ | ✅ reshard | Twemproxy ❌ / Codis ✅ |
| 额外组件 | 3+ 个哨兵进程 | 无 | Proxy 集群 + ZK/etcd |
| 网络跳数 | 1 跳(直连主节点) | 1 跳(智能客户端) | 2 跳(多一层代理) |
| 客户端要求 | 支持哨兵协议 | 支持集群协议 | 无(像连单机) |
| 多 db | ✅ 16 个 | ❌ 只有 db0 | 通常 ❌ |
| 跨节点多 key | ✅(单节点无此问题) | ❌ 需 hash tag | ❌ |
| 运维复杂度 | 中 | 中高 | 高 |
| 适用规模 | 数据 < 10GB | 数据 10GB ~ TB | 遗留系统/特殊需求 |
选型建议:
- 数据量 < 10GB、写 QPS < 几万 → 主从 + 哨兵足够,简单可靠;
- 数据量或写量超过单机 → Redis Cluster(官方方案,无中间层,1 跳直达);
- 必须支持多 db、或客户端无法改造 → 考虑 Proxy(但这通常意味着架构债,应该规划改造);
- 云环境 → 直接用云厂商的托管版(阿里云 Tair、AWS ElastiCache、腾讯云 Redis),它们通常提供 Proxy 模式和 Cluster 模式两种选择,还免去了运维。
10. 高频面试题
Q1:Redis Cluster 为什么是 16384 个槽?
antirez 本人给出三个理由:
- 心跳包大小:Gossip 消息(
clusterMsg)里带一个表示"我负责哪些槽"的位图。16384 槽 =16384/8 = 2048 字节 = 2KB;如果是 65536 槽就是 8KB。因为每个节点每秒都要向多个节点发心跳,8KB 会让集群总线流量增加 4 倍。2KB 是可接受的开销; - 集群设计上不超过 1000 个节点(超过这个规模 Gossip 消息量不可控)。
16384 / 1000 ≈ 16个槽每节点,这个粒度足够均衡分配。槽太少(如 1024)在 1000 节点时每节点只有 1 个槽,无法均衡; - 位图压缩率:位图会用游程编码压缩,节点少时用 65536 槽虽然压缩后也不大,但没必要为极端情况付出常态 4 倍的开销。
记忆:2KB 位图 + 1000 节点上限 + 压缩率。
Q2:key 是怎么映射到节点的?
两步:
- key → 槽:
slot = CRC16(key) & 16383(16384 是 2 的幂,用位与代替取模); - 槽 → 节点:每个节点负责一段连续的槽范围,这个映射关系通过 Gossip 在所有节点间同步,每个节点都有完整的槽映射表。
智能客户端的做法:启动时执行 CLUSTER SLOTS(或 7.0+ 的 CLUSTER SHARDS)获取完整映射表并缓存,之后本地计算槽号直接连正确的节点,实现"一跳直达",没有代理的额外延迟。只在收到 MOVED 时刷新映射表。
Q3:MOVED 和 ASK 有什么区别?
| MOVED | ASK | |
|---|---|---|
| 含义 | 槽永久属于目标节点 | 槽正在迁移,这个 key 暂时在目标节点 |
| 客户端是否更新槽映射表 | 是 | 否 |
是否要先发 ASKING |
否 | 是 |
| 发生时机 | 客户端映射表过期(扩缩容后、故障转移后、首次连错节点) | 仅在槽迁移过程中 |
| 持续性 | 永久 | 一次性 |
为什么 ASK 必须先发 ASKING:目标节点的该槽处于 IMPORTING 状态,默认会拒绝该槽的请求(否则就和源节点的 MOVED 形成死循环)。ASKING 是一次性通行证,告诉目标节点"我是被源节点介绍来的,请破例处理这一次"。ASKING 只对紧接着的下一条命令有效。
记忆:MOVED 是"搬家了,记住新地址";ASK 是"这次先去那边找,但户口还没迁"。
Q4:槽迁移的完整流程是什么?为什么迁移期间集群还能用?
迁移槽 S 从 A 到 B:
1. B 上:CLUSTER SETSLOT S IMPORTING <A的ID>
2. A 上:CLUSTER SETSLOT S MIGRATING <B的ID>
3. 循环搬 key:
├─ A 上 CLUSTER GETKEYSINSLOT S <count> 取一批 key
├─ 对每个 key:MIGRATE <B> <key> ...
│ (MIGRATE = DUMP + 传输 + 目标 RESTORE + 源 DEL,原子且【同步阻塞】)
└─ 直到 CLUSTER COUNTKEYSINSLOT S 返回 0
4. 【迁移期间的请求】客户端访问 A 上槽 S 的 key:
├─ key 还在 A → A 正常处理
└─ key 已迁走 → A 返回 ASK <S> <B> → 客户端去 B(先 ASKING)
5. 完成后在所有主节点上:CLUSTER SETSLOT S NODE <B的ID>
→ 之后访问会得到 MOVED
能在线服务的原因:迁移是以 key 为单位逐个进行的,任何时刻每个 key 都明确在 A 或 B 上,通过 ASK 机制精确地把请求引导到 key 实际所在的节点。代价只是那些已迁移的 key 会多一次重定向。
Q5:为什么大 key 会让扩容变成灾难?
因为 MIGRATE 是同步阻塞的:
MIGRATE 内部 = 源节点 DUMP 序列化整个 key → 网络传输 → 目标节点 RESTORE 反序列化 → 源节点 DEL
整个过程中,【源节点和目标节点的主线程都被阻塞】
一个 1GB 的大 key 迁移可能阻塞几十秒,后果:
- 两个节点都无法响应任何请求,客户端大面积超时;
- 集群可能因为节点无响应而误判它下线(PFAIL→FAIL),触发不必要的故障转移;
MIGRATE本身可能超时失败,导致槽卡在 migrating/importing 的中间状态,需要人工修复。
所以扩容前必须先处理大 key(redis-cli --bigkeys/--memkeys 排查,拆分或迁到独立实例)。调大 MIGRATE timeout 只能让它别失败,阻塞照样发生。
Q6:什么是 hash tag?用错了会怎样?
hash tag:如果 key 中包含 {...},只用第一对 {} 里的内容计算 CRC16。这样可以让相关的 key 强制落在同一个槽,从而支持多 key 操作。
CLUSTER KEYSLOT '{user1001}:name' # 5798
CLUSTER KEYSLOT '{user1001}:age' # 5798(相同)
MGET {user1001}:name {user1001}:age # 可以执行
精确规则:找第一个 {,再找它之后的第一个 },如果两者之间至少有一个字符就用这部分算哈希,否则用整个 key。
用错的最大风险:数据倾斜。
# ❌ 灾难:tag 粒度太粗,所有订单挤在【同一个槽、同一个节点】
{orders}:1, {orders}:2, {orders}:3 ...
后果:那个节点内存爆满而其他节点空闲、成为 QPS 热点、而且无法通过扩容解决(因为一个槽不能被拆分到两个节点)。只能改数据模型重新迁移。
正确用法:tag 用高基数的标识(用户 ID、订单 ID):{user:1001}:profile、{order:88888}:info。
Q7:Cluster 是怎么做故障检测和故障转移的?
故障检测(类似哨兵的 SDOWN/ODOWN,但去中心化):
1. PFAIL(疑似下线):节点 A 向 B 发 PING,超过 cluster-node-timeout 没收到 PONG
→ A 在自己视角标记 B 为 PFAIL
2. A 通过 gossip 把"B 是 PFAIL"传播出去,其他节点记录【故障报告】(带时间戳)
3. 任意主节点发现:持有 B 的 PFAIL 报告的【主节点数】>= 主节点总数/2 + 1
→ 标记 B 为 FAIL
4. 【广播 FAIL 消息】给全集群,所有节点立即标记 B 为 FAIL
关键:只有主节点的报告算数、需要超过半数主节点同意(防止分区少数派误判)、FAIL 用专门的广播消息(不靠 gossip 慢慢扩散)。
故障转移(从节点自己竞选,不需要外部协调者):
1. 【资格检查】断开时间 > cluster-node-timeout × cluster-replica-validity-factor(10)
→ 放弃竞选(数据太旧)
2. 【延迟计算】delay = 500ms + random(0~500ms) + rank × 1000ms
rank = 按复制 offset 排名(offset 最大的 rank=0)
→ 【数据最新的从节点最先发起选举】
3. 【发起选举】currentEpoch+1,向所有【主节点】广播 FAILOVER_AUTH_REQUEST
4. 【主节点投票】每个 epoch 只投一票
5. 【当选】得票 > 主节点总数/2 → 提升自己为主节点、接管槽、
把 configEpoch 设为本次 epoch、【广播 PONG】宣告新归属
6. 原主节点恢复后发现槽已归他人(configEpoch 更大)→ 自动变成从节点
与哨兵的核心区别:Cluster 用 rank 延迟机制让数据最新的从节点先发起选举,不需要先选 Leader;投票者是主节点而非独立的哨兵进程;整套逻辑内置在节点里,不需要额外部署组件。
Q8:Cluster 和哨兵的区别?什么时候用哪个?
| 主从 + 哨兵 | Redis Cluster | |
|---|---|---|
| 数据分片 | ❌(单主节点) | ✅ 16384 槽 |
| 写/存储扩展 | ❌ | ✅ |
| 额外组件 | 3+ 个哨兵进程 | 无(功能内置) |
| 故障检测者 | 哨兵 | 每个节点自己 |
| 投票者 | 哨兵 | 主节点 |
| 执行转移者 | 哨兵 Leader | 候选从节点自己 |
| 多 db | ✅ 16 个 | ❌ 只有 db0 |
| 跨节点多 key | ✅ | ❌ 需 hash tag |
| 在线扩容 | ❌ | ✅ reshard |
| 客户端要求 | 支持哨兵协议 | 支持集群协议 |
选型:
- 数据 < 10GB、写 QPS < 几万 → 哨兵(简单可靠,功能无限制);
- 数据量或写量超单机 → Cluster;
- 升级信号:单实例超 10GB(fork 慢、恢复慢、全量同步压力大)、写 QPS 到瓶颈、需要在线扩容。
Q9:Cluster 有哪些使用限制?
- 只能用 db0(
SELECT 1报错)——所以设计阶段就不该用多 db 做隔离; - 跨槽多 key 操作报
CROSSSLOT:MGET/MSET/SINTER/ZUNIONSTORE/RENAME/SMOVE/BITOP/PFMERGE/LMOVE/SORT...STORE等。但成熟客户端(go-redis、Lettuce)会自动把MGET/MSET按槽分组并行发送,所以业务代码通常无感; - Lua 脚本的所有 key 必须同槽,且 7.0+ 会检查"脚本是否访问了未在 KEYS 声明的 key";
- 事务(
MULTI/WATCH)的 key 必须同槽; - pub/sub:7.0 前
PUBLISH全集群广播造成流量放大,7.0+ 用SPUBLISH/SSUBSCRIBE分片; KEYS/SCAN/DBSIZE/FLUSHALL只作用于当前节点,要全集群操作得用--cluster call;- 一个槽不能拆分到两个节点——hash tag 用错造成的超大槽无法通过扩容解决。
Q10:cluster-require-full-coverage 应该设 yes 还是 no?
yes(默认):任何一个槽不可用(该分片主从全挂),整个集群停止服务,所有请求返回CLUSTERDOWN The cluster is down;no:只有访问不可用的槽才报错,其他槽正常服务。
默认 yes 的理由:数据一致性——部分槽不可用还继续服务,客户端可能误以为"这个 key 不存在",进而做出错误决策(比如缓存穿透写入了错误数据)。
生产建议 no 的理由:可用性——一个分片挂掉导致整个集群 100% 不可用通常比"1/3 的 key 不可用"糟糕得多。
结论:纯缓存场景 → no;数据一致性关键 → 保持 yes 并确保每个分片都有足够从节点。
Q11:Cluster 节点之间怎么通信?
集群总线(Cluster Bus):每个节点额外监听 数据端口 + 10000(如 6379 → 16379),用二进制协议(不是 RESP)通信,节点之间两两建立 TCP 连接(全连接网状)。
注意运维要点:总线端口也必须在防火墙/安全组放开,很多人只开了 6379 忘了 16379。
五种消息:MEET(邀请新节点)、PING(心跳,携带自己 + 随机部分其他节点的信息)、PONG(回复,也用于广播配置变更)、FAIL(广播客观下线)、PUBLISH(转发 pub/sub)。
Gossip 工作方式:
每 100ms 的 clusterCron:
1. 从已知节点里【随机选 5 个】,挑其中最久没回 PONG 的那个发 PING
2. 【强制机制】遍历所有节点,超过 cluster-node-timeout/2 没收到 PONG 的
【立即】发 PING → 保证故障检测的及时性上界
每条 PING/PONG 会捎带大约 1/10 已知节点的信息(每个 gossip entry 约 104 字节),信息像流言一样在 O(logN) 轮内传遍全集群。
Q12:容器化部署 Cluster 要注意什么?
核心是地址广播问题(比哨兵更严重):节点通过 Gossip 交换彼此地址,如果一个节点广播的是容器内网 IP,其他节点和客户端都连不上它。
cluster-announce-ip <可路由的IP>
cluster-announce-port 7000
cluster-announce-bus-port 17000 # 【别忘了总线端口】
没配的症状:节点互相 fail?、客户端收到无法连接的重定向地址、集群 state 一直不 ok。
其他注意点:
- 总线端口(+10000)必须放开;
- 用 StatefulSet 保证稳定的网络标识和存储;
nodes.conf必须持久化(存了节点 ID 和拓扑,丢了节点身份就变了);cluster-node-timeout要适当调大(容器环境网络抖动更多,建议 15000~30000);- 内存 limit 要留 fork COW 的余量,
maxmemory必须小于 limit; - K8s 环境推荐用 Operator(如 Redis Enterprise Operator、KubeDB)而不是手工维护。
Q13:CLUSTER FAILOVER 的三种模式有什么区别?
在从节点上执行:
| 命令 | 行为 | 用途 |
|---|---|---|
CLUSTER FAILOVER |
与主节点协调:暂停写入 → 等 offset 追平 → 切换。零数据丢失 | 计划内维护(如主节点要重启) |
CLUSTER FAILOVER FORCE |
不等主节点确认(主节点已不可达时用),但仍需集群多数主节点授权 | 主节点宕机但自动转移没触发 |
CLUSTER FAILOVER TAKEOVER |
不需要任何集群授权,单方面把自己 configEpoch 提高并接管槽 | 仅灾难恢复(如多数主节点都挂了) |
TAKEOVER 极其危险:它绕过了所有一致性检查,可能造成数据丢失和脑裂(两个节点都认为自己拥有同一批槽)。只在"集群已经无法自愈、必须强行恢复服务"时使用。
Q14:为什么 CLUSTER FORGET 有 60 秒黑名单?
执行 CLUSTER FORGET <nodeID> 后,该节点被加入一个 60 秒黑名单,期间不会通过 Gossip 重新认识它。
必要性:如果没有黑名单,你在节点 A 上 forget 了节点 X,但节点 B 还认识 X,B 会通过 Gossip 把 X 重新介绍给 A,forget 就失效了。
60 秒的窗口保证你有足够时间在所有节点上都执行 forget。redis-cli --cluster del-node 会自动帮你对所有节点执行。
Q15:集群里某个节点内存明显高于其他节点,怎么排查?
按顺序排查四种原因:
# 1. 槽分配不均
redis-cli --cluster check 127.0.0.1:7000 # 看各节点槽数
redis-cli --cluster rebalance 127.0.0.1:7000 # 均衡槽数
# 2. hash tag 用错导致某个槽超大(最常见且最难解决)
for slot in $(seq 0 16383); do
n=$(redis-cli -c -p 7000 CLUSTER COUNTKEYSINSLOT $slot)
[ "$n" -gt 100000 ] && echo "slot $slot: $n keys"
done
# → 【无法通过扩容解决】(一个槽不可拆分),只能改数据模型重新迁移
# 3. 大 key
redis-cli -p <该节点> --bigkeys
redis-cli -p <该节点> --memkeys
# → 拆分大 key
# 4. 内存碎片或客户端缓冲区
redis-cli -p <该节点> INFO memory | grep -E 'fragmentation|used_memory_rss'
redis-cli -p <该节点> MEMORY STATS
redis-cli -p <该节点> CLIENT LIST # 看 omem
根本预防:设计阶段就把 hash tag 的粒度控制在高基数标识(用户 ID/订单 ID),并做大 key 监控。
小结
- 分片三种方案:客户端分片(快但扩容痛苦)、Proxy(透明但多一跳且要维护代理)、Redis Cluster(官方、无中间层、内置高可用和在线扩缩容)。
- 16384 个槽的三个理由:心跳位图 2KB(65536 槽要 8KB,流量翻 4 倍)、设计上不超过 1000 节点(16 槽/节点足够均衡)、位图压缩率考虑。
- key → 槽:
CRC16(key) & 16383;槽 → 节点:Gossip 同步的映射表。智能客户端本地缓存映射表实现"一跳直达",这是它优于 Proxy 的核心。 - hash tag:只用第一对
{}里的内容算哈希,用来让相关 key 同槽。最大风险是数据倾斜——tag 粒度太粗(如{orders}:1)会让所有数据挤在一个槽,而一个槽不能拆分,无法通过扩容解决。要用高基数标识({user:1001})。 - MOVED(槽永久归属变了,更新映射表)vs ASK(槽正在迁移,不更新映射表,且必须先发
ASKING,因为目标节点的 IMPORTING 槽默认拒绝请求)。 - 槽迁移:
IMPORTING/MIGRATING标记 → 逐个 keyMIGRATE→ 期间靠ASK引导请求 →SETSLOT NODE完成。MIGRATE是同步阻塞的,所以大 key 会让扩容变成灾难(阻塞几十秒、误触发故障转移、迁移超时留下中间状态)——扩容前必须先处理大 key。 - 集群总线 = 数据端口 + 10000,二进制协议,节点两两全连接。防火墙必须放开总线端口。五种消息:MEET/PING/PONG/FAIL/PUBLISH。
- Gossip:每 100ms 随机选 5 个挑最久没回 PONG 的发 PING,外加"超过
node-timeout/2没收到 PONG 就立即 PING"的强制机制;每条消息捎带约 1/10 已知节点的信息。 - 故障检测:PFAIL(单节点主观)→ gossip 传播故障报告 → 超过半数主节点持有报告 → FAIL → 专门的广播消息告知全集群。只有主节点的报告算数。
- 故障转移:从节点自己竞选(不需要外部协调者)。
delay = 500ms + random(0~500) + rank × 1000ms,rank 按 offset 排名,让数据最新的从节点先发起;主节点投票,得票 > 半数则接管槽并广播 PONG(靠更大的 configEpoch 生效)。 - Cluster 的限制:只能用 db0、跨槽多 key 报
CROSSSLOT(但成熟客户端会自动拆分MGET/MSET)、Lua/事务的 key 必须同槽、KEYS/SCAN/DBSIZE只作用于当前节点、7.0 前 pub/sub 全集群广播。 cluster-require-full-coverage:默认yes(一个槽不可用则整个集群停服,保一致性),生产缓存场景建议no(保可用性)。- 运维必备:
--cluster check(槽覆盖与一致性)、--cluster call(对所有节点执行命令)、--cluster rebalance、--cluster fix(修复中间状态)。CLUSTER FORGET有 60 秒黑名单(防止 gossip 把节点重新介绍回来)。 CLUSTER FAILOVER(协调、零丢失、计划内维护)<FORCE(主节点不可达但仍需多数授权)<TAKEOVER(绕过所有授权,极危险,仅灾难恢复)。- 容器化必配
cluster-announce-ip/cluster-announce-port/cluster-announce-bus-port,nodes.conf必须持久化,cluster-node-timeout适当调大。 - 核心监控:
cluster_state(必须 ok)、cluster_slots_assigned(必须 16384)、cluster_slots_pfail/fail(> 0 告警)、cluster_known_nodes/cluster_size变化、各节点内存偏差 > 30%(数据倾斜)。
xingliuhua