第 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 处理细节。