跳转至

Redis Cluster 集群

💡 一句话概述

哨兵解决了"主挂了谁来顶",但写能力和容量仍卡在单机上;Redis Cluster 用 16384 个哈希槽把数据切片分散到多个主节点(分片),同时每个主节点带从节点、节点间用 Gossip 互相心跳(去中心化高可用),是 Redis 官方的水平扩展方案。


🔑 核心概念

  1. 分片(Sharding) — 数据按 key 切分到多个主节点,每个主节点只存一部分,容量和写能力随节点数线性扩展。
  2. 哈希槽(Hash Slot) — 固定 16384 个槽,slot = CRC16(key) mod 16384,槽是分片和数据迁移的最小单位。
  3. 去中心化 — 没有 proxy、没有中心元数据服务,每个节点都保存完整的"槽 → 节点"映射表,节点间用 Gossip 协议(集群总线端口 = 客户端端口 + 10000)交换状态。
  4. MOVED / ASK 重定向 — 客户端访问错节点时服务端返回的重定向错误:MOVED 表示槽已永久搬家(客户端该更新映射),ASK 表示槽正在迁移中(只此一次,去那边试试)。
  5. hash tag — key 里带 {...} 时只用大括号内的内容算槽位,强制多个 key 落在同一槽,解决集群下多 key 操作 / 事务 / Lua 的同槽限制。

📝 详解

1. 为什么需要 Cluster:哨兵没解决的两个问题

先回顾一下演进路线。单机 Redis 挂了怎么办?加从节点 + 哨兵(Sentinel)——哨兵负责监控、自动故障转移,**高可用**解决了。但还有两个问题解决不了:

问题 单机 + 哨兵 原因
高可用(主挂了自动切) ✅ 已解决 哨兵负责选新主
容量扩展 ❌ 所有数据都在一台主节点上,内存就那么大(比如单机 32G 到头了)
写能力扩展 ❌ 写只能打到唯一的主节点,从节点只读,单核单线程的写上限卡死

大白话:哨兵模式就像**一个仓库配了几个备份仓库**——仓库烧了能顶上,但一个仓库的货架总量、装卸货速度还是那么多。Cluster 的思路是**多开几个仓库,每个仓库存一部分货**——货架总量和装卸能力都上去了。

哨兵模式(一主多从,数据全量复制):

  写 ──► [主节点:全部数据]
              │ 全量复制
        ┌─────┴─────┐
        ▼           ▼
    [从节点1]    [从节点2]     ← 只读,分担读压力
    (哨兵进程在旁边盯着谁挂了)

Cluster 模式(数据分片,每片自带副本):

  写 key A/B ──► [主1:槽 0~5460]──► [从1]
  写 key C/D ──► [主2:槽 5461~10922]──► [从2]
  写 key E/F ──► [主3:槽 10923~16383]──► [从3]

所以 Cluster = 分片(解决容量 + 写扩展)+ 去中心化的高可用(每个主带从,挂了从顶上)。

2. 哈希槽:16384 个抽屉

Cluster 不是简单地把 key 按 hash(key) mod N 分到 N 台机器(那样加减机器要重算几乎所有 key),而是引入了一个中间层——哈希槽(hash slot):

大白话:把整个数据空间想象成 16384 个抽屉。每个 key 用 CRC16 算出一个数,mod 16384 得到"该放哪个抽屉";每台机器(主节点)负责管一批抽屉。加减机器只是**搬抽屉**,不用重算每个 key。

slot = CRC16(key) mod 16384

key "user:1001"  ──CRC16──► 22096 ──mod 16384──► 槽 5712  ──► 落在主节点 2
key "order:88"   ──CRC16──► 17876 ──mod 16384──► 槽 1492  ──► 落在主节点 1
key "goods:1001" ──CRC16──► 27673 ──mod 16384──► 槽 11289 ──► 落在主节点 3

槽分配(3 主均分 16384):
┌──────────────┬──────────────┬──────────────┐
│  槽 0~5460   │ 槽 5461~10922│槽 10923~16383│
│    主节点1    │    主节点2    │    主节点3    │
└──────────────┴──────────────┴──────────────┘

槽的好处是**迁移以槽为单位**:要把节点 3 上的一部分数据挪走,只需把某几千个槽(连同槽里的 key)迁到别的节点,其余槽完全不动。

为什么是 16384(2^14)而不是 65536?

这是面试高频题,作者 antirez 在 GitHub issue 里给过官方解释,核心两点:

原因 说明
心跳包体积 节点间 Gossip 心跳(PING/PONG)要携带自己的槽位图(bitmap)。16384 bit = 2KB;如果用 65536 bit = 8KB,心跳包太大,浪费带宽
节点数上限 Redis 官方建议集群节点数**不超过 1000 个**。16384 个槽分给 1000 个节点,每个节点平均还有 16 个槽,粒度完全够用,再多没有意义

一句话:16384 是"槽位图 2KB 能塞进心跳包"和"够 1000 个节点分"之间的折中。

3. 去中心化:Gossip 协议

Cluster 没有中心节点、没有 proxy、也没有像 ZooKeeper 那样的元数据服务:

  • 每个节点都保存全部 16384 个槽的映射表(哪个槽归谁),所以客户端问任何一个节点,它都知道"你这个 key 该找谁"。
  • 节点之间用 **Gossip 协议**通信:每个节点随机挑几个节点定期发消息,消息像八卦一样扩散,最终全集群收敛到一致视图。

Gossip 的四种消息类型:

消息 作用
MEET 通知新节点加入集群(redis-cli --cluster create 时发送)
PING 定期探测对方状态,附带自己已知的部分节点信息(含槽位图)
PONG 回应 PING / MEET,同样携带节点信息
FAIL 某节点被判定客观下线后,向**全集群广播**(这个不是随机的,是广播)

Gossip 走的不是客户端端口,而是专门的**集群总线(cluster bus)**:

集群总线端口 = 客户端端口 + 10000

客户端命令:  应用 ──► 6379  (RESP 协议)
节点间通信:  节点A ◄──► 16379 节点B  (二进制 Gossip 协议)

⚠️ 部署时防火墙要同时放行两个端口(6379 和 16379),
   只开 6379 是最常见的"集群起不来"原因之一。

对比代理方案

Redis Cluster Codis Twemproxy
架构 去中心化,客户端直连节点 中心化 proxy + ZooKeeper 存元数据 轻量 proxy
数据迁移 支持(在线槽迁移) 支持 ❌ 不支持
高可用 内置(从节点自动顶上) 依赖哨兵/自身组件 ❌ 无
额外组件 无 proxy、dashboard、ZK 仅 proxy
客户端要求 必须支持 cluster 协议 基本透明 基本透明

Twemproxy 是 Twitter 的早期方案(只能分片、不能迁移、无高可用),Codis 是豌豆荚的方案(功能全但要维护 proxy + ZK 一套组件)。Redis 3.0 官方推出 Cluster 后,这两个方案基本退出历史舞台。

4. MOVED 与 ASK:两种重定向(重点)

客户端本地缓存了槽映射表,但映射会变(迁移槽、故障转移)。访问错节点时,服务端返回两种重定向错误,语义完全不同:

MOVED:槽已经永久搬家

$ redis-cli -c GET user:1001
客户端 ──GET user:1001──► 节点A(按旧映射)
节点A:槽 5712 已经不归我管了
      ◄──MOVED 5712 192.168.1.2:6379──

客户端行为:
  1. 更新本地槽映射:槽 5712 → 节点B(192.168.1.2:6379)
  2. 把请求重发给节点B
  3. 以后再访问槽 5712 的 key,直接找节点B(不再经过A)

ASK:槽正在搬家中,这次你去那边问问

槽迁移过程中,槽里的一部分 key 已经搬到目标节点、一部分还在源节点。此时:

槽 5712 迁移中:源节点A ────搬迁────► 目标节点B

客户端 ──GET user:1001──► 节点A
情况1:key 还没搬走 → A 直接返回数据 ✅
情况2:key 已经搬走 → A 返回 ASK 5712 节点B
       客户端重发(先 ASKING,再 GET)到 B
       ⚠️ 不更新本地映射!槽的归属还没最终变更,
          下次这个槽的请求仍然先发给 A

一张表总结区别

MOVED ASK
含义 槽**已永久**属于另一节点 槽**正在迁移**,部分 key 已搬走
客户端是否更新映射 ✅ 更新,以后直连新节点 ❌ 不更新,仅本次请求去目标节点
触发时机 迁移完成后 / 故障转移后 迁移进行中
重发前是否要先发 ASKING 不需要 需要(目标节点默认不接这个槽的请求)

smart client 与 redis-cli

  • smart client(go-redis、Jedis Cluster、redis-py-cluster 等):启动时通过 CLUSTER SLOTS / CLUSTER SHARDS 拉取全量槽映射缓存在本地,正常请求**直连目标节点,零重定向开销**;收到 MOVED/ASK 自动跟随并重刷映射。
  • redis-cli 默认不是 cluster 模式,访问错节点只会把 MOVED 当普通错误打印出来。必须加 -c 参数才会自动跟随重定向:
# 不加 -c:报错给你看
$ redis-cli -h 127.0.0.1 -p 6379 GET user:1001
(error) MOVED 5712 192.168.1.2:6379

# 加 -c:自动跳转
$ redis-cli -c -h 127.0.0.1 -p 6379 GET user:1001
-> Redirected to slot [5712] located at 192.168.1.2:6379
"zhangsan"

5. hash tag:强制多个 key 同槽

集群下有个硬限制:多 key 操作(MSET、MGET、事务 MULTI/EXEC、Lua 脚本 EVAL)要求所有 key 在同一个槽,否则报错:

(error) CROSSSLOT Keys in the request don't hash to the same slot

原因很直白:两个 key 在不同节点上,Redis 没法跨节点保证一次操作的原子性,干脆拒绝。

hash tag 是官方给的解法:key 中如果包含 {...},则**只用大括号内的内容计算槽位**:

普通 key:  seckill:1001:stock        → CRC16("seckill:1001:stock") 算槽
hash tag: seckill:{1001}:stock       → 只用 CRC16("1001") 算槽

规则细节:
- 取第一对完整 {} 内的内容;{} 为空(如 "foo{}bar")则按整个 key 算
- 只认第一对大括号:"{a}x{b}" → 用 "a" 算槽

秒杀场景的例子(与《高并发秒杀系统设计》一文保持一致)——把同一商品的库存 key 和用户已购标记 key 绑到同一槽:

seckill:{1001}:stock          ┐
                              ├─ 都只按 CRC16("1001") = 48159 算槽
seckill:{1001}:bought:123     ┘   → 槽 15391 → 同一槽 → 同一节点

Lua 脚本同时操作这两个 key:✅ 原子执行,不报 CROSSSLOT
不加 hash tag:seckill:1001:stock 和 seckill:1001:bought:123
              按整串算槽 → 大概率不同槽 → ❌ CROSSSLOT

hash tag 滥用会导致数据倾斜

如果把大量 key 都打上同一个 tag(比如全部 {seckill}),这些 key 会全部挤进同一个槽、落在同一个主节点上——分片等于白做,该节点内存和 CPU 被打爆,其他节点闲着。正确姿势是 tag 取"业务实体 ID"(如商品 ID),让不同实体自然散列到不同槽;只有确实需要一起操作的 key 才共享 tag。

6. 集群搭建

节点配置(redis.conf)

# ========== Cluster 核心配置 ==========
cluster-enabled yes                  # 开启集群模式(节点以 cluster node 身份启动)
cluster-config-file nodes.conf       # 集群状态文件,由 Redis 自动维护,不要手改
cluster-node-timeout 15000           # 节点失联判定超时(毫秒),超时进 PFAIL 流程

# ========== 持久化(集群下仍建议开 AOF)==========
appendonly yes                       # 集群的故障转移是异步复制顶替,AOF 能减少丢数据窗口
appendfsync everysec

# 可选:槽迁移时容忍脏数据比例,控制迁移对写入的影响
# cluster-migration-barrier 1        # 主节点至少留 1 个从,多余的从可迁去无从的主
# cluster-require-full-coverage no   # 部分槽不可用时,其余槽是否继续服务

注意两点:

  • nodes.conf 是节点自己读写的集群元数据(记录节点 ID、槽归属、纪元),由 Redis 自动维护,人工不要编辑。
  • 端口放行要成对:客户端端口(如 6379)+ 集群总线端口(16379)。

创建集群

# 6 个节点(3 主 3 从)一键建集群,--cluster-replicas 1 表示每个主配 1 个从
$ redis-cli --cluster create \
    192.168.1.1:6379 192.168.1.2:6379 192.168.1.3:6379 \
    192.168.1.4:6379 192.168.1.5:6379 192.168.1.6:6379 \
    --cluster-replicas 1

[CLUSTERINFO] ft_state=ok ...
Can I set the above configuration? (type 'yes' to accept): yes
[OK] All 16384 slots covered.

为什么**最少 3 主**?故障转移选举需要"多数派主节点投票"(见下一节)。2 个主节点时,挂掉 1 个,剩 1 个凑不齐多数派(2/2+1),选举必然失败;3 主挂 1 个,剩 2 个刚好是多数派(2 ≥ 3/2+1)。所以 3 主 3 从是能自动故障转移的最小生产拓扑。

查看集群状态

# 节点列表:节点ID、角色(master/slave/myself)、地址、槽区间
$ redis-cli -c cluster nodes
0f3e2b... 192.168.1.1:6379@16379 myself,master - 0 ... connected 0-5460
8a1c4d... 192.168.1.2:6379@16379 master - 0 ... connected 5461-10922
c22ef1... 192.168.1.3:6379@16379 master - 0 ... connected 10923-16383
5d9b3a... 192.168.1.4:6379@16379 slave 0f3e2b... 0 ... connected
...

# 槽分布:每段槽 → 负责的主节点及其从节点
$ redis-cli cluster slots
1) 1) (integer) 0
   2) (integer) 5460
   3) 1) "192.168.1.1"
      2) (integer) 6379
      ...

# 集群整体健康:状态、节点数、槽覆盖
$ redis-cli cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_known_nodes:6
cluster_size:3

常用运维命令还有:redis-cli --cluster check <host:port>(检查槽覆盖与迁移状态)、--cluster reshard(在线迁移槽)、--cluster add-node / del-node(增删节点)、--cluster rebalance(重新均衡槽)。

7. 故障转移:集群内置的"哨兵"

哨兵模式需要额外部署哨兵进程来做故障判定和选举;Cluster 把这套机制内置了——每个节点都参与监控和投票,不需要任何外部进程。

完整流程四步:

  1. PFAIL(主观下线):节点 A 在 cluster-node-timeout 内 PING 不通节点 B,就在自己的视图里把 B 标记为 PFAIL。这只是 A 的一家之言(可能只是 A 和 B 之间网络抖了)。
  2. FAIL(客观下线):A 通过 Gossip 传播 PFAIL 标记;当**半数以上的主节点**都在超时窗口内标记了 B 为 PFAIL,B 被升级为 FAIL——这是集群共识。标记 FAIL 的节点会向**全集群广播 FAIL 消息**,所有节点立刻知道 B 客观下线。
  3. 选举:B 的从节点们发起选举,请求其他主节点投票。每个主节点在**一个配置纪元(configuration epoch)内只有一票**(先到先得,类似 Raft 的 term)。从节点获得**多数派主节点**投票即胜出;若多个从同时竞选,纪元 +1 重来一轮。
  4. 接管:胜出的从节点执行 slaveof no one 提升为主,接管原主的槽范围,并以新纪元 PONG 广播出去,客户端和其他节点随之更新映射。
sequenceDiagram
    participant M2 as 主节点2(故障)
    participant M1 as 主节点1
    participant M3 as 主节点3
    participant S2 as 从节点2(M2 的从)

    Note over M1,M3: ① 各节点 PING M2 超时<br/>分别标记 PFAIL(主观下线)
    M1-->>M3: Gossip:我这看 M2 是 PFAIL
    Note over M1,M3: ② 半数以上主节点都报 PFAIL<br/>→ M2 升级为 FAIL(客观下线)
    M1->>S2: 广播 FAIL 消息
    Note over S2: ③ S2 发起选举<br/>(延迟 = 复制偏移越新延迟越短,数据新的先选)
    S2->>M1: 请求投票(epoch N)
    S2->>M3: 请求投票(epoch N)
    M1-->>S2: 投票给 S2(本纪元仅一票)
    M3-->>S2: 投票给 S2
    Note over S2: 获得 2/3 多数派 → 胜出
    Note over S2: ④ slaveof no one 提升为主<br/>接管 M2 的槽(5461~10922)
    S2->>M1: PONG 广播新主身份与新槽映射
    S2->>M3: PONG 广播
    Note over M1,M3: 集群恢复正常<br/>客户端收到 MOVED 后更新映射

与哨兵故障转移的对比

哨兵模式 Cluster
故障判定/选举由谁做 独立部署的哨兵进程 集群节点自己(内置)
额外组件 需要 ≥3 个哨兵进程 无
投票权 哨兵投票 **主节点**投票(每纪元一票)
新主接管什么 全部数据(全量副本) 只接管故障主的**槽**
数据丢失风险 异步复制,可能丢 异步复制,同样可能丢

一个容易混淆的点:Cluster 节点数下限是 3 主(为了多数派投票),哨兵部署下限也是 3 个哨兵进程,但原因都是"多数派"——只是投票主体一个是主节点、一个是哨兵。

8. 集群的局限(重点)

Cluster 不是银弹,上生产前这些坑要心里有数:

# 局限 说明 应对
① 多 key 操作受同槽限制 MSET/MGET、MULTI/EXEC 事务、EVAL Lua 涉及的所有 key 必须在同一槽,否则 CROSSSLOT 报错 hash tag 绑定同槽;或业务上拆开单 key 操作
② 不支持多数据库 只有 db0,SELECT 命令在集群模式下直接报错,单机时代的 SELECT 1 习惯全废 用 key 前缀做逻辑隔离
③ 数据倾斜与大 key hash tag 滥用或 key 分布不均 → 某槽/某节点数据暴涨;且**单个 key 永远只在一个槽一个节点上**,无法再拆 tag 设计要散列;大 key 业务侧拆成多个子 key;热点 key 上本地缓存
④ 异步复制,故障转移可能丢数据 主节点写入后异步复制给从;主挂掉瞬间未同步的写会丢 开 AOF、设置 min-replicas-to-write 1(至少 1 个从确认才算写成功,牺牲部分可用性换数据)
⑤ 节点数不宜过多 官方建议 ≤ 1000:节点越多 Gossip 心跳流量越大(每节点定期互相发消息),收敛越慢 一般 3~几十主足够;真到千级考虑业务拆分多套集群
⑥ 客户端必须支持 cluster 协议 要会解析槽映射、跟随 MOVED/ASK;老客户端、部分简单封装库直接用不了 go-redis、Jedis Cluster 等主流库都已支持;用前确认版本

9. 选型:单机 vs 哨兵 vs Cluster

单机 哨兵(主从) Cluster
容量扩展 ❌ 单机内存上限 ❌ 每台都是全量数据 ✅ 分片,加主节点即扩容
写扩展 ❌ ❌ 写仍集中在唯一主节点 ✅ 多主同时写
读扩展 ❌ ✅ 从节点分担读 ✅ 各片的从节点分担读
高可用 ❌ 挂了就挂 ✅ 哨兵自动切换 ✅ 内置选举自动切换
多 key/事务/Lua ✅ 无限制 ✅ 无限制 ⚠️ 受同槽限制
多数据库 SELECT ✅ db0~15 ✅ db0~15 ❌ 只有 db0
运维复杂度 最低 中(要部署哨兵) 最高(分片、迁移、均衡)
客户端要求 无 需支持哨兵发现 必须支持 cluster 协议
适用规模 开发测试 / 小数据量 数据单机放得下,要高可用 数据量或写 QPS 超单机上限

选型口诀:单机放得下 → 哨兵;放不下或写不动 → Cluster。别为了"显得架构先进"给 2GB 数据上 Cluster——分片的复杂度(CROSSSLOT、hash tag、倾斜、迁移)是实打实的成本。秒杀场景如果单商品库存数据量很小,一个哨兵集群 + 本地缓存往往就够了。


💻 代码示例

Go:go-redis v9 连接集群

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

func main() {
    // NewClusterClient:go-redis v9 的集群客户端
    // 它就是一个 smart client:启动时拉取槽映射缓存在本地,
    // 请求直连目标节点,收到 MOVED/ASK 自动跟随并刷新映射
    rdb := redis.NewClusterClient(&redis.ClusterOptions{
        Addrs: []string{
            "192.168.1.1:6379",
            "192.168.1.2:6379",
            "192.168.1.3:6379", // 写全部主节点地址,客户端会自动发现其余节点
        },
        // 只把从节点用于读,写仍走主节点
        RouteByLatency: true, // 按延迟路由
        MaxRedirects:   3,    // MOVED/ASK 最大跟随次数,防死循环
        DialTimeout:    2 * time.Second,
        ReadTimeout:    time.Second,
        PoolSize:       50, // 每个节点的连接池大小
    })

    ctx := context.Background()

    // 单 key 操作:客户端算好槽位直连对应节点
    if err := rdb.Set(ctx, "user:1001", "zhangsan", time.Hour).Err(); err != nil {
        panic(err)
    }
    val, err := rdb.Get(ctx, "user:1001").Result()
    if err != nil {
        panic(err)
    }
    fmt.Println(val) // zhangsan

    // ⚠️ 多 key 操作:两个 key 不同槽会报 CROSSSLOT
    // rdb.MSet(ctx, "seckill:1001:stock", 100, "seckill:1001:bought:123", 1)
    // → error: CROSSSLOT Keys in the request don't hash to the same slot

    // ✅ 用 hash tag 绑定同槽后,多 key 操作合法
    rdb.MSet(ctx, "seckill:{1001}:stock", 100, "seckill:{1001}:bought:123", 0)

    // 查看某个 key 落在哪个槽(CLUSTER KEYSLOT,服务端按 hash tag "1001" 计算)
    slot, err := rdb.ClusterKeySlot(ctx, "seckill:{1001}:stock").Result()
    if err == nil {
        fmt.Println("slot:", slot) // 库存 key 与 bought key 算出的槽相同
    }
}

Go + Lua:秒杀扣减(hash tag 是集群下的生命线)

// 秒杀 Lua 脚本:原子完成「查已购 → 判库存 → 扣减 → 打标」
// KEYS[1] = 库存 key,KEYS[2] = 用户已购标记 key
// 返回值:1=成功 / -1=已购过 / 0=售罄
const seckillScript = `
if redis.call('exists', KEYS[2]) == 1 then
    return -1
end
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then
    return 0
end
redis.call('decr', KEYS[1])
redis.call('set', KEYS[2], 1, 'EX', 86400)
return 1
`

var seckillCmd = redis.NewScript(seckillScript)

// Seckill 执行秒杀扣减。
// 关键:两个 key 用相同的 hash tag {goodsID},
// CRC16 只对大括号内的 goodsID 计算 → 两 key 必然同槽同节点,
// Lua 才能原子执行;否则集群直接报 CROSSSLOT。
func Seckill(ctx context.Context, rdb *redis.ClusterClient, goodsID, userID string) (int64, error) {
    stockKey := fmt.Sprintf("seckill:{%s}:stock", goodsID)
    userKey := fmt.Sprintf("seckill:{%s}:bought:%s", goodsID, userID)

    // NewScript + Run:go-redis 自动处理 EVALSHA/EVAL 回退
    res, err := seckillCmd.Run(ctx, rdb, []string{stockKey, userKey}).Int64()
    if err != nil {
        return 0, err
    }
    return res, nil // 1=成功(发MQ异步落库) / -1=已购 / 0=售罄
}
如果去掉 hash tag(错误示范):

stockKey = "seckill:1001:stock"        → CRC16("seckill:1001:stock")        = 51746 → 槽 2594  (主节点1)
userKey  = "seckill:1001:bought:123"   → CRC16("seckill:1001:bought:123")   = 59352 → 槽 10200 (主节点2)

两个槽在不同主节点上 → Lua 无法跨节点原子执行:
(error) CROSSSLOT Keys in the request don't hash to the same slot

⚠️ 常见陷阱

陷阱一:集群下 Lua/事务操作多 key 忘加 hash tag

单机时代 EVAL 随便传多个 key,上了集群直接 CROSSSLOT Keys in the request don't hash to the same slot,秒杀、购物车合并扣减等逻辑整体不可用。解决:所有需要一起操作的 key 用相同 hash tag(如 seckill:{1001}:stock 与 seckill:{1001}:bought:123);上集群前全局排查 MSET/MGET/MULTI/EVAL 的多 key 调用。

陷阱二:以为集群能解决热点大 key

Cluster 分的是"槽",但**单个 key 永远只属于一个槽、落在一个主节点上**。一个被 10 万 QPS 打的热点 key(如爆款库存),集群再大也只有那一台主节点扛,照样打满。解决:热点 key 要业务侧拆(库存分桶:stock:{1001}:bucket:1~N 摊到多槽)或上本地缓存/多级缓存,分片救不了单 key 热点。

陷阱三:节点太少(< 3 主),故障转移失败

选举需要多数派主节点投票:2 主挂 1 个,剩 1 个凑不齐多数派,从节点永远选不上,槽不可用且**不会自动恢复**。同理,3 主集群挂 2 个也完蛋。解决:生产至少 3 主 3 从;跨机架/可用区部署,避免一次断电带走多数派。

陷阱四:带着单机习惯上集群(SELECT 多库、hash tag 滥用)

集群只有 db0,SELECT 1 直接报错——用 key 前缀替代多库隔离。另一个极端是 hash tag 滥用:把一堆 key 全打成 {same-tag},全挤到一个槽一个节点,分片白做、单节点被打爆。解决:tag 粒度取业务实体 ID(商品 ID、用户 ID),保证不同实体自然散列;只有"必须一起原子操作"的 key 才共享 tag。


🏋️ 练习题

练习 1:Redis Cluster 为什么是 16384 个槽,而不是 65536 个?
答案

两个原因:① 心跳包体积——节点间 Gossip 心跳要携带槽位图(bitmap),16384 bit = 2KB 可以接受,65536 bit = 8KB 心跳包太大;② 节点数上限——官方建议集群不超过 1000 个节点,16384 个槽分给 1000 个节点粒度已足够,槽再多没有收益。本质上 16384 是带宽开销与分片粒度之间的折中。

练习 2:客户端收到 MOVED 和收到 ASK,处理方式有什么区别?为什么?
答案

MOVED:槽已永久迁移到目标节点,客户端应**更新本地缓存的槽映射**,之后该槽的请求直接发新节点。ASK:槽正在迁移中,只有部分 key 已搬走,客户端**只对本次请求**去目标节点重试(先发 ASKING 再发命令),不更新映射——因为槽的最终归属还没变,其余 key 可能还在源节点。区别根源:MOVED 代表"迁移已完成"的确定事实,ASK 代表"迁移进行中"的临时状态。

练习 3:Cluster 中一个主节点宕机后,故障转移的完整流程是什么?和哨兵模式有什么本质区别?
答案

流程:① 某节点在 cluster-node-timeout 内 PING 不通故障主,标记 PFAIL(主观下线);② 半数以上**主节点**都标记 PFAIL 后升级为 FAIL(客观下线)并广播;③ 故障主的从节点发起选举,其他主节点按 configuration epoch 投票(每纪元一票),获得多数派主节点票的从胜出;④ 胜者提升为主,接管原主的槽并广播新映射。本质区别:哨兵模式要额外部署哨兵进程来做判定和选举,Cluster 把这套机制内置到数据节点自身,无需任何外部组件;另外 Cluster 新主只接管故障主的槽(分片数据),哨兵模式新主接管全量数据。


🔗 相关链接