目录

Redis-12 Cluster 集群

1. 为什么需要 Cluster

哨兵解决了高可用,但没解决扩展性

  1. 存储受限于单机内存:所有数据都在一个主节点上,超过 10GB 就开始有 fork 慢、恢复慢、全量同步压力大的问题;
  2. 写入受限于单节点:所有写请求集中在一个主节点,达到几万 QPS 就是瓶颈;
  3. 单点的所有风险都被放大:一个大实例出问题影响全部业务。

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 在同一个槽,但 k1k2 的 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 的精确规则(容易记错):

  1. 找到第一个 {
  2. 从它之后找第一个 }
  3. 如果两者之间有至少一个字符,就只用这部分算哈希;
  4. 否则(没有 {、没有 }、或 {} 之间为空),用整个 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 之后的限制

  • 只能用 db0SELECT 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(自己)、masterslavefail?(疑似下线 PFAIL)、fail(已下线 FAIL)、handshakenoaddrnofailover

# 更友好的槽分布查看
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

关键认识

  1. 迁移期间集群仍然可以正常读写(这就是"在线"扩容),代价是那些正在迁移的 key 会多一次 ASK 重定向;
  2. MIGRATE 是同步阻塞的——迁移一个大 key 时,源节点和目标节点都会阻塞。这是"大 key 会让扩容极其痛苦"的原因(见 5.4);
  3. 一个槽不能被拆分到两个节点——这决定了 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+ 还有 MODULEPUBLISHSHARD(分片 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(不需要自己判断)

三个关键点

  1. 只有主节点的投票算数(从节点的 PFAIL 报告不计入统计);
  2. 需要超过半数的主节点同意——这是防止网络分区的少数派误判并触发故障转移;
  3. 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

受影响的命令MGETMSETMSETNXSINTER/SUNION/SDIFF(及其 STORE 版本)、ZUNIONSTORE/ZINTERSTORERENAMESMOVEBITOPPFMERGEPFCOUNT(多 key)、LMOVE/RPOPLPUSHCOPYGEORADIUS ... STORESORT ... STOREXREAD 多 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 遗留系统/特殊需求

选型建议

  1. 数据量 < 10GB、写 QPS < 几万主从 + 哨兵足够,简单可靠;
  2. 数据量或写量超过单机Redis Cluster(官方方案,无中间层,1 跳直达);
  3. 必须支持多 db、或客户端无法改造 → 考虑 Proxy(但这通常意味着架构债,应该规划改造);
  4. 云环境 → 直接用云厂商的托管版(阿里云 Tair、AWS ElastiCache、腾讯云 Redis),它们通常提供 Proxy 模式和 Cluster 模式两种选择,还免去了运维。

10. 高频面试题

Q1:Redis Cluster 为什么是 16384 个槽?

antirez 本人给出三个理由:

  1. 心跳包大小:Gossip 消息(clusterMsg)里带一个表示"我负责哪些槽"的位图。16384 槽 = 16384/8 = 2048 字节 = 2KB;如果是 65536 槽就是 8KB。因为每个节点每秒都要向多个节点发心跳,8KB 会让集群总线流量增加 4 倍。2KB 是可接受的开销;
  2. 集群设计上不超过 1000 个节点(超过这个规模 Gossip 消息量不可控)。16384 / 1000 ≈ 16 个槽每节点,这个粒度足够均衡分配。槽太少(如 1024)在 1000 节点时每节点只有 1 个槽,无法均衡;
  3. 位图压缩率:位图会用游程编码压缩,节点少时用 65536 槽虽然压缩后也不大,但没必要为极端情况付出常态 4 倍的开销

记忆2KB 位图 + 1000 节点上限 + 压缩率

Q2:key 是怎么映射到节点的?

两步:

  1. key → 槽slot = CRC16(key) & 16383(16384 是 2 的幂,用位与代替取模);
  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 迁移可能阻塞几十秒,后果:

  1. 两个节点都无法响应任何请求,客户端大面积超时;
  2. 集群可能因为节点无响应而误判它下线(PFAIL→FAIL),触发不必要的故障转移
  3. MIGRATE 本身可能超时失败,导致槽卡在 migrating/importing 的中间状态,需要人工修复。

所以扩容前必须先处理大 keyredis-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 有哪些使用限制?

  1. 只能用 db0SELECT 1 报错)——所以设计阶段就不该用多 db 做隔离;
  2. 跨槽多 key 操作报 CROSSSLOTMGET/MSET/SINTER/ZUNIONSTORE/RENAME/SMOVE/BITOP/PFMERGE/LMOVE/SORT...STORE 等。但成熟客户端(go-redis、Lettuce)会自动把 MGET/MSET 按槽分组并行发送,所以业务代码通常无感;
  3. Lua 脚本的所有 key 必须同槽,且 7.0+ 会检查"脚本是否访问了未在 KEYS 声明的 key";
  4. 事务(MULTI/WATCH)的 key 必须同槽
  5. pub/sub:7.0 前 PUBLISH 全集群广播造成流量放大,7.0+ 用 SPUBLISH/SSUBSCRIBE 分片;
  6. KEYS/SCAN/DBSIZE/FLUSHALL 只作用于当前节点,要全集群操作得用 --cluster call
  7. 一个槽不能拆分到两个节点——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 秒的窗口保证你有足够时间在所有节点上都执行 forgetredis-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 标记 → 逐个 key MIGRATE → 期间靠 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-portnodes.conf 必须持久化,cluster-node-timeout 适当调大。
  • 核心监控:cluster_state(必须 ok)、cluster_slots_assigned(必须 16384)、cluster_slots_pfail/fail(> 0 告警)、cluster_known_nodes/cluster_size 变化、各节点内存偏差 > 30%(数据倾斜)。