Redis Cluster 集群¶
💡 一句话概述
哨兵解决了"主挂了谁来顶",但写能力和容量仍卡在单机上;Redis Cluster 用 16384 个哈希槽把数据切片分散到多个主节点(分片),同时每个主节点带从节点、节点间用 Gossip 互相心跳(去中心化高可用),是 Redis 官方的水平扩展方案。
🔑 核心概念¶
- 分片(Sharding) — 数据按 key 切分到多个主节点,每个主节点只存一部分,容量和写能力随节点数线性扩展。
- 哈希槽(Hash Slot) — 固定 16384 个槽,
slot = CRC16(key) mod 16384,槽是分片和数据迁移的最小单位。 - 去中心化 — 没有 proxy、没有中心元数据服务,每个节点都保存完整的"槽 → 节点"映射表,节点间用 Gossip 协议(集群总线端口 = 客户端端口 + 10000)交换状态。
- MOVED / ASK 重定向 — 客户端访问错节点时服务端返回的重定向错误:MOVED 表示槽已永久搬家(客户端该更新映射),ASK 表示槽正在迁移中(只此一次,去那边试试)。
- 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 在同一个槽,否则报错:
原因很直白:两个 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 把这套机制内置了——每个节点都参与监控和投票,不需要任何外部进程。
完整流程四步:
- PFAIL(主观下线):节点 A 在
cluster-node-timeout内 PING 不通节点 B,就在自己的视图里把 B 标记为 PFAIL。这只是 A 的一家之言(可能只是 A 和 B 之间网络抖了)。 - FAIL(客观下线):A 通过 Gossip 传播 PFAIL 标记;当**半数以上的主节点**都在超时窗口内标记了 B 为 PFAIL,B 被升级为 FAIL——这是集群共识。标记 FAIL 的节点会向**全集群广播 FAIL 消息**,所有节点立刻知道 B 客观下线。
- 选举:B 的从节点们发起选举,请求其他主节点投票。每个主节点在**一个配置纪元(configuration epoch)内只有一票**(先到先得,类似 Raft 的 term)。从节点获得**多数派主节点**投票即胜出;若多个从同时竞选,纪元 +1 重来一轮。
- 接管:胜出的从节点执行
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 新主只接管故障主的槽(分片数据),哨兵模式新主接管全量数据。
🔗 相关链接¶
- Redis Cluster Tutorial(官方教程) — 集群搭建、槽迁移与 hash tag 的权威说明
- Redis Cluster Specification — 集群规范:MOVED/ASK、Gossip、故障转移的精确定义
- go-redis ClusterOptions 文档 — Go 集群客户端配置参考
- 高并发秒杀系统设计 — 本站文章:hash tag 在秒杀 Lua 扣减中的实战用法
- 多级缓存与读写策略 — 本站文章:热点 key 的多级缓存解法(集群救不了单 key 热点)