14 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-05-15 18:14 |
Redis Cluster 集群方案
概述
Redis Cluster 是 Redis 官方的分布式解决方案,通过数据分片(sharding)将数据分布在多个节点上,支持横向扩展和自动故障转移。核心设计:**哈希槽(Hash Slots)**而非一致性哈希。
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 个槽:
# CRC16(key) % 16384 = slot 编号
# 例:CRC16("foo") % 16384 = 5798 → 属于 Node B (5461~10922)
// 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,维持集群状态的一致性:
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 传播的三类信息:
- PING/PONG — 心跳,携带发送者的状态摘要
- MEET — 手动加入集群,向指定节点发送 MEET 指令
- FAIL — 某节点不可达,广播给其他节点
[!QUESTION] 思考 为什么 Gossip 不直接发给所有节点,而是随机选 3 个?当集群扩展到 1000 台节点时,这样做的好处是什么?
PING/PONG 报文详解
PING 报文体包含:
├── 发送节点的 nodeId
├── 槽位分配表(哪些节点负责哪些 slot)
├── 节点状态(master/slave/fail)
├── 最后通信时间戳
└── 当前集群 epoch(用于版本控制)
数据迁移流程
移动一个 slot(跨节点)
# 在源节点执行(准备阶段)
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
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)
# 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(开发环境)
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:
# ...
连接集群客户端
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 跨节点问题
// ❌ 错误: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")
运维命令速查
# 集群状态
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
扩容指南
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] 扩容节奏建议
- 先加 Master 并分 slot
- 再给每个 Master 配 Replica
- 分批迁移(每次 1~2 台),避免流量剧变
故障转移机制(Failover)
[!QUESTION] 思考 假设一个 Master 宕机了,谁来决定哪个 Replica 接任?如果让客户端来判断,会有什么问题?
故障检测三阶段
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 | 亚秒级 |
选举规则
- 只有 Replica 能参与选举,Master 不会主动抢占
- 选举时机:Replica 发现自己复制的 Master 进入 FAIL 状态
- Raft 式投票:每个 Master 一票,先到先得
- 获胜条件:获得多数 Master 的选票
- 升级步骤:
- 将自己提升为 Master
- 接管所有原 Master 管理的 slots
- 向所有节点广播新的配置
[!NOTE] 脑裂保护 如果网络分区导致两个节点的 Master 都认为对方宕机,各自选举出新的 Master,Redis 会通过 epoch 版本号解决冲突——后升级的那个会被回退,这是为了避免「脑裂」写丢失。
客户端容错处理
集群模式下客户端会收到两种重定向响应:
// 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-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()
// 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 表示不拒绝主从链接 |
架构最佳实践
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] 原则
- 一服一群:不同业务线使用独立集群,避免互相干扰
- 奇数 Master:选举需要多数派,偶数 Master 不如少一个 Master 多一个 Slave
- 至少 3 Master + 3 Replica:单 Master 没有高可用意义
- 数据隔离:不要用
FLUSHALL,集群里会影响所有节点- 大 key 慎用:集群下大 key 无法拆分,会导致单个节点内存/带宽瓶颈
关联笔记
- hhs/Redis/06-主从与哨兵 — Sentinel 单主高可用方案
- hhs/Redis/11-运维与性能调优 — 监控指标与告警
- hhs/Redis/README — 知识索引总览