Files
cs-note/hhs/Redis/07-集群方案.md
T
2026-05-27 11:24:47 +08:00

481 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 高可用, Cluster, 分布式]
create time: 2026-05-15 18:14
---
# Redis Cluster 集群方案
## 概述
Redis Cluster 是 Redis 官方的**分布式**解决方案,通过数据分片(sharding)将数据分布在多个节点上,支持横向扩展和自动故障转移。核心设计:**哈希槽(Hash Slots)**而非一致性哈希。
```mermaid
flowchart TD
C1["Client"] --> N1["Node A: slot 0~5460"]
C1 --> N2["Node B: slot 5461~10922"]
C1 --> N3["Node C: slot 10923~16383"]
N1 -->|"复制"| NA["Node A Replica"]
N2 -->|"复制"| NB["Node B Replica"]
N3 -->|"复制"| NC["Node C Replica"]
```
## 哈希槽机制
### 为什么不用一致性哈希?
> [!NOTE] 一致性哈希是什么?
> 一致性哈希是一种分布式分片算法,它将所有节点和数据映射到一个虚拟的**哈希环**上。数据沿顺时针找到的第一个节点就是它的归属。核心优势是:增减节点时,只需要迁移相邻区间的少量数据。更多细节见 [[hhs/Redis/07-集群方案/一致性哈希|一致性哈希]]。
| 对比项 | 一致性哈希 | 哈希槽 |
|--------|-----------|---------|
| 增节点迁移量 | **~N/K**(N 为总 key 数,K 为节点数) | **total_slots × 1/N** ≈ 每个新节点分得 ~2730 slots |
| 客户端透明性 | ✅ 通常透明,按哈希环顺时针归属 | ⚠️ 需要 MOVED/ASK 重定向 |
| 数据倾斜 | 依赖虚拟节点数量平衡 | ❌ slots 分配不均时会倾斜 |
| 复杂度 | 简单,纯哈希环 | 略复杂(Gossip + 槽位元数据同步) |
> [!QUESTION] 思考
> Redis 选择了 16384 个槽而不是其他数字,你觉得为什么是这个数?太少了会怎样?太多了又会怎样?
> [!NOTE] 为什么是 16384(2¹⁴)?
> Redis 作者 antirez 在 [GitHub Issue #2576](https://github.com/redis/redis/issues/2576) 中明确解释过:
> 1. **Gossip 消息大小限制**:每个节点的槽位信息用 bitmap 表示,16384 slots = 2KB(16384/8 字节),放入心跳包后带宽可控;若用 65536 slots 则需 8KB,心跳报文膨胀明显。
> 2. **节点数量上限**:Redis Cluster 官方建议最多 1000 个节点。16384 个槽在 1000 节点时,平均每个节点 16 个槽,粒度已经足够细。
> 3. **计算效率**:16384 是 2 的幂,CRC16 取模可直接用位运算 `& 0x3FFF`,无需真正执行除法。
>
> 因此,16384 是**槽位粒度**与**心跳带宽**之间的工程折中,而非哈希冲突的考量。
### 槽位计算
Redis 使用 **CRC16 算法**(Cyclic Redundancy Check,循环冗余校验——一种广泛用于数据校验的快速哈希算法)对 key 取模,结果映射到 0~16383 共 16384 个槽:
```bash
# CRC16(key) % 16384 = slot 编号
# 例:CRC16("foo") % 16384 = 5798 → 属于 Node B (5461~10922)
```
```go
// Go redis 库内部自动计算,开发者无需关心
rdb.Set(ctx, "foo", "bar", 0) // 自动路由到对应节点
```
> [!TIP] 多 key 在同一节点的技巧——用 `{}` 包裹 tag:
> ```
> SET {user:100}:name alice → slot X
> SET {user:100}:age 25 → 同 slot X
> SADD {user:100}:friends user:200
> ```
> 这样 `user:100` 的所有操作都在同一个节点上,支持 MULTI/LUA 事务。
> [!NOTE] hash tag 的边界
> - `{user:100}:name` 中只有 `{user:100}` 部分参与 hash 计算,`{}` 后面的 `:name` 是实际 key 的一部分。
> - 如果 key 中没有 `{}`,则整个 key 参与 hash 计算。
> - 务必确保 tag 逻辑正确,否则同一用户的不同属性可能分散在不同节点,导致事务失败。
## 通信协议与 Gossip 协议
> Gossip 协议是一种去中心化的信息传播协议,灵感来自"八卦传播"——每个节点定期向随机选择的邻居交换状态信息,几轮之后信息便传遍集群。Redis Cluster 用它同步节点状态和槽位分配,无需中心化协调者。详见 [[hhs/Redis/07-集群方案/Gossip协议|Gossip 协议]]。
### 节点间通信端口
```
节点通信端口 = client_port + 10000
例如: 6379 → 16379 (cluster bus)
```
> [!WARNING] 防火墙配置
> 生产环境中必须开放 `port` 和 `port+10000` 两个端口。很多云服务器安全组默认只开一个端口,会导致集群组建失败或节点状态异常。
### Gossip 协议运作方式
每个节点每秒向随机选择的节点发送 PING/PONG,维持集群状态的一致性。选择数量为固定公式:1 个长期未通信节点 + min(5, node_count/10) 个随机节点:
```mermaid
sequenceDiagram
participant A as Node A
participant B as Node B
loop 每秒钟
A->>B: PING (包含自身状态 + 随机选 3 个其他节点)
B->>A: PONG (含自身 + 选中节点的状态)
end
Note over A: Gossip 收敛原理:<br/>集群越大 fanout 越大,约 O(sqrt(N)),<br/>O(log(N)) 轮即可全同步
```
**Gossip 传播的三类信息:**
1. **PING/PONG** — 心跳,携带发送者的状态摘要
2. **MEET** — 手动加入集群,向指定节点发送 MEET 指令
3. **FAIL** — 某节点不可达,广播给其他节点
> [!QUESTION] 思考
> Redis 的 Gossip 选择策略(源码 `cluster.c:clusterCron()`):每次选 1 个长时间未通信的随机节点 + 5 个随机节点(上限为集群节点总数的 1/10),而非广播给所有节点。这是固定公式,不因集群规模动态切换。为什么不广播所有节点?1000 节点的集群中,每秒广播意味着 O(N²) 条消息——这正是 Gossip 用随机子集换取带宽的关键设计。
### PING/PONG 报文详解
```
PING 报文体包含:
├── 发送节点的 nodeId
├── 槽位分配表(哪些节点负责哪些 slot)
├── 节点状态(master/slave/fail)
├── 最后通信时间戳
└── 当前集群 epoch(配置版本号,每次集群拓扑变更如 failover 或 reshard 时递增,用于解决节点间的状态冲突——epoch 更大的配置被视为最新真相)
```
## 数据迁移流程
### 移动一个 slot(跨节点)
```bash
# 在源节点执行(准备阶段)
CLUSTER SETSLOT <slot_id> MIGRATING <target_node_id>
# 在目标节点执行(确认阶段)
CLUSTER SETSLOT <slot_id> IMPORTING <source_node_id>
# 逐 key 迁移
# GETKEYSINSLOT: 返回指定 slot 中的 key 列表,每次最多 100 个
# MIGRATE: 原子地将 key 从源节点转移到目标节点(同步阻塞,最后一个参数 5000 是超时 ms)
for key in $(redis-cli -h $SRC -p $SRC_PORT CLUSTER GETKEYSINSLOT $slot 100); do
redis-cli -h $SRC -p $SRC_PORT MIGRATE $DST $DST_PORT $key 0 5000
done
# 完成迁移
CLUSTER SETSLOT <slot_id> NODE <target_node_id>
```
> [!NOTE] 迁移期间的 ASK 临时定向
> 当 slot 处于 MIGRATING/IMPORTING 中间状态时:
> - 源节点**拥有该 key** → 正常处理
> - 源节点**找不到该 key**(已迁移走) → 返回 `ASK <target-node>`,客户端只需本次请求转发到目标节点,后续请求仍发原节点。
>
> 这保证了迁移过程对客户端透明,且不会造成路由表的提前混乱。
### 自动化迁移 —— redis-cli cluster reshard
```bash
redis-cli --cluster reshard node-host:6379 \
--cluster-from <src-node-id> \
--cluster-to <dst-node-id> \
--cluster-slots 5461 \
--cluster-yes
```
> [!WARNING] reshard 期间服务不中断
> 但涉及迁移的 slot 在高负载下可能有额外延迟,低峰期操作更稳妥。
## 创建集群
### 手动创建(3 Master + 3 Replica)
```bash
# 1. 启动 6 个实例(不同端口)
for port in $(seq 7001 7006); do
mkdir -p /tmp/$port && redis-server \
--port $port --cluster-enabled yes \
--appendonly yes --cluster-config-file nodes.conf \
--cluster-node-timeout 5000 \
--loglevel warning > /tmp/$port/redis.log 2>&1 &
done
# 2. 一行命令组建集群
redis-cli --cluster create \
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 127.0.0.1:7006 \
--cluster-replicas 1
```
### Docker Compose(开发环境)
```yaml
version: "3.9"
services:
redis-1:
image: redis:7-alpine
command: redis-server --cluster-enabled yes --appendonly yes
ports: ["7001:7001"]
volumes: ["redis-data-1:/data"]
# ... 重复 redis-2 ~ redis-6
volumes:
redis-data-1:
# ...
```
### 连接集群客户端
```go
import "github.com/redis/go-redis/v9"
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{":7001", ":7002", ":7003", ":7004", ":7005", ":7006"},
Password: "your-password",
MaxRetries: 3,
})
defer rdb.Close()
// SET/GET 自动路由到正确的节点
rdb.Set(ctx, "key", "value", 0)
val, _ := rdb.Get(ctx, "key").Result()
```
## Cluster 的局限性
| 限制 | 说明 | 替代方案 |
|------|------|---------|
| 不支持多数据库 | 固定只有 db 0 | 应用层模拟 db(key 前缀) |
| 命令子集受限 | `KEYS`, `MIGRATE`, `SORT` 等不可用 | 用 `SCAN` 替代 `KEYS` |
| 跨节点多 key 命令受限 | `DEL`, `MGET`, `MSET`, `RENAME` 等多 key 操作要求 key 同 slot | 用 hash tag 或应用层拆分为多次单 key 调用 |
| Lua 脚本有限 | 所有 key **必须映射到同一 slot**;不支持非确定性函数(time/random) | 同 Lua 内 key 用同一 hash tag;用 `EVALSHA` 减少网络传输 |
| Pipeline 受限 | 同一 pipeline 中所有 key 必须在同一节点 | 用 hash tag `{user:100}:x` 对齐 |
| MULTI/EXEC 受限 | 同上,跨 key 事务不支持 | 用 Lua 脚本实现原子操作 |
> [!TIP] 解决 Pipeline 跨节点问题
> ```go
> // ❌ 错误:key 分布在不同节点
> pipe.Pipeline().
> Get(ctx, "user:1:name").
> Get(ctx, "user:2:name")
>
> // ✅ 正确:用 hash tag 对齐到同一节点
> pipe.Pipeline().
> Get(ctx, "{user:1}:name").
> Get(ctx, "{user:1}:age")
> ```
## 运维命令速查
```bash
# 集群状态
redis-cli --cluster check 127.0.0.1:7001
# 查看节点信息
redis-cli -p 7001 CLUSTER NODES
# 输出格式: nodeId ip:port@busPort flags master/slot-range self
# 示例输出解析
e3a1b2c3... 192.168.1.10:7001@17001 master - 0 1715000000000 1 connected 0-5460
# ↑ node-id ↑ addr ↑ bus-port ↑ role ↑ epoch ↑ slots
# 统计各节点 key 数量
redis-cli -p 7001 DBSIZE
redis-cli -c -p 7001 --cluster call DBSIZE # 在所有节点执行
# 强制某个 slot 下线(紧急情况)
redis-cli --cluster fix 127.0.0.1:7001
```
## 扩容指南
```mermaid
flowchart LR
Before["3 Master<br/>各 5461 slots"] -->|添加 3 新节点| Middle["6 Master<br/>slot 全空"]
Middle -->|reshard 迁 slot| After["6 Master<br/>各 ~2730 slots"]
After -->|加 replica| Final["6 Master + 6 Replica"]
```
> [!TIP] 扩容节奏建议
> 1. 先加 Master 并分 slot
> 2. 再给每个 Master 配 Replica
> 3. 分批迁移(每次 1~2 台),避免流量剧变
## 故障转移机制(Failover)
> [!QUESTION] 思考
> 假设一个 Master 宕机了,谁来决定哪个 Replica 接任?如果让客户端来判断,会有什么问题?
### 故障检测三阶段
> [!NOTE] Raft 式投票是什么?
> Raft 是一种分布式共识算法,核心思想之一是**领导者选举**:当 Leader 宕机时,Follower 发起选举,获得多数派投票的节点成为新 Leader。Redis Cluster 的 Failover 选举借鉴了这个机制——Replica 通过递增 epoch 并向所有 Master 请求投票,获得多数票后晋升为新 Master。但 Redis 只使用了 Raft 的选举部分,数据复制仍然走自身协议。详见 [[hhs/Redis/07-集群方案/Raft共识算法|Raft 共识算法]]。
```mermaid
flowchart LR
A["PFAIL 潜在失效<br/>单个节点判定"] -->|"超过 cluster-node-timeout"| B["FAIL 确认失效<br/>多数 Master 同意"]
B -->|"Raft 式投票"| C["选举 Leader<br/>Replica 竞选"]
C -->|"最高优先级获胜"| D["Failover<br/>接管 slots"]
style A fill:#fff3e0,stroke:#ff9800
style B fill:#fce4ec,stroke:#e91e63
style C fill:#e8eaf6,stroke:#3f51b5
style D fill:#e8f5e9,stroke:#4caf50
```
**详细流程:**
| 阶段 | 谁触发 | 条件 | 持续时间 |
|------|--------|------|---------|
| **PFAIL** | 单个节点 | 超过 `cluster-node-timeout` 未收到 PONG | 瞬时 |
| **FAIL** | PFAIL 节点广播 | 大多数 Master 同意该节点不可达 | 秒级 |
| **选举** | 宕机 Master 下唯一存活 Replica | 发起集群纪元递增 + Raft 投票 | 秒级 |
| **Failover** | 当选 Leader | 提升为 Master,接管原 Master 的 slots | 亚秒级 |
### 选举规则
1. **只有 Replica 能参与选举**,Master 不会主动抢占
2. **选举时机**:Replica 发现自己复制的 Master 进入 FAIL 状态
3. **竞争排序依据**(依次比较):
- `replica-priority` 值越小优先级越高(配置为 0 表示永远不参选)
- 复制偏移量(`replication offset`)越大越优先——偏移量大意味着数据最新
- 若偏移量相同,`node ID` 字典序小的优先(确定性 tiebreaker,避免同时发起投票)
4. **Raft 式投票**:每个 Master 一票,先到先得
5. **获胜条件**:获得多数 Master 的选票
6. **升级步骤**:
- 将自己提升为 Master
- 接管所有原 Master 管理的 slots
- 向所有节点广播新的配置(configEpoch 递增)
> [!NOTE] 脑裂保护
> 脑裂是指网络分区把集群割裂成多个子集群,各自选举出 Master 同时对外服务。Redis 通过 **epoch 版本号** 解决冲突——epoch 更小的 Master 被强制降级。此外,建议配置 `min-replicas-to-write 1`,当 Master 联系不到足够 Replica 时主动拒绝写入,防止分区恢复后数据丢失。详见 [[hhs/Redis/07-集群方案/脑裂问题|脑裂问题]]。
### 客户端容错处理
集群模式下客户端会收到两种重定向响应:
```go
// MOVED: 永久重定向(槽位已迁移到新节点)
// 客户端应更新拓扑,后续请求直接发到目标节点
MOVED 3999 192.168.1.20:7002
// ASK: 临时重定向(正在迁移中)
// 单次请求发送到目标节点,后续仍发原节点
ASK 3999 192.168.1.20:7002
```
> [!TIP] 重试策略要点
> - MOVED 后更新本地路由表,**不再重试旧节点**
> - ASK 只重试**这一次**,之后恢复原来的路由
> - 如果连续收到 MOVED/ASK,说明集群正在剧烈变更,适当退避
## 软客户端 vs 硬客户端
> [!QUESTION] 思考
> 客户端拿到第一个 MOVED 响应后才去查询新拓扑,还是从一开始就维护完整的集群地图?哪种方式在扩容时更稳定?
### 两种模式对比
| 特性 | 软客户端(Lazy Mode) | 硬客户端(Eager Mode) |
|------|---------------------|----------------------|
| 初始化行为 | 连接任一节点获取初始拓扑 | 预取所有节点完整拓扑 |
| 拓扑更新 | 遇到 MOVED/ASK 时按需刷新 | 后台定时拉取,提前感知变化 |
| 扩容时体验 | 迁移过渡期频繁 MOVED,请求偶发延迟 | 平滑无感,提前知道新 slot 在哪 |
| 内存开销 | 仅存储已知节点 | 存储全部节点 + slot→node 映射表 |
| 代表库 | lettuce(默认 lazy) | go-redis, jedis(内置拓扑缓存) |
```go
// go-redis 示例:硬客户端,自动维护拓扑
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{":7001", ":7002", ":7003"},
PoolSize: 20, // 每个节点独立连接池
MinIdleConns: 5, // 保持最小空闲连接
ReadTimeout: 3 * time.Second, // 读超时
WriteTimeout: 3 * time.Second, // 写超时
IdleTimeout: 5 * time.Minute, // 连接空闲回收
})
// 连接池隔离:每个节点有独立的连接池,互不影响
// GET 请求 go-redis 会自动判断 key 所在的 slot 并路由到正确节点
val, err := rdb.Get(ctx, "user:100:name").Result()
```
```java
// lettuce 示例:软客户端,按需刷新拓扑(Java/Spring)
RedisClusterClient client = RedisClusterClient.create("redis://node1:7001");
StatefulRedisClusterConnection<String, String> conn = client.connect();
// 遇到 MOVED 时,ClusterTopologyRefreshScheduler 自动重新发现拓扑
String val = conn.sync().get("key");
```
> [!TIP] 推荐配置
> 生产环境建议采用硬客户端 + 合理连接池大小。每个节点独立连接池可以避免热点 key 占满单个节点的连接资源。
## 生产部署关键注意点
### 网络与安全
| 项目 | 建议值/做法 | 原因 |
|------|------------|------|
| 防火墙 | 开放 `port` 和 `port+10000` | 集群总线通信依赖双端口 |
| TLS | Redis 7.0+ 支持 TLS(需编译开启) | 加密集群总线通信,防止拓扑泄露 |
| ACL | Redis 6+ 启用 ACL 认证 | 替代传统 AUTH,更细粒度权限控制 |
| bind | 绑定内网 IP,不暴露公网 | 集群总线不鉴权,外网可控制整个集群 |
### 参数调优
| 参数 | 推荐值 | 说明 |
|------|--------|------|
| `cluster-node-timeout` | 15000 ms | 不要太短,避免网络抖动误判;不要长于业务容忍度 |
| `maxmemory-policy` | 缓存场景 `allkeys-lru`;持久化场景 `noeviction` | 缓存类应用优先 LRU 避免 OOM;需要严格保证写入成功的场景用 `noeviction`,由业务层处理满错误 |
| `appendonly` | `yes` | 集群推荐 AOF + 每秒刷盘,兼顾性能与安全 |
| `cluster-replica-validity-factor` | 10 | 乘以 timeout 作为复制链路容忍阈值,0 表示不拒绝主从链接 |
| `cluster-require-full-coverage` | `yes`(默认) | `yes` 时任意 slot 无主节点则集群整体拒绝写入;生产环境若追求可用性可设 `no`,允许部分 slot 不可用时其余 slot 正常服务 |
| `cluster-allow-reads-when-down` | `no` | 设为 `yes` 时集群即使处于 FAIL 状态也允许读请求(前提:该 key 所在 slot 有可达节点),适合读密集型业务容忍短暂降级 |
> [!IMPORTANT] `cluster-require-full-coverage` 的取舍
> 当某个 Master 和它的所有 Replica 同时宕机时,该 Master 负责的 slot 就"裸奔"了。
> - **`yes`(默认)**:集群整体拒绝读写,宁可停机也不允许数据不一致——适合对一致性要求极高的金融场景。
> - **`no`**:集群只拒绝访问故障 slot 的请求,其他 slot 继续服务——适合追求可用性的互联网业务,牺牲部分可用 slot 换取整体不宕机。
### 架构最佳实践
```mermaid
flowchart TD
App["你的应用服务"] --> SA["Service A<br/>:7001-7007"]
App --> SB["Service B<br/>:7004-7007"]
App --> SC["Service C<br/>:7010-7017"]
App --> SD["Service D<br/>:7016-7023"]
SA --> C1["Cluster 1<br/>独立集群"]
SB --> C2["Cluster 2<br/>独立集群"]
SC --> C3["Cluster 3<br/>独立集群"]
SD --> C4["Cluster 4<br/>独立集群"]
style App fill:#e3f2fd,stroke:#1565c0
style C1,C2,C3,C4 fill:#e8f5e9,stroke:#4caf50
```
> [!IMPORTANT] 原则
> 1. **一服一群**:不同业务线使用独立集群,避免互相干扰
> 2. **奇数 Master**:选举需要多数派,偶数 Master 不如少一个 Master 多一个 Slave
> 3. **至少 3 Master + 3 Replica**:单 Master 没有高可用意义
> 4. **数据隔离**:不要用 `FLUSHALL`,集群里会影响所有节点
> 5. **大 key 慎用**:集群下大 key 无法拆分,会导致单个节点内存/带宽瓶颈
## 大 Key 的检测与处理
> [!WARNING] 为什么集群下大 Key 更危险?
> 单机 Redis 的大 Key 只影响自己;集群模式下,大 Key 不可拆分——它**固定落在某个 slot、某台机器**。这台机器的 CPU、内存、带宽瞬间成为瓶颈,相当于把集群退化成了单点。
**检测:**
```bash
# redis-cli 自带扫描(Redis 4.0+)
redis-cli --bigkeys # 只统计每种类型中最大的 key
redis-cli --memkeys # 按内存占用排序,更精确
redis-cli --bigkeys --i 0.01 # 控制扫描间隔(秒),避免线上打满带宽
# 输出示例
# [00.00%] Biggest string found so far 'user:100:feed' with 536870912 bytes
# [00.01%] Biggest hash found so far 'order:cache' with 876543 entries
```
**处理策略:**
| 类型 | 问题 | 拆分方案 |
|------|------|---------|
| 大 String | `GET` 一次传输全量数据 | 拆成多个子 key:`item:1:chunk1`、`item:1:chunk2` |
| 大 Hash | `HGETALL` 阻塞,cursor 遍历慢 | 按字段分桶:`user:100:profile:v1`、`user:100:profile:v2` |
| 大 List | `LRANGE` 一次返回全量 | 只保留最近 N 条,历史数据归档到 DB/ES |
| 大 Set/ZSet | `SMEMBERS`/`ZREVRANGE` 带宽打满 | 分段查询 + 游标,或业务层拆成多个小集合 |
> [!TIP] 异步删除(Redis 4.0+)
> 对已有的大 Key,优先用 `UNLINK`(异步删除)替代 `DEL`(同步阻塞),防止删除操作拖垮主线程。删除前用 `DEBUG OBJECT key` 查看 `serializedlength` 估算影响范围。
## 关联笔记
- [[hhs/Redis/07-集群方案/一致性哈希]] — 一致性哈希原理与虚拟节点
- [[hhs/Redis/07-集群方案/Gossip协议]] — Gossip 协议的传播原理与收敛特性
- [[hhs/Redis/07-集群方案/Raft共识算法]] — Raft 领导者选举与 Redis Failover
- [[hhs/Redis/07-集群方案/脑裂问题]] — 脑裂问题的本质与防护策略
- [[hhs/Redis/06-主从与哨兵]] — Sentinel 单主高可用方案
- [[hhs/Redis/11-运维与性能调优]] — 监控指标与告警
- [[hhs/Redis/README]] — 知识索引总览