--- 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 收敛原理:
集群越大 fanout 越大,约 O(sqrt(N)),
O(log(N)) 轮即可全同步 ``` **Gossip 传播的三类信息:** 1. **PING/PONG** — 心跳,携带发送者的状态摘要 2. **MEET** — 手动加入集群,向指定节点发送 MEET 指令 3. **FAIL** — 某节点不可达,广播给其他节点 > [!QUESTION] 思考 > Gossip 节点选择策略会随集群规模动态调整——小集群(<100 节点)向所有节点发 PING,大集群才随机选择子集。为什么不直接广播给所有节点?这样做的带宽开销是怎样的? ### PING/PONG 报文详解 ``` PING 报文体包含: ├── 发送节点的 nodeId ├── 槽位分配表(哪些节点负责哪些 slot) ├── 节点状态(master/slave/fail) ├── 最后通信时间戳 └── 当前集群 epoch(用于版本控制) ``` ## 数据迁移流程 ### 移动一个 slot(跨节点) ```bash # 在源节点执行(准备阶段) CLUSTER SETSLOT MIGRATING # 在目标节点执行(确认阶段) CLUSTER SETSLOT IMPORTING # 逐 key 迁移(GETKEYSINSLOT 返回指定 slot 中的 key,每次最多 100 个) 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 NODE ``` ### 自动化迁移 —— redis-cli cluster reshard ```bash redis-cli --cluster reshard node-host:6379 \ --cluster-from \ --cluster-to \ --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
各 5461 slots"] -->|添加 3 新节点| Middle["6 Master
slot 全空"] Middle -->|reshard 迁 slot| After["6 Master
各 ~2730 slots"] After -->|加 replica| Final["6 Master + 6 Replica"] ``` > [!TIP] 扩容节奏建议 > 1. 先加 Master 并分 slot > 2. 再给每个 Master 配 Replica > 3. 分批迁移(每次 1~2 台),避免流量剧变 ## 故障转移机制(Failover) > [!QUESTION] 思考 > 假设一个 Master 宕机了,谁来决定哪个 Replica 接任?如果让客户端来判断,会有什么问题? ### 故障检测三阶段 ```mermaid flowchart LR A["PFAIL 潜在失效
单个节点判定"] -->|"超过 cluster-node-timeout"| B["FAIL 确认失效
多数 Master 同意"] B -->|"Raft 式投票"| C["选举 Leader
Replica 竞选"] C -->|"最高优先级获胜"| D["Failover
接管 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. **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 表示不拒绝主从链接 | | `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
:7001-7007"] App --> SB["Service B
:7004-7007"] App --> SC["Service C
:7010-7017"] App --> SD["Service D
:7016-7023"] SA --> C1["Cluster 1
独立集群"] SB --> C2["Cluster 2
独立集群"] SC --> C3["Cluster 3
独立集群"] SD --> C4["Cluster 4
独立集群"] 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 无法拆分,会导致单个节点内存/带宽瓶颈 ## 关联笔记 - [[hhs/Redis/06-主从与哨兵]] — Sentinel 单主高可用方案 - [[hhs/Redis/11-运维与性能调优]] — 监控指标与告警 - [[hhs/Redis/README]] — 知识索引总览