Skip to content

集群模式 ​

哨兵解决了高可用,但数据量大到单机装不下时,就需要把数据分片到多个节点。Redis Cluster 通过哈希槽把数据分散到多个主节点,同时支持水平扩展与自动故障转移。本章从分片原理、Gossip 协议、请求路由到扩缩容,完整讲解集群机制。

1. 分片原理 ​

Redis Cluster 把整个键空间划分为 16384 个哈希槽(slot),每个节点负责一部分槽。

1.1 槽的分配 ​

txt
节点 A:slot 0 ~ 5460(5461 个槽)
节点 B:slot 5461 ~ 10922(5462 个槽)
节点 C:slot 10923 ~ 16383(5461 个槽)

1.2 key 到槽的映射 ​

txt
槽号 = CRC16(key) % 16384

如果 key 包含 {},只对 {} 内的内容计算哈希(哈希标签):

txt
{user:1001}:name  → CRC16("user:1001") % 16384
{user:1001}:age   → CRC16("user:1001") % 16384
两者槽号相同

核心目的

哈希标签的核心目的,是让应用层能主动把多个逻辑相关的 key 强制归入同一个槽,从而规避集群「跨槽禁操作」的限制(见第 6 节)。

1.3 为什么是 16384 ​

原因说明
心跳包大小Gossip 心跳携带槽位图,16384 个槽用 2KB(16384/8=2048 字节)表示
规模设计规范以最多约 1000 个节点作为线性扩展设计目标,16384 个槽足以分配
网络开销槽数量过大时心跳包更大,节点通信开销增加

为什么不用 65536?作者 antirez 的解释:集群节点数不太可能超过 1000,16384 个槽已经足够,心跳包用 2KB 表示比 8KB 更高效。

2. Gossip 协议 ​

Gossip 是一种“人人都随口互相通风报信”的分布式通信协议:每个节点定期随机挑几个节点,把自己知道的最新状态告诉对方;对方再继续传播。最终整个集群都会知道所有节点的状态。

节点之间通过 Gossip 协议交换集群状态。每个节点每秒随机选择几个节点发送 PING,接收方返回 PONG。

2.1 消息类型 ​

消息方向内容
PINGA → B发送 A 知道的集群状态(槽分配、节点信息)
PONGB → A返回 B 知道的集群状态
MEETA → B新节点加入集群
FAIL广播通知所有节点某个节点已故障

2.2 消息体 ​

下图是便于理解的示意字段,不是 Redis Cluster 线协议的实际编码:

txt
{
  "sender": "node-a",
  "slots": [0, 1, 2, ..., 5460],     // 负责的槽
  "cluster_state": "ok",
  "cluster_size": 3,
  "nodes": [                           // 已知的节点列表
    {"id": "node-a", "ip": "10.0.0.1", "port": 6379, "flags": "master"},
    {"id": "node-b", "ip": "10.0.0.2", "port": 6379, "flags": "master"},
    {"id": "node-c", "ip": "10.0.0.3", "port": 6379, "flags": "master"}
  ]
}

2.3 收敛速度 ​

Gossip 是最终一致的:新状态需要几轮传播才能到达所有节点,节点越多、收敛越慢。具体耗时取决于节点规模、网络延迟与消息发送间隔,没有统一的固定数值。

3. 请求路由 ​

3.1 MOVED(永久重定向) ​

客户端请求的 key 不在当前节点时,返回 MOVED:

txt
客户端 → 节点A:GET user:1001
节点A  → 客户端:-MOVED 3999 10.0.0.2:6379
客户端 → 节点B:GET user:1001(永久改道)

智能客户端会缓存「槽 → 节点」映射,后续请求直接路由到正确节点。

3.2 ASK(临时重定向) ​

槽迁移期间,key 可能在旧节点也可能在新节点:

txt
客户端 → 节点A:GET user:1001
节点A  → 客户端:-ASK 3999 10.0.0.2:6379
客户端 → 节点B:ASKING + GET user:1001(临时访问,不更新映射)
类型场景客户端行为
MOVED槽已归属其他节点更新本地映射,永久改道
ASK槽正在迁移临时访问一次,不更新映射

3.3 Jedis Cluster 客户端 ​

java
Set<HostAndPort> nodes = new HashSet<>();
nodes.add(new HostAndPort("10.0.0.1", 6379));
nodes.add(new HostAndPort("10.0.0.2", 6379));
nodes.add(new HostAndPort("10.0.0.3", 6379));

JedisCluster cluster = new JedisCluster(nodes);

// 自动路由到正确节点
cluster.set("user:1001:name", "张三");
String name = cluster.get("user:1001:name");

// 同槽多 key 操作
cluster.mset("{user:1001}:name", "张三", "{user:1001}:age", "25");

4. 故障检测 ​

4.1 PFAIL → FAIL ​

txt
节点A → 节点B:PING
节点B → 节点A:...(超时无响应)
节点A 标记 B 为 PFAIL(疑似下线)

节点C → 节点B:PING
节点B → 节点C:...(超时无响应)
节点C 标记 B 为 PFAIL

主节点在多个 gossip 周期中确认 B 持续 PFAIL,并且超过半数主节点持相同观点 → B 升级为 FAIL(确认下线)
阶段说明
PFAIL(疑似下线)单个节点认为目标下线
FAIL(确认下线)超过半数主节点确认,广播 FAIL 消息

4.2 故障转移 ​

从节点发现自己的主节点 FAIL 后,发起选举:

txt
1. 从节点向所有主节点请求投票
2. 获得超过半数主节点投票的从节点当选
3. 新主节点接管原主节点的所有槽
4. 新主节点广播自己的新身份

复制偏移量最大的从节点具有更高 rank,通常会更早开始申请投票;但主节点不会像 Sentinel 那样按偏移量选出“最佳副本”,最终仍由 epoch、投票规则和时序决定选举结果。

5. 扩缩容 ​

5.1 添加节点 ​

bash
# 1. 启动新节点
redis-server --port 6380 --cluster-enabled yes

# 2. 加入集群
redis-cli --cluster add-node 10.0.0.4:6380 10.0.0.1:6379

# 3. 分配槽(从现有节点迁移)
redis-cli --cluster reshard 10.0.0.1:6379
# 交互式:迁多少个槽?→ 从哪些节点迁?→ 迁到哪个节点?

5.2 移除节点 ​

bash
# 1. 先迁走该节点的槽
redis-cli --cluster reshard 10.0.0.1:6379

# 2. 移除节点
redis-cli --cluster del-node 10.0.0.1:6379 <node-id>

5.3 槽迁移过程 ​

txt
1. 目标节点:CLUSTER SETSLOT <slot> IMPORTING <source-node-id>(准备接收)
2. 源节点:CLUSTER SETSLOT <slot> MIGRATING <target-node-id>(准备发送)
3. 源节点:CLUSTER GETKEYSINSLOT <slot> <count>(获取槽内 key 列表)
4. 源节点:MIGRATE <target-ip> <port> "" 0 5000 KEYS k1 k2 ...(伪代码;实际循环迁移 GETKEYSINSLOT 返回的 Key)
5. 所有节点:CLUSTER SETSLOT <slot> NODE <target-node-id>(更新槽归属)

为什么需要 IMPORTING / MIGRATING 两个状态同时存在?槽迁移不是原子的,key 逐个从源迁到目标,迁移过程中某个 key 可能还在源节点、也可能已到目标节点。源节点标记 MIGRATING:请求的 key 若还在本地就正常处理,不在则返回 ASK 指向目标节点;目标节点标记 IMPORTING:只有收到 ASKING 前缀的请求才允许查询该槽。双向状态让迁移期间的读写请求总能找到 key 当前所在的节点。

迁移期间,对该槽的请求遵循 ASK 规则。

6. 跨槽限制 ​

集群下,多 key 命令要求所有 key 在同一个槽:

bash
# 合法(同槽)
MGET {user:1001}:name {user:1001}:age

# 非法(跨槽)
MGET user:1001 user:1002   # ERR

涉及跨槽的解决方案:

方案说明
哈希标签相关 key 用 {tag} 强制同槽
应用层拆分客户端按槽分组,分别发送
事务/Lua跨槽不支持,必须同槽

Released under the MIT License.