第 19 章

Redis Cluster:16384 槽与 Gossip 协议

第19章 Redis Cluster:16384 槽与 Gossip 协议

Redis Cluster 是官方提供的分布式解决方案,通过哈希槽分片将数据分散到多个节点,同时通过 Gossip 协议实现去中心化的集群状态管理。本章深入分析哈希槽设计、MOVED/ASK 重定向机制、Gossip 协议细节,以及 clusterNode 数据结构。


19.1 为何不用一致性哈希

在 Redis Cluster 之前,业界分布式缓存常用一致性哈希(Consistent Hashing)。Redis 选择哈希槽而非一致性哈希,原因如下:

一致性哈希的问题

问题1:少节点时数据分布不均
场景:3个节点的一致性哈希环
     [Node-A] 负责 0~45°    → 12.5% 数据
     [Node-B] 负责 45~200°  → 43.0% 数据  ← 严重不均
     [Node-C] 负责 200~360° → 44.5% 数据

解决方案(虚拟节点):每个物理节点映射为 150 个虚拟节点
→ 复杂度增加,配置难度上升

问题2:扩缩容影响范围不精确
一致性哈希扩缩容只影响相邻节点,但实际迁移量难以精确控制

问题3:无法为特定 key 集合指定位置
无法保证相关 key 在同一节点(HashTag 需要额外机制实现)

哈希槽的优势

哈希槽(Hash Slot)方案:
- 固定 16384 个槽,每个节点负责若干槽
- 扩缩容:只需迁移槽,精确可控
- HashTag:通过 {} 语法保证相关 key 在同一槽
- 管理简单:槽分配表全集群共享,任意节点均可路由

19.2 16384 槽的由来

为什么是 16384(2^14)而非 65536(2^16)?

心跳包中携带槽信息:
- 每个节点定期发送心跳,包含自己负责的槽的位图(bitmap)
- 16384 bits = 2048 bytes = 2KB(心跳包大小可接受)
- 65536 bits = 8192 bytes = 8KB(心跳包过大,集群网络开销翻倍)

集群规模考量:
- Redis Cluster 官方推荐最多 1000 个节点
- 16384 槽 / 1000 节点 ≈ 每节点 16 槽(粒度足够)
- 没有必要用 65536(浪费且增加开销)

Antirez 原话(GitHub issue #2576):
"Normal heartbeats packets carry the full configuration
 of a node, that can be encoded in 2k. With 65536 it
 would be 8k, and this is not cool."

源码中的槽位图

/* cluster.h */
#define CLUSTER_SLOTS 16384

typedef struct clusterNode {
    /* 槽位图:每个 bit 表示是否负责该槽 */
    unsigned char slots[CLUSTER_SLOTS/8];  /* 2048 bytes */
    int numslots;    /* 负责的槽数量 */
    /* ... */
} clusterNode;

/* 检查节点是否负责某个槽 */
#define bitmapTestBit(b, bit) ((b)[(bit)/8] & (1 << ((bit)&7)))

19.3 哈希槽计算

基础计算

HASH_SLOT = CRC16(key) mod 16384

CRC16 使用 CCITT 变体(多项式 x^16 + x^12 + x^5 + 1),输出范围 0~65535,取模 16384。

# Python 实现(验证用)
def crc16(data):
    crc = 0xFFFF
    for byte in data:
        crc ^= byte << 8
        for _ in range(8):
            if crc & 0x8000:
                crc = (crc << 1) ^ 0x1021
            else:
                crc <<= 1
    return crc & 0xFFFF

def hash_slot(key):
    # 处理 HashTag
    start = key.find('{')
    if start != -1:
        end = key.find('}', start + 1)
        if end != -1 and end > start + 1:
            key = key[start+1:end]  # 只取 {} 内的部分
    return crc16(key.encode()) % 16384

# 示例
print(hash_slot("user:1:orders"))    # 随机槽
print(hash_slot("{user:1}:orders"))  # 与 {user:1}:info 在同一槽
print(hash_slot("{user:1}:info"))    # 同上

HashTag 机制

HashTag 允许控制多个 key 映射到同一个槽,这是实现跨 key 操作的唯一合法方式:

# 规则:key 中 { 和 } 之间的内容参与哈希
# 如果没有 {} 或 {} 内为空,使用整个 key

示例:
key = "order:9999"          → CRC16("order:9999") % 16384
key = "{order:9999}:items"  → CRC16("order:9999") % 16384
key = "{order:9999}:status" → CRC16("order:9999") % 16384
↑ 三个 key 在同一槽,可以用于 MGET/Pipeline/MULTI-EXEC

反例(槽不同,无法原子操作):
key1 = "order:items:9999"   → 不同槽
key2 = "order:status:9999"  → 不同槽

验证 key 所在槽

redis-cli --cluster check 127.0.0.1:7000

# 单个 key 的槽
redis-cli -c -p 7000 CLUSTER KEYSLOT "user:1:orders"
# (integer) 10778

redis-cli -c -p 7000 CLUSTER KEYSLOT "{user:1}:orders"
# (integer) 6112

redis-cli -c -p 7000 CLUSTER KEYSLOT "{user:1}:info"
# (integer) 6112  ← 与上面相同

19.4 MOVED 与 ASK 重定向

客户端向某节点请求一个不属于该节点的 key 时,节点返回重定向响应,而不是透明转发(避免单节点成为瓶颈)。

MOVED 重定向

场景:槽已经永久迁移到其他节点

客户端发送:GET user:1
节点 A(不负责该槽)响应:
-MOVED 6112 192.168.1.3:7002

含义:
- 6112:该 key 所在的槽号
- 192.168.1.3:7002:负责该槽的节点地址

客户端处理:
1. 更新本地路由表(slot 6112 → 192.168.1.3:7002)
2. 向新地址重新发送请求
3. 后续对同一槽的请求直接发到正确节点

ASK 重定向

场景:槽正在迁移中(部分 key 已迁移,部分未迁移)

客户端发送:GET user:1
节点 A(源节点,key 已迁移)响应:
-ASK 6112 192.168.1.3:7002

客户端处理:
1. 不更新本地路由表(迁移未完成,暂时性的)
2. 向目标节点发送 ASKING 命令(允许访问正在导入的槽)
3. 再次发送 GET user:1
4. 目标节点返回结果

# 目标节点必须先收到 ASKING,否则对 importing 槽的访问会被拒绝

MOVED vs ASK 对比

维度 MOVED ASK
触发场景 槽已永久迁移 槽迁移进行中
路由表更新 是(永久更新) 否(临时访问)
目标节点是否需要 ASKING
含义 "去那里找,以后都去那里" "这次去那里找,但别记住"

智能客户端实现原理

class RedisClusterClient:
    def __init__(self, startup_nodes):
        self.slots_cache = {}  # slot → (host, port)
        self._init_slots_cache(startup_nodes)

    def _init_slots_cache(self, startup_nodes):
        """从任意节点获取完整的槽分配表"""
        for node in startup_nodes:
            try:
                conn = redis.Redis(*node)
                # CLUSTER SLOTS 返回完整路由表
                slots_info = conn.execute_command("CLUSTER SLOTS")
                for slot_range in slots_info:
                    start, end = slot_range[0], slot_range[1]
                    master = slot_range[2]
                    for slot in range(start, end + 1):
                        self.slots_cache[slot] = (master[0], master[1])
                break
            except Exception:
                continue

    def execute(self, key, command, *args, retry=3):
        slot = hash_slot(key)
        node = self.slots_cache.get(slot)
        conn = self._get_connection(*node)

        for _ in range(retry):
            try:
                return conn.execute_command(command, key, *args)
            except redis.ResponseError as e:
                if str(e).startswith("MOVED"):
                    _, slot_str, addr = str(e).split()
                    host, port = addr.split(":")
                    self.slots_cache[int(slot_str)] = (host, int(port))
                    conn = self._get_connection(host, int(port))
                elif str(e).startswith("ASK"):
                    _, slot_str, addr = str(e).split()
                    host, port = addr.split(":")
                    conn = self._get_connection(host, int(port))
                    conn.execute_command("ASKING")
                    # 不更新 slots_cache
                else:
                    raise

19.5 Gossip 协议详解

Gossip 是 Redis Cluster 节点间传播状态信息的核心协议,实现去中心化的集群管理。

消息类型

消息类型          用途                        触发时机
PING             心跳+状态传播              每100ms随机选择节点发送
PONG             PING 的回复               收到 PING 后回复
MEET             新节点加入                执行 CLUSTER MEET 后
FAIL             快速广播节点故障           检测到节点 pfail 达到 fail 条件
PUBLISH          转发 Pub/Sub 消息          客户端 PUBLISH 命令
UPDATE           更新路由配置              检测到配置版本落后

PING/PONG 消息结构

/* cluster.h */
typedef struct {
    char sig[4];            /* "RCmb"(Redis Cluster message body)*/
    uint32_t totlen;        /* 消息总长度 */
    uint16_t ver;           /* 协议版本 */
    uint16_t port;          /* 发送者端口 */
    uint16_t type;          /* 消息类型(CLUSTERMSG_TYPE_PING 等)*/
    uint16_t count;         /* gossip 信息中包含的节点数量 */
    uint64_t currentEpoch;  /* 发送者的 currentEpoch */
    uint64_t configEpoch;   /* 发送者的 configEpoch */
    uint64_t offset;        /* 主库:repl_offset;从库:已处理 offset */
    char sender[CLUSTER_NAMELEN]; /* 发送者 runid(40字节)*/
    unsigned char myslots[CLUSTER_SLOTS/8]; /* 发送者的槽位图(2048字节)*/
    char slaveof[CLUSTER_NAMELEN];          /* 若为从库,主库的 runid */
    /* ... */
    union clusterMsgData data;  /* Gossip 数据 */
} clusterMsg;

Gossip 数据:携带其他节点信息

每次 PING/PONG 额外携带随机选取的 1/10 节点状态(至少3个):

/* cluster.h */
typedef struct {
    char nodename[CLUSTER_NAMELEN];  /* 被描述节点的 runid */
    uint32_t ping_sent;              /* 最后一次 PING 时间 */
    uint32_t pong_received;          /* 最后一次收到 PONG 时间 */
    char ip[NET_IP_STR_LEN];         /* IP */
    uint16_t port;                   /* 端口 */
    uint16_t cport;                  /* 集群总线端口(port+10000)*/
    uint16_t flags;                  /* 状态标志 */
    uint32_t notused1;
} clusterMsgDataGossip;

这种"随机传播"机制使得节点状态变更在 O(log N) 轮心跳后传播至全集群。

clusterCron 执行逻辑

/* cluster.c - clusterCron(),每100ms执行一次 */
void clusterCron(void) {
    /* 1. 向随机选取的节点发送 PING */
    if (!(iteration % 10)) {
        /* 每1秒(10次迭代):检查所有节点,对 ping_delay 最大的发 PING */
        clusterNode *min_pong_node = NULL;
        /* 找到最久没收到 PONG 的节点 */
    }

    /* 2. 检测节点超时(pfail)*/
    listIter li;
    listRewind(server.cluster->nodes, &li);
    while ((ln = listNext(&li)) != NULL) {
        clusterNode *node = ln->value;
        mstime_t now = mstime();

        /* 超过 cluster-node-timeout 没收到 PONG → pfail */
        if (node->link && now - node->link->last_recv_time > server.cluster_node_timeout) {
            node->flags |= CLUSTER_NODE_PFAIL;
        }
    }

    /* 3. 处理 failover(从节点检测到主节点 fail 时)*/
    if (nodeIsSlave(myself) && server.cluster->mf_end == 0) {
        clusterHandleSlaveFailover();
    }
}

19.6 clusterNode 关键数据结构

/* cluster.h */
typedef struct clusterNode {
    mstime_t ctime;          /* 节点加入时间 */
    char name[CLUSTER_NAMELEN]; /* 节点 ID(40字节,启动时随机生成)*/
    int flags;               /* 状态标志位 */

    /* 标志位定义 */
    /* CLUSTER_NODE_MASTER    = 1 */
    /* CLUSTER_NODE_SLAVE     = 2 */
    /* CLUSTER_NODE_PFAIL     = 4  → 主观失败 */
    /* CLUSTER_NODE_FAIL      = 8  → 客观失败 */
    /* CLUSTER_NODE_MYSELF    = 16 */
    /* CLUSTER_NODE_HANDSHAKE = 32 → 正在握手 */
    /* CLUSTER_NODE_NOADDR    = 64 → 尚未知道地址 */

    uint64_t configEpoch;    /* 配置纪元(用于冲突解决)*/
    unsigned char slots[CLUSTER_SLOTS/8]; /* 槽位图 */
    int numslots;            /* 负责的槽数量 */

    struct clusterNode *slaveof; /* 若为从库,指向主库节点 */

    mstime_t ping_sent;      /* 最后一次发送 PING 的时间 */
    mstime_t pong_received;  /* 最后一次收到 PONG 的时间 */
    mstime_t data_received;  /* 最后一次收到数据的时间 */
    mstime_t fail_time;      /* 标记为 FAIL 的时间 */
    mstime_t voted_time;     /* 最后一次投票时间(防重复投票)*/
    mstime_t repl_offset_time; /* repl_offset 更新时间 */
    long long repl_offset;   /* 已知的复制偏移量 */

    char ip[NET_IP_STR_LEN]; /* IP 地址 */
    int port;                /* 客户端端口 */
    int cport;               /* 集群总线端口(默认 = port + 10000)*/

    clusterLink *link;       /* 与该节点的 TCP 连接 */

    /* pfail 报告列表:记录哪些节点报告过自己 pfail */
    list *fail_reports;
} clusterNode;

查看集群节点信息

redis-cli -c -p 7000 CLUSTER NODES
# <node-id> <ip:port@cport> <flags> <master-id> <ping-sent> <pong-recv> <config-epoch> <link-state> <slot-range>
# 示例:
# 1a2b3c... 192.168.1.1:7000@17000 master - 0 1714000000000 1 connected 0-5460
# 4d5e6f... 192.168.1.2:7001@17001 master - 0 1714000000001 2 connected 5461-10922
# 7g8h9i... 192.168.1.3:7002@17002 master - 0 1714000000002 3 connected 10923-16383

redis-cli -c -p 7000 CLUSTER INFO
# cluster_enabled:1
# cluster_state:ok
# cluster_slots_assigned:16384
# cluster_slots_ok:16384
# cluster_slots_pfail:0
# cluster_slots_fail:0
# cluster_known_nodes:6
# cluster_size:3

19.7 集群拓扑发现

新节点加入流程

# 新节点 7006 启动后,未加入任何集群
redis-server --port 7006 --cluster-enabled yes \
    --cluster-config-file nodes-7006.conf \
    --cluster-node-timeout 5000 \
    --daemonize yes

# CLUSTER MEET:让新节点与集群中的一个节点握手
redis-cli -p 7000 CLUSTER MEET 127.0.0.1 7006

# 握手流程:
# 1. 7000 → 7006: MEET 消息
# 2. 7006 → 7000: PONG 回复(携带自身状态)
# 3. 7006 标记 7000 为已知节点
# 4. 后续 Gossip:7000 在 PING/PONG 中携带 7006 的信息
#    → 其他节点(7001/7002/...)通过 Gossip 发现 7006
#    → 约 O(log N) 轮心跳后,全集群都知道 7006 的存在

Gossip 收敛速度

假设集群有 N 个节点,每次 PING 携带 N/10 个节点信息(最少3个):

收敛轮数 ≈ log(N) / log(N/10 + 1)

实际数据:
N=10 节点:约 3-4 轮(3秒,每秒心跳)
N=100 节点:约 5-6 轮(5秒)
N=1000 节点:约 7-8 轮(7秒)

结论:即使大规模集群,拓扑变更也能在数秒内全网收敛。

19.8 集群配置示例

最小集群(3主3从,6节点)

# 节点配置模板(以 7000 端口为例)
cat > /etc/redis/redis-7000.conf << 'EOF'
port 7000
bind 0.0.0.0
daemonize yes
logfile /var/log/redis/redis-7000.log
dir /var/lib/redis/7000

# 集群配置
cluster-enabled yes
cluster-config-file /etc/redis/cluster/nodes-7000.conf
cluster-node-timeout 15000      # 15秒超时(生产推荐)
cluster-announce-ip 192.168.1.1 # 公告 IP(多网卡/NAT 环境必须配置)
cluster-announce-port 7000
cluster-announce-bus-port 17000

# 持久化
appendonly yes
appendfsync everysec

# 内存
maxmemory 8gb
maxmemory-policy allkeys-lru

# 性能
hz 10
aof-rewrite-incremental-fsync yes
EOF

# 创建集群(--cluster-replicas 1 表示每主1从)
redis-cli --cluster create \
    192.168.1.1:7000 192.168.1.1:7001 \
    192.168.1.2:7002 192.168.1.2:7003 \
    192.168.1.3:7004 192.168.1.3:7005 \
    --cluster-replicas 1

# 输出示例:
# Master[0] -> Slots 0 - 5460
# Master[1] -> Slots 5461 - 10922
# Master[2] -> Slots 10923 - 16383
# Adding replica 192.168.1.2:7003 to 192.168.1.1:7000
# ...
# Can I set the above configuration? (type 'yes' to accept): yes

19.9 集群限制与注意事项

不支持的操作

# 1. 跨节点 MGET(key 不在同一槽)
MGET user:1:name user:2:name
# (error) CROSSSLOT Keys in request don't hash to the same slot

# 解决:使用 HashTag
MGET {user:1}:name {user:1}:age  # 同槽,可以

# 2. 跨节点事务
MULTI
SET user:1:name "Alice"  # 槽 A
SET order:1:status "paid" # 槽 B
EXEC
# (error) CROSSSLOT

# 3. SELECT 只能用 db 0
SELECT 1
# (error) ERR SELECT is not allowed in cluster mode

# 4. Lua 脚本所有 key 必须在同一槽
EVAL "return redis.call('SET',KEYS[1],ARGV[1])" 2 key1 key2 value
# (error) CROSSSLOT(如果 key1 和 key2 不在同槽)

集群模式下的 Pipeline 注意事项

# 问题:Pipeline 在集群模式下,所有命令必须路由到同一节点
# 智能客户端(如 redis-py-cluster)按槽分组 Pipeline 命令

from rediscluster import RedisCluster

rc = RedisCluster(startup_nodes=[{"host": "127.0.0.1", "port": "7000"}])

# 使用 HashTag 确保同槽
pipe = rc.pipeline()
pipe.set("{user:1}:name", "Alice")
pipe.set("{user:1}:age", 30)
pipe.get("{user:1}:name")
pipe.execute()  # 三个命令都在同一槽,Pipeline 有效

本章小结

概念 关键细节 生产建议
哈希槽 CRC16(key) % 16384 用 HashTag 控制数据分布
16384 原因 心跳包 2KB vs 65536 的 8KB
MOVED 永久重定向,更新路由表 智能客户端必须支持
ASK 临时重定向,不更新路由表 迁移期间的过渡机制
Gossip PING/PONG 随机传播节点状态 cluster-node-timeout 调优
集群限制 不支持跨槽 MGET/事务/SELECT 设计时用 HashTag 规避

下一章将详细介绍集群故障转移机制、在线扩容操作手册,以及槽迁移期间的 ASK 处理细节。

本章评分
4.6  / 5  (12 评分)

💬 留言讨论