目录

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

  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 之后的限制:

  • 只能用 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

关键认识:

  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+ 还有 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(不需要自己判断)

三个关键点:

  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

受影响的命令: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 遗留系统/特殊需求

选型建议:

  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 的中间状态,需要人工修复。

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

  1. 只能用 db0(SELECT 1 报错)——所以设计阶段就不该用多 db 做隔离;
  2. 跨槽多 key 操作报 CROSSSLOT:MGET/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 秒的窗口保证你有足够时间在所有节点上都执行 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 标记 → 逐个 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-port,nodes.conf 必须持久化,cluster-node-timeout 适当调大。
  • 核心监控:cluster_state(必须 ok)、cluster_slots_assigned(必须 16384)、cluster_slots_pfail/fail(> 0 告警)、cluster_known_nodes/cluster_size 变化、各节点内存偏差 > 30%(数据倾斜)。