Redis 集群模式详解(上篇)

137 阅读22分钟

Redis 集群模式详解(上篇):架构、数据结构与 Gossip 协议

哨兵模式解决了高可用问题,但仍然受限于单机容量。Redis Cluster 通过数据分片实现了水平扩展,既提供高可用,又突破了单机内存限制。本文是 Redis Cluster 系列的上篇,深入剖析集群架构、节点内部数据结构、Hash Slot 机制以及 Gossip 协议的传播原理。

📖 目录


为什么需要 Redis Cluster?

哨兵模式的瓶颈

哨兵模式的三大限制:

1. 容量限制:
┌─────────────────────┐
│  Master (64GB)      │  单机内存上限
│  • 数据增长到 64GB   │  
│  • 无法继续扩展 ❌   │
└─────────────────────┘

2. 写入瓶颈:
Master: 5 万 QPS
• 所有写入集中在一个节点
• 无法水平扩展写能力 ❌

3. 成本问题:
• 256GB 内存服务器:约 8 万元
• 8 台 32GB 服务器:约 3 万元
• 大服务器成本高且不灵活 ❌

Redis Cluster 的解决方案

分布式集群:数据分片 + 高可用

容量扩展:
┌──────────┐  ┌──────────┐  ┌──────────┐
│ Master1  │  │ Master2  │  │ Master3  │
│  20GB    │  │  20GB    │  │  20GB    │
│ Slot     │  │ Slot     │  │ Slot     │
│ 0-5460   │  │5461-10922│  │10923-16383│
└──────────┘  └──────────┘  └──────────┘
   总容量:60GB ✅(可扩展到 TB 级)

写入扩展:
Master1: 2 万 QPS
Master2: 2 万 QPS
Master3: 2 万 QPS
总计:6 万 QPS ✅(N 倍提升)

成本优化:
• 使用多台普通服务器 ✅
• 按需扩展 ✅
• 成本降低 50%+ ✅

Cluster 整体架构

┌─────────────────────────────────────────────────┐
    Redis Cluster 完整架构(3  3 从)           
└─────────────────────────────────────────────────┘

              客户端(Smart Client)
                      
          1. 计算 Key  Slot
          slot = CRC16(key) % 16384
                      
          2. 查本地槽位缓存
          slot 14909  Node3
                      
          3. 直接连接目标节点
        ┌─────────────┼─────────────┐
                                  
  ┌──────────┐  ┌──────────┐  ┌──────────┐
   Master1     Master2     Master3  
   6379        6380        6381     
   Slots:      Slots:      Slots:   
   0-5460     │5461-10922│  │10923-16383│
  └────┬─────┘  └────┬─────┘  └────┬─────┘
        主从复制     主从复制     主从复制
                                 
  ┌──────────┐  ┌──────────┐  ┌──────────┐
    Slave1      Slave2      Slave3  
    6382        6383        6384    
  └──────────┘  └──────────┘  └──────────┘

  ┌──────────────────────────────────────┐
     Cluster Bus(集群总线,端口+10000) 
     16379, 16380, 16381, 16382, ...    
  └───────────────┬──────────────────────┘
                  
    Gossip 协议(节点间通信)
     PING/PONG 心跳(每秒)
     配置传播(槽位、纪元)
     故障检测(PFAIL/FAIL)

节点内部数据结构深度解析

clusterState 全局状态

每个节点维护完整的集群视图:

// cluster.h
typedef struct clusterState {
    clusterNode *myself;                    // 自己
    uint64_t currentEpoch;                  // 当前纪元(全局)
    int state;                              // 集群状态(OK/FAIL)
    int size;                               // 负责槽位的 Master 数
    
    // ===== 节点管理 =====
    dict *nodes;                            // 所有节点 Dict
    dict *nodes_black_list;                 // 黑名单
    
    // ===== 槽位管理 =====
    clusterNode *slots[CLUSTER_SLOTS];      // 槽位→节点映射(16384个)
    uint64_t slots_keys_count[CLUSTER_SLOTS]; // 每个槽位的Key数量
    rax *slots_to_keys;                     // 槽位→Key的Radix树
    
    // ===== 迁移状态 =====
    clusterNode *migrating_slots_to[CLUSTER_SLOTS];      // 迁出目标
    clusterNode *importing_slots_from[CLUSTER_SLOTS];    // 迁入来源
    
    // ===== 故障转移 =====
    mstime_t failover_auth_time;            // 请求投票时间
    int failover_auth_count;                // 收到的投票数
    int failover_auth_sent;                 // 是否已请求投票
    uint64_t failover_auth_epoch;           // 投票纪元
    
    // ===== 统计信息 =====
    long long stats_bus_messages_sent;      // 发送的消息数
    long long stats_bus_messages_received;  // 接收的消息数
    
} clusterState;

内存布局示意

Node1 的 clusterState 内存布局:
┌─────────────────────────────────────────────────┐
│                  clusterState                    │
├─────────────────────────────────────────────────┤
│ myself → ┌────────────────────┐                 │
│          │ clusterNode (自己)  │                 │
│          │ name: 07c37dfe...  │                 │
│          │ flags: MASTER|MYSELF│                 │
│          │ slots: [位图2KB]    │                 │
│          └────────────────────┘                 │
├─────────────────────────────────────────────────┤
│ nodes (Dict) → {                                │
│   "07c37dfe..." → clusterNode (Node1, myself)   │
│   "67ed2db8..." → clusterNode (Node2)           │
│   "2dcb8d1f..." → clusterNode (Node3)           │
│   "9f8e7d6c..." → clusterNode (Node4)           │
│   "5a4b3c2d..." → clusterNode (Node5)           │
│   "3e2f1d0c..." → clusterNode (Node6)           │
│ }                                               │
├─────────────────────────────────────────────────┤
│ slots[16384] → 槽位映射表                        │
│   slots[0]     → Node1 ──┐                     │
│   slots[1]     → Node1   │                     │
│   ...                    │ Node1 负责           │
│   slots[5460]  → Node1 ──┘                     │
│   slots[5461]  → Node2 ──┐                     │
│   ...                    │ Node2 负责           │
│   slots[10922] → Node2 ──┘                     │
│   slots[10923] → Node3 ──┐                     │
│   ...                    │ Node3 负责           │
│   slots[16383] → Node3 ──┘                     │
├─────────────────────────────────────────────────┤
│ slots_keys_count[16384] → 每个槽的Key数量        │
│   slots_keys_count[0] = 1500                   │
│   slots_keys_count[1] = 2300                   │
│   ...                                           │
├─────────────────────────────────────────────────┤
│ currentEpoch: 100  (全局纪元)                   │
│ state: CLUSTER_OK                               │
│ size: 3  (3 个 Master)                         │
└─────────────────────────────────────────────────┘

总大小:约 200KB(包含所有节点信息和槽位映射)

clusterNode 节点信息

// cluster.h
typedef struct clusterNode {
    mstime_t ctime;                         // 创建时间
    char name[CLUSTER_NAMELEN];             // 节点ID(40字节,十六进制)
    int flags;                              // 状态标志
    uint64_t configEpoch;                   // 配置纪元
    
    // ===== 槽位信息(位图,2KB)=====
    unsigned char slots[CLUSTER_SLOTS/8];   // 16384/8 = 2048 字节
    int numslots;                           // 槽位数量
    
    // ===== 主从关系 =====
    int numslaves;                          // 从节点数
    struct clusterNode **slaves;            // 从节点数组
    struct clusterNode *slaveof;            // 主节点(如果是slave)
    
    // ===== 网络信息 =====
    mstime_t ping_sent;                     // 最后PING时间
    mstime_t pong_received;                 // 最后PONG时间
    mstime_t data_received;                 // 最后数据接收时间
    mstime_t fail_time;                     // 下线时间
    char ip[NET_IP_STR_LEN];                // IP地址
    int port;                               // 客户端端口
    int cport;                              // 集群总线端口
    clusterLink *link;                      // TCP连接
    
    // ===== 故障报告 =====
    list *fail_reports;                     // 故障报告列表
    
} clusterNode;

详细字段说明

// flags 标志位(位掩码)
#define CLUSTER_NODE_MASTER 1        // 0x0001: Master
#define CLUSTER_NODE_SLAVE 2         // 0x0002: Slave
#define CLUSTER_NODE_PFAIL 4         // 0x0004: 主观下线
#define CLUSTER_NODE_FAIL 8          // 0x0008: 客观下线
#define CLUSTER_NODE_MYSELF 16       // 0x0010: 自己
#define CLUSTER_NODE_HANDSHAKE 32    // 0x0020: 握手中
#define CLUSTER_NODE_NOADDR 64       // 0x0040: 无地址
#define CLUSTER_NODE_MEET 128        // 0x0080: 需要MEET
#define CLUSTER_NODE_MIGRATE_TO 256  // 0x0100: 迁移目标

// flags 组合示例
flags = CLUSTER_NODE_MASTER | CLUSTER_NODE_MYSELF  // 17 (0x0011)
flags = CLUSTER_NODE_SLAVE | CLUSTER_NODE_PFAIL    // 6  (0x0006)

clusterNode 实例示例

Node1 的完整信息:
┌──────────────────────────────────────────────┐
            clusterNode (Node1)                
├──────────────────────────────────────────────┤
 name: 07c37dfeb235213a872192d90877d0cd55635b91│
       (40字节十六进制,SHA1哈希生成)          
├──────────────────────────────────────────────┤
 flags: 17  (二进制: 0001 0001)               
        = MASTER (1) | MYSELF (16)            
├──────────────────────────────────────────────┤
 configEpoch: 1  (配置版本号)                 
├──────────────────────────────────────────────┤
 slots (位图2KB):                              
   byte 0:    11111111  (Slot 0-7)            
   byte 1:    11111111  (Slot 8-15)           
   ...                                         
   byte 682:  11110000  (Slot 5456-5463)      
   byte 683:  00000000  (Slot 5464-5471)      
   ...                                         
   byte 2047: 00000000  (Slot 16376-16383)    
                                               
 numslots: 5461                               
├──────────────────────────────────────────────┤
 slaveof: NULL  (我是Master)                  
 numslaves: 1                                 
 slaves[0]  Node2 (Slave)                    
├──────────────────────────────────────────────┤
 ip: 127.0.0.1                                
 port: 6379                                   
 cport: 16379  (集群总线端口)                  
├──────────────────────────────────────────────┤
 ping_sent: 1698307200000  (毫秒时间戳)        
 pong_received: 1698307201000                 
 fail_time: 0  (未下线)                        
├──────────────────────────────────────────────┤
 link  ┌─────────────────┐                   
         clusterLink                        
         fd: 25                             
         sndbuf: ...                        
         rcvbuf: ...                        
        └─────────────────┘                   
├──────────────────────────────────────────────┤
 fail_reports: []  (故障报告列表,空)          
└──────────────────────────────────────────────┘

总大小:约 3KB/节点

nodes.conf 配置文件

# nodes-6379.conf
# 这个文件是 Redis 自动维护的,不要手动编辑

# ===== 格式说明 =====
# <node_id> <ip:port@cport> <flags> <master_id> <ping_sent> <pong_recv> <config_epoch> <link_state> <slots>

# ===== 实际内容 =====
07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:6379@16379 myself,master - 0 0 1 connected 0-5460
67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 127.0.0.1:6380@16380 slave 07c37dfeb235213a872192d90877d0cd55635b91 0 1698307201568 1 connected
2dcb8d1f8c9a4e6b3f7d5a9c2e8b4d6f1a3c5e7b 127.0.0.1:6381@16381 master - 0 1698307202789 2 connected 5461-10922
9f8e7d6c5b4a3d2c1e0f9d8c7b6a5d4c3e2f1d0c 127.0.0.1:6382@16382 slave 2dcb8d1f8c9a4e6b3f7d5a9c2e8b4d6f1a3c5e7b 0 1698307203456 2 connected
5a4b3c2d1e0f9e8d7c6b5a4d3c2e1f0d9c8b7a6 127.0.0.1:6383@16383 master - 0 1698307204123 3 connected 10923-16383
3e2f1d0c9b8a7d6c5e4f3d2c1e0f9d8c7b6a5d4 127.0.0.1:6384@16384 slave 5a4b3c2d1e0f9e8d7c6b5a4d3c2e1f0d9c8b7a6 0 1698307205678 3 connected

# ===== 全局变量 =====
vars currentEpoch 6 lastVoteEpoch 0

字段详解

字段 1: node_id (40字节)
  07c37dfeb235213a872192d90877d0cd55635b91
   节点启动时随机生成
   永不改变(即使IP变化)
   类似身份证号

字段 2: ip:port@cport
  127.0.0.1:6379@16379
   6379: 客户端连接端口
   16379: 集群总线端口(通信专用)
   公式:cport = port + 10000

字段 3: flags
  myself,master
   myself: 当前节点
   master/slave: 角色
   fail: 已下线
   handshake: 握手中
   noaddr: 地址未知

字段 4: master_id
  07c37dfeb...  -
   Slave 记录其 Master 的ID
   Master 显示 "-"

字段 5: ping_sent
  0 或时间戳
   最后发送PING的时间
   0 表示未发送或myself

字段 6: pong_received
  1698307201568
   最后接收PONG的时间
   用于判断节点是否存活

字段 7: config_epoch
  1
   配置纪元(版本号)
   用于解决槽位归属冲突
   越大越新

字段 8: link_state
  connected  disconnected
   连接状态

字段 9: slots
  0-5460  5461-10922 或多段
   负责的槽位范围
   可以不连续:0-100 200-300 400-500

槽位存储结构

三种槽位数据结构

// 1. slots[16384] - 数组索引
clusterNode *slots[CLUSTER_SLOTS];

// O(1) 查找槽位对应的节点
clusterNode *node = server.cluster->slots[14909];

// 2. clusterNode->slots[2048] - 位图
unsigned char slots[CLUSTER_SLOTS/8];

// 检查节点是否负责某个槽位
int hasSlot(clusterNode *n, int slot) {
    return (n->slots[slot/8] & (1 << (slot%8))) != 0;
}

// 3. slots_to_keys - Radix树
rax *slots_to_keys;

// Slot → Key 的映射,用于迁移
// 结构:
// Slot 100 → [key1, key2, key3, ...]
// Slot 101 → [key4, key5, ...]

位图操作详解

// 设置槽位
void clusterAddSlot(clusterNode *n, int slot) {
    // 位运算设置
    n->slots[slot/8] |= (1 << (slot%8));
    n->numslots++;
    
    // 更新全局映射
    server.cluster->slots[slot] = n;
}

// 示例:设置 Slot 100
// 100 / 8 = 12 (第13个字节)
// 100 % 8 = 4  (第5位)
// slots[12] |= (1 << 4)
// slots[12] |= 0001 0000

// 清除槽位
void clusterDelSlot(int slot) {
    clusterNode *n = server.cluster->slots[slot];
    
    // 位运算清除
    n->slots[slot/8] &= ~(1 << (slot%8));
    n->numslots--;
    
    // 清除全局映射
    server.cluster->slots[slot] = NULL;
}

位图优化的原因

为什么用位图而不是数组?

方案1:数组(int slots[16384])
• 大小:16384 × 4 = 64KB
• 心跳包每次携带64KB
• 网络开销大 ❌

方案2:位图(unsigned char slots[2048])
• 大小:16384 / 8 = 2KB
• 心跳包只需2KB ✅
• 节省32倍空间

位图操作:
• 设置:O(1)
• 检查:O(1)
• 遍历:O(16384/8) = O(2048)

Hash Slot 机制

为什么是 16384 个槽?

Redis 作者的解释(antirez):

原因 1:心跳包大小
┌────────────────────────────────────┐
│     PING/PONG 消息结构              │
├────────────────────────────────────┤
│ 消息头部:约 100 字节               │
│ 槽位位图:2KB (16384个槽)          │
│ Gossip 数据:约 1-2KB (10个节点)   │
│ 总计:约 3-4KB                      │
└────────────────────────────────────┘

如果用 65536 个槽:
• 位图:65536/8 = 8KB
• 总大小:约 9-10KB
• 每秒发送数十个心跳
• 网络带宽消耗增加 2-3 倍

原因 2:集群规模限制
• Redis 官方建议:集群不超过 1000 节点
• 16384 / 1000 ≈ 16 个槽/节点
• 粒度已经足够细

原因 3:槽位迁移成本
• 槽位越多,迁移时的元数据管理越复杂
• 16384 个槽已经提供足够的灵活性

原因 4:CRC16 性能
• CRC16 输出 0-65535
• slot = crc16 & 16383  (& 0x3FFF)
• 位运算比取模快

经验值:
• 小集群(3-10节点):16384 / 3 ≈ 5461 槽/节点
• 中集群(10-50节点):16384 / 20 ≈ 819 槽/节点
• 大集群(50-100节点):16384 / 100 ≈ 163 槽/节点

Key 路由详解

完整的路由实现

// cluster.c
unsigned int keyHashSlot(char *key, int keylen) {
    int s, e;
    
    // ======== 阶段 1:查找 Hash Tag ========
    // Hash Tag 格式:prefix{tag}suffix
    // 规则:只对 {tag} 部分计算哈希
    
    // 从左往右查找 '{'
    for (s = 0; s < keylen; s++) {
        if (key[s] == '{') break;
    }
    
    // 情况 1:没有找到 '{'
    if (s == keylen) {
        // 对完整 Key 计算哈希
        return crc16(key, keylen) & 0x3FFF;  // 0x3FFF = 16383
    }
    
    // ======== 阶段 2:查找配对的 '}' ========
    // 从 '{' 的下一个位置开始查找 '}'
    for (e = s+1; e < keylen; e++) {
        if (key[e] == '}') break;
    }
    
    // 情况 2:没有找到 '}' 或 {} 之间为空
    if (e == keylen || e == s+1) {
        // {} 不完整或为空,对完整 Key 计算
        return crc16(key, keylen) & 0x3FFF;
    }
    
    // ======== 阶段 3:对 Tag 计算哈希 ========
    // 只对 {} 之间的内容计算
    // key+s+1: 跳过 '{'
    // e-s-1: 不包括 '{' 和 '}'
    return crc16(key+s+1, e-s-1) & 0x3FFF;
}

// CRC16 算法实现
uint16_t crc16(const char *buf, int len) {
    int counter;
    uint16_t crc = 0;
    
    for (counter = 0; counter < len; counter++) {
        crc = (crc<<8) ^ crc16tab[((crc>>8) ^ *buf++)&0x00FF];
    }
    
    return crc;
}

详细示例

# 示例 1:标准 Hash Tag
Key: "user:{1001}:name"
步骤:
  1. 查找 '{': 位置 5
  2. 查找 '}': 位置 10
  3. 提取tag: "1001" (位置6-9)
  4. CRC16("1001") = 58503
  5. 58503 & 16383 = 9351
  结果:Slot 9351

# 示例 2:多个 {}
Key: "user:{1001}:{2002}:name"
步骤:
  1. 查找第一个 '{': 位置 5
  2. 查找对应 '}': 位置 10
  3. 提取tag: "1001" (只用第一个)
  4. CRC16("1001") = 58503
  5. Slot 9351

# 示例 3:空 {}
Key: "user:{}:name"
步骤:
  1. 查找 '{': 位置 5
  2. 查找 '}': 位置 6
  3. e == s+1(空tag)
  4. 使用完整Key: CRC16("user:{}:name")
  结果:Slot ???

# 示例 4:未闭合
Key: "user:{1001:name"
步骤:
  1. 查找 '{': 位置 5
  2. 查找 '}': 未找到
  3. e == keylen(未闭合)
  4. 使用完整Key: CRC16("user:{1001:name")
  结果:Slot ???

# 示例 5:嵌套 {}
Key: "user:{10{01}}:name"
步骤:
  1. 查找第一个 '{': 位置 5
  2. 查找 '}': 位置 11(外层的})
  3. 提取tag: "10{01}" (包含内层{})
  4. CRC16("10{01}") = ???
  结果:Slot ???

Hash Tag 深入应用

实战场景

// 场景1:用户相关数据聚合
public class UserService {
    
    public void saveUser(User user) {
        String userId = user.getId();
        
        // 所有用户数据用相同tag
        jedis.hset("user:{" + userId + "}:profile", "name", user.getName());
        jedis.hset("user:{" + userId + "}:profile", "age", String.valueOf(user.getAge()));
        jedis.lpush("user:{" + userId + "}:orders", "order123", "order456");
        jedis.sadd("user:{" + userId + "}:tags", "vip", "active");
        
        // 都在同一个Slot,可以用事务
        Transaction tx = jedis.multi();
        tx.hset("user:{" + userId + "}:profile", "updated", "true");
        tx.incr("user:{" + userId + "}:login_count");
        tx.exec();  // ✅ 事务成功
    }
    
    // 批量获取
    public Map<String, String> getUserInfo(String userId) {
        // 一次性获取所有相关数据(在同一节点)
        Pipeline p = jedis.pipelined();
        Response<Map<String, String>> profile = p.hgetAll("user:{" + userId + "}:profile");
        Response<List<String>> orders = p.lrange("user:{" + userId + "}:orders", 0, -1);
        Response<Set<String>> tags = p.smembers("user:{" + userId + "}:tags");
        p.sync();
        
        // 返回合并结果
        return mergeResults(profile.get(), orders.get(), tags.get());
    }
}

Hash Tag的陷阱

# 陷阱:数据倾斜

场景:电商系统,使用商家ID作为tag
shop:{seller:1001}:product:1
shop:{seller:1001}:product:2
...
shop:{seller:1001}:product:10000  # 1万个商品

# 后果:
# • 所有商品在同一个Slot
# • 某个节点数据特别多
# • 造成负载不均衡

解决方案1:二级分片
shop:{seller:1001:shard:0}:product:1-100
shop:{seller:1001:shard:1}:product:101-200
shop:{seller:1001:shard:2}:product:201-300

# 分散到不同Slot

解决方案2:监控+手动调整
# 定期检查数据分布
# 发现倾斜及时调整策略

Gossip 协议深度解析

Gossip 消息格式

完整的二进制协议

// cluster.h
#define CLUSTERMSG_TYPE_PING 0
#define CLUSTERMSG_TYPE_PONG 1
#define CLUSTERMSG_TYPE_MEET 2
#define CLUSTERMSG_TYPE_FAIL 3
#define CLUSTERMSG_TYPE_PUBLISH 4
#define CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 5
#define CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 6
#define CLUSTERMSG_TYPE_UPDATE 7
#define CLUSTERMSG_TYPE_MFSTART 8

typedef struct {
    char sig[4];                            // "RCmb" (Redis Cluster message bus)
    uint32_t totlen;                        // 消息总长度
    uint16_t ver;                           // 协议版本(当前是1)
    uint16_t port;                          // 发送者端口
    uint16_t type;                          // 消息类型
    uint16_t count;                         // Gossip节点数量
    uint64_t currentEpoch;                  // 当前纪元
    uint64_t configEpoch;                   // 配置纪元
    uint64_t offset;                        // 复制偏移量
    char sender[CLUSTER_NAMELEN];           // 发送者ID(40字节)
    unsigned char myslots[CLUSTER_SLOTS/8]; // 发送者的槽位(2KB)
    char slaveof[CLUSTER_NAMELEN];          // Master ID(如果是slave)
    char myip[NET_IP_STR_LEN];              // IP地址
    char notused1[34];                      // 对齐填充
    uint16_t cport;                         // 集群端口
    uint16_t flags;                         // 发送者标志
    unsigned char state;                    // 集群状态
    unsigned char mflags[3];                // 消息标志
    union clusterMsgData data;              // 消息体(变长)
} clusterMsg;

// Gossip 节点信息
typedef struct {
    char nodename[CLUSTER_NAMELEN];         // 节点ID
    uint32_t ping_sent;                     // PING发送时间
    uint32_t pong_received;                 // PONG接收时间
    char ip[NET_IP_STR_LEN];                // IP
    uint16_t port;                          // 端口
    uint16_t cport;                         // 集群端口
    uint16_t flags;                         // 标志
    uint32_t notused1;
} clusterMsgDataGossip;

消息大小计算

PING 消息大小:
固定头部:
  sig: 4 字节
  totlen: 4 字节
  ver: 2 字节
  port: 2 字节
  type: 2 字节
  count: 2 字节
  currentEpoch: 8 字节
  configEpoch: 8 字节
  offset: 8 字节
  sender: 40 字节
  myslots: 2048 字节   最大的字段
  slaveof: 40 字节
  myip: 46 字节
  cport: 2 字节
  flags: 2 字节
  state: 1 字节
  mflags: 3 字节
  小计:约 2220 字节

Gossip 数据(变长):
每个节点信息:
  nodename: 40 字节
  ping_sent: 4 字节
  pong_received: 4 字节
  ip: 46 字节
  port: 2 字节
  cport: 2 字节
  flags: 2 字节
  小计:100 字节

携带 10 个节点:10 × 100 = 1000 字节

总大小:2220 + 1000  3.2KB

频率:每秒每个节点发送约 5-10 个PING
6 节点集群:每秒约 30-60 个PING
总流量:30 × 3.2KB  96KB/秒(可接受)

PING/PONG 内容

PING 消息的完整内容

发送者:Node1
时间:T0

┌──────────────────────────────────────────────┐
           PING 消息详细内容                   
├──────────────────────────────────────────────┤
 sig: "RCmb"                                  
 type: PING (0)                               
 sender: 07c37dfeb235213a872192d90877d0cd... 
 currentEpoch: 100                            
 configEpoch: 1                               
 offset: 1234567  (复制偏移量)                
 myip: 127.0.0.1                              
 port: 6379                                   
 cport: 16379                                 
 flags: MASTER (1)                            
 state: CLUSTER_OK                            
├──────────────────────────────────────────────┤
 myslots (位图2KB):                            
   [11111111][11111111]...[11110000][00000000]│
   表示负责 Slot 0-5460                        
├──────────────────────────────────────────────┤
 count: 3  (携带3个节点的Gossip)               
├──────────────────────────────────────────────┤
 Gossip[0]: Node2 的信息                      
   nodename: 67ed2db8...                      
   ping_sent: 1698307200000                   
   pong_received: 1698307201000               
   ip: 127.0.0.1                              
   port: 6380                                 
   cport: 16380                               
   flags: SLAVE                               
├──────────────────────────────────────────────┤
 Gossip[1]: Node5 的信息                      
   nodename: 5a4b3c2d...                      
   ping_sent: 1698307195000                   
   pong_received: 1698307180000   延迟21秒! 
   flags: MASTER | PFAIL   主观下线          
├──────────────────────────────────────────────┤
 Gossip[2]: Node3 的信息                      
   ...                                        
└──────────────────────────────────────────────┘

接收者处理逻辑:
Node2 收到后:
1. 更新 Node1 的信息(IP、端口、槽位、纪元)
2. 更新 Node1  pong_received(刚收到)
3. 处理 Gossip 数据:
    Node5  PFAIL信息  添加故障报告
    检查是否达到客观下线
4. 发送 PONG 响应

传播算法

Gossip 节点选择策略

// cluster.c
void clusterSendPing(clusterLink *link, int type) {
    int gossipcount = 0;
    int wanted;
    clusterMsg buf[1];
    clusterMsg *hdr = (clusterMsg*) buf;
    
    // ======== 1. 计算要携带的Gossip节点数 ========
    // 集群大小的 1/10,最少 3 个
    wanted = floor(dictSize(server.cluster->nodes)/10);
    if (wanted < 3) wanted = 3;
    if (wanted > freshnodes) wanted = freshnodes;
    
    // ======== 2. 选择Gossip节点 ========
    int maxiterations = wanted*3;
    
    while(freshnodes > 0 && gossipcount < wanted && maxiterations--) {
        dictEntry *de = dictGetRandomKey(server.cluster->nodes);
        clusterNode *this = dictGetVal(de);
        
        // 过滤条件
        if (this == myself) continue;  // 跳过自己
        if (this->flags & CLUSTER_NODE_HANDSHAKE) continue;  // 跳过握手中的
        if (this->link == NULL) continue;  // 跳过无连接的
        
        // 特殊优先级:PFAIL 或 FAIL 节点
        // 优先传播故障信息
        if (this->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL)) {
            // 总是包含故障节点信息
            clusterSetGossipEntry(hdr, gossipcount, this);
            freshnodes--;
            gossipcount++;
            continue;
        }
        
        // 普通节点:随机选择
        if (rand() < (RAND_MAX/wanted)) {
            clusterSetGossipEntry(hdr, gossipcount, this);
            freshnodes--;
            gossipcount++;
        }
    }
    
    // ======== 3. 设置消息头 ========
    hdr->count = htons(gossipcount);
    hdr->currentEpoch = htonu64(server.cluster->currentEpoch);
    hdr->configEpoch = htonu64(master->configEpoch);
    memcpy(hdr->myslots, server.cluster->myself->slots, sizeof(hdr->myslots));
    // ...
    
    // ======== 4. 发送消息 ========
    clusterSendMessage(link, (unsigned char*)hdr, totlen);
}

Config Epoch 机制

配置纪元的完整机制

// Config Epoch 的作用:解决分布式系统中的配置冲突

// 场景:网络分区导致的槽位冲突
void clusterHandleConfigEpochCollision(clusterNode *sender) {
    // ======== 检测冲突 ========
    // 两个Master的configEpoch相同,但负责不同槽位
    
    if (sender->configEpoch == myself->configEpoch &&
        clusterNodeIsAMaster(sender) &&
        clusterNodeIsAMaster(myself)) {
        
        // ======== 解决冲突 ========
        // 增加自己的 configEpoch
        server.cluster->currentEpoch++;
        myself->configEpoch = server.cluster->currentEpoch;
        
        serverLog(LL_WARNING,
            "Config epoch collision with node %.40s (%llu)."
            " Updating my config epoch to %llu",
            sender->name,
            (unsigned long long) sender->configEpoch,
            (unsigned long long) myself->configEpoch);
        
        // 广播新配置
        clusterSaveConfigOrDie(1);
        clusterBroadcastPong(CLUSTER_BROADCAST_ALL);
    }
}

详细示例

场景:网络分区导致两个节点都声称负责Slot 100

初始状态:
Partition A:
  Node1: Slot 100, configEpoch=5

Partition B:
  Node2: Slot 100, configEpoch=3

网络恢复后:
两个节点开始通信

Node1  Node2: PING {
  sender: node1,
  configEpoch: 5,
  myslots: [Slot 100 = 1]  (位图显示负责Slot 100)
}

Node2 收到消息:
1. 解析:Node1 也声称负责 Slot 100
2. 比较configEpoch:5 > 3
3. 决定:Node1 获胜 
4. 更新本地:slots[100] = Node1
5. 清除自己的Slot 100:myslots[100/8] &= ~(1<<(100%8))

Node2  Node1: PING {
  configEpoch: 3,
  myslots: [Slot 100 = 0]  (已清除)
}

最终:
Node1: Slot 100 
Node2: 无Slot 100

冲突解决!

信息收敛过程

完整的收敛示例(6个节点)

场景:Node6 新加入集群

初始状态:
Node1 知道:[Node1, Node2, Node3, Node4, Node5]
Node2 知道:[Node1, Node2, Node3, Node4, Node5]
Node3 知道:[Node1, Node2, Node3, Node4, Node5]
Node4 知道:[Node1, Node2, Node3, Node4, Node5]
Node5 知道:[Node1, Node2, Node3, Node4, Node5]
Node6 知道:[Node6]  ← 新节点,只知道自己

T0: 管理员执行
127.0.0.1:6379> CLUSTER MEET 127.0.0.1:6385

Node1 → Node6: MEET {sender: node1, ...}
Node6 收到:
  • 添加 Node1 到 nodes
  • Node6 知道:[Node1, Node6]

Node6 → Node1: PONG {sender: node6, ...}
Node1 收到:
  • 确认 Node6 加入
  • Node1 知道:[Node1-Node6]

T1 (100ms 后): Node1 发送常规心跳
Node1 随机选择 3 个节点:Node2, Node4, Node6

Node1 → Node2: PING {
  gossip: [
    {nodename: node6, ip: 127.0.0.1, port: 6385, ...}  ← 携带Node6信息
    {nodename: node3, ...},
    {nodename: node5, ...}
  ]
}

Node2 收到:
  • 发现新节点 Node6
  • Node2 → Node6: MEET
  • Node2 知道:[Node1-Node6]

T2 (200ms 后): Node2 发送心跳
Node2 → Node3: PING {
  gossip: [{nodename: node6, ...}, ...]
}

Node3 收到:
  • 发现 Node6
  • Node3 → Node6: MEET
  • Node3 知道:[Node1-Node6]

T3-T10: 继续传播...

T10 (约1秒后): 收敛完成
所有节点都知道:[Node1-Node6]

收敛时间:O(log N)
6个节点:约1100个节点:约2-3

Gossip 选择算法的代码实现

// cluster.c
void clusterSetGossipEntry(clusterMsg *hdr, int i, clusterNode *n) {
    clusterMsgDataGossip *gossip = &(hdr->data.ping.gossip[i]);
    
    // 填充Gossip信息
    memcpy(gossip->nodename, n->name, CLUSTER_NAMELEN);
    gossip->ping_sent = htonl(n->ping_sent/1000);
    gossip->pong_received = htonl(n->pong_received/1000);
    memcpy(gossip->ip, n->ip, sizeof(n->ip));
    gossip->port = htons(n->port);
    gossip->cport = htons(n->cport);
    gossip->flags = htons(n->flags);
    gossip->notused1 = 0;
}

总结

本文深入剖析了 Redis Cluster 的架构和核心机制:

节点数据结构

  • 📊 clusterState:每个节点维护完整集群视图(约200KB)
  • 🔢 clusterNode:存储节点详情(约3KB/节点)
  • 🗺️ 位图优化:2KB表示16384个槽位(节省32倍空间)
  • 📁 nodes.conf:自动持久化集群配置

Hash Slot 机制

  • 🎯 16384个槽:心跳包大小、集群规模、迁移成本的最优平衡
  • 🔢 CRC16算法:快速计算(& 0x3FFF)
  • 🏷️ Hash Tag:{tag}控制Key分布,实现多键操作
  • 📍 三层映射:Key→Slot→Node(O(1)查找)

Gossip 协议

  • 💬 二进制协议:"RCmb"签名 + 结构化消息
  • 📦 消息内容:槽位位图(2KB) + Gossip数据(1KB)
  • 🔄 传播策略:随机选择 + 故障优先
  • ⏱️ 收敛时间:O(log N),6节点约1秒,100节点约2-3秒
  • 🎯 Config Epoch:版本号机制解决槽位冲突

设计智慧

  • ✅ 位图压缩:2KB vs 64KB(节省网络)
  • ✅ Gossip协议:去中心化,容错性好
  • ✅ 随机传播:避免消息风暴
  • ✅ 故障优先:重要信息快速传播

理解这些底层机制,能帮助你:

  • ✅ 理解Cluster的性能特征
  • ✅ 正确设计Hash Tag策略
  • ✅ 避免数据倾斜问题
  • ✅ 理解配置冲突的解决

💡 下篇预告:《Redis集群模式详解(下篇):故障转移、扩容缩容与生产实战》

深入槽位迁移、在线扩容、故障转移的完整流程!