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

405 lines
15 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"]
```
## 哈希槽机制
### 为什么不用一致性哈希?
| 对比项 | 一致性哈希 | 哈希槽 |
|--------|-----------|---------|
| 增节点迁移量 | O(N/K × log N) | **O(1/N × total_slots)** = 每次 ~1300 slots |
| 客户端透明性 | ✅ 通常透明 | ⚠️ 需要 MOVED/ASK 重定向 |
| 数据倾斜 | 依赖虚拟节点 | ❌ slots 分配不均会倾斜 |
| 复杂度 | 简单 | 略复杂(Gossip + 槽位管理) |
> [!QUESTION] 思考
> Redis 选择了 16384 个槽而不是其他数字,你觉得为什么是这个数?太少了会怎样?太多了又会怎样?
### 槽位计算
Redis 使用 **CRC16 算法**对 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 协议
### 节点间通信端口
```
节点通信端口 = client_port + 10000
例如: 6379 → 16379 (cluster bus)
```
> [!WARNING] 防火墙配置
> 生产环境中必须开放 `port` 和 `port+10000` 两个端口。很多云服务器安全组默认只开一个端口,会导致集群组建失败或节点状态异常。
### Gossip 协议运作方式
每个节点每秒向其他节点发送 PING/PONG,维持集群状态的一致性:
```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/>每次随机选 3 个节点交换信息,<br/>N 个节点 ~ log₃(N) 轮即可全同步
```
**Gossip 传播的三类信息:**
1. **PING/PONG** — 心跳,携带发送者的状态摘要
2. **MEET** — 手动加入集群,向指定节点发送 MEET 指令
3. **FAIL** — 某节点不可达,广播给其他节点
> [!QUESTION] 思考
> 为什么 Gossip 不直接发给所有节点,而是随机选 3 个?当集群扩展到 1000 台节点时,这样做的好处是什么?
### PING/PONG 报文详解
```
PING 报文体包含:
├── 发送节点的 nodeId
├── 槽位分配表(哪些节点负责哪些 slot)
├── 节点状态(master/slave/fail)
├── 最后通信时间戳
└── 当前集群 epoch(用于版本控制)
```
## 数据迁移流程
### 移动一个 slot(跨节点)
```bash
# 在源节点执行(准备阶段)
CLUSTER SETSLOT <slot_id> MIGRATING <target_node_id>
# 在目标节点执行(确认阶段)
CLUSTER SETSLOT <slot_id> IMPORTING <source_node_id>
# 逐 key 迁移(客户端负责搬 data)
for key in $(redis-cli -h source-cluster-scan $slot --count 100); do
redis-cli -h source MIGRATE target "" $key 0 5000
done
# 完成迁移
CLUSTER SETSLOT <slot_id> NODE <target_node_id>
```
### 自动化迁移 —— 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` |
| Lua 脚本有限 | 不支持非确定性的函数(time/random) | 使用 `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 接任?如果让客户端来判断,会有什么问题?
### 故障检测三阶段
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PFAIL 潜在失效 │ → │ FAIL 确认失效 │ → │ 选举 Leader │ → Failover
│ (单个节点判定) │ │ (多数节点同意) │ │ (Replica 竞选) │
└─────────────┘ └─────────────┘ └─────────────┘
↓ 超时 ↓ 多数派 ↓ Raft 式投票
cluster-node-timeout 超过半数标记 最高优先级获胜利
```
**详细流程:**
| 阶段 | 谁触发 | 条件 | 持续时间 |
|------|--------|------|---------|
| **PFAIL** | 单个节点 | 超过 `cluster-node-timeout` 未收到 PONG | 瞬时 |
| **FAIL** | PFAIL 节点广播 | 大多数 Master 同意该节点不可达 | 秒级 |
| **选举** | 宕机 Master 下唯一存活 Replica | 发起集群纪元递增 + Raft 投票 | 秒级 |
| **Failover** | 当选 Leader | 提升为 Master,接管原 Master 的 slots | 亚秒级 |
### 选举规则
1. **只有 Replica 能参与选举**,Master 不会主动抢占
2. **选举时机**:Replica 发现自己复制的 Master 进入 FAIL 状态
3. **Raft 式投票**:每个 Master 一票,先到先得
4. **获胜条件**:获得多数 Master 的选票
5. **升级步骤**:
- 将自己提升为 Master
- 接管所有原 Master 管理的 slots
- 向所有节点广播新的配置
> [!NOTE] 脑裂保护
> 如果网络分区导致两个节点的 Master 都认为对方宕机,各自选举出新的 Master,Redis 会通过 **epoch 版本号**解决冲突——后升级的那个会被回退,这是为了避免「脑裂」写丢失。
### 客户端容错处理
集群模式下客户端会收到两种重定向响应:
```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()
```
```go
// lettuce 示例:软客户端,按需刷新拓扑(Java/Spring)
let cluster = RedisCluster.connect(url)
// 遇到 MOVED 时自动重新发现拓扑
let val = await cluster.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` | 集群场景统一设置 LRU 淘汰,避免部分节点 OOM |
| `appendonly` | `yes` | 集群推荐 AOF + 每秒刷盘,兼顾性能与安全 |
| `cluster-replica-validity-factor` | 10 | 乘以 timeout 作为复制链路容忍阈值,0 表示不拒绝主从链接 |
### 架构最佳实践
```
┌─────────────────────────────────────────────┐
│ 你的应用服务 │
└──┬──────────┬──────────┬──────────┬──────────┘
│ │ │ │
┌──▼────┐ ┌──▼────┐ ┌──▼────┐ ┌──▼────┐
│Service A│ │Service B│ │Service C│ │Service D│
│ :7001-7 │ │ :7004-7 │ │ :7010-7 │ │ :7016-7 │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
Cluster 1 Cluster 2 Cluster 3 Cluster 4
(独立集群) (独立集群) (独立集群) (独立集群)
```
> [!IMPORTANT] 原则
> 1. **一服一群**:不同业务线使用独立集群,避免互相干扰
> 2. **奇数 Master**:选举需要多数派,偶数 Master 不如少一个 Master 多一个 Slave
> 3. **至少 3 Master + 3 Replica**:单 Master 没有高可用意义
> 4. **数据隔离**:不要用 `FLUSHALL`,集群里会影响所有节点
> 5. **大 key 慎用**:集群下大 key 无法拆分,会导致单个节点内存/带宽瓶颈
## 关联笔记
- [[hhs/Redis/06-主从与哨兵]] — Sentinel 单主高可用方案
- [[hhs/Redis/09-运维调优]] — 监控指标与告警
- [[hhs/Redis/README]] — 知识索引总览