【Redis从入门到精通】第48篇:哈希槽——Redis Cluster的数据分片机制
上一篇【第47篇】Redis Cluster——官方分布式集群方案全解析
下一篇【第49篇】MOVED和ASK——Cluster重定向机制详解
上一篇文章我们聊了Cluster的节点和Gossip协议,节点之间互相认识了,但数据怎么分配呢?不能拍脑袋往某个节点塞吧?今天的主角——哈希槽(Hash Slot),就是Redis Cluster实现数据分片的核心机制。
简单说:集群有16384个槽,每条key通过CRC16哈希算法落到一个槽,每个节点负责一部分槽。槽到节点的映射关系由集群维护,客户端根据槽找节点。这套机制乍看简单,细节处却处处是匠心。
一、16384个槽:这个数字是怎么来的?
16384,看起来是个很随意的数字。为什么不是65536?为什么不是10000?为什么是2的14次方?这里藏着Redis作者antirez的精妙权衡。
┌─────────────────────────────────────────────────┐
│ 16384 vs 65536:为什么选16384? │
├─────────────────────────────────────────────────┤
│ │
│ 如果选 65536(2^16): │
│ ✓ 粒度更细,数据分布更均匀 │
│ ✗ 槽位位图 = 65536/8 = 8KB(每个clusterNode) │
│ ✗ Gossip消息头太大,每个PING/PONG要带8KB的位图 │
│ ✗ 节点间心跳包每7.5秒发一次,8KB × N个节点=灾难 │
│ │
│ 如果选 16384(2^14): │
│ ✓ 槽位位图 = 16384/8 = 2KB(紧凑!) │
│ ✓ Gossip消息头大小可控,心跳开销小 │
│ ✓ 1000个节点时每个节点约16个槽,粒度够用 │
│ ✓ PING/PONG消息体可以放更多邻居摘要 │
│ │
└─────────────────────────────────────────────────┘
具体的技术考量如下:
1. Gossip消息的负载考量
Redis Cluster的PING/PONG消息中,发送方需要携带自己的槽位位图(unsigned char slots[CLUSTER_SLOTS/8])。如果CLUSTER_SLOTS=65536,每个节点的位图就是8KB。而Gossip协议要求每次PING还要附带其他随机节点的摘要信息。8KB的位图塞进心跳包,心跳就不再轻量。
antirez在GitHub上解释过:
“16384 is the right size for small to medium clusters and it’s also a good choice for the maximum size of the cluster (I suggest 1000 nodes).”
翻译一下:16384个槽对于1000个节点的集群已经绰绰有余(每个节点平均16个槽)。而对于端口号+10000的集群通信端口,10000到16383这个范围很有用——不用在65536端口的空间里找。
2. 槽与CPU缓存行的微妙关系
2KB的槽位位图刚好适合L1/L2缓存。在频繁的路由判断中,位图能一直待在CPU缓存里,毫秒级的判断延迟降到微秒级。8KB的位图就没这么幸运了。
槽位位图访问性能:
2KB → 适合 32KB L1 缓存 → 几乎零延迟
8KB → 可能要出 L1 → 几十个时钟周期的延迟
3. 实际节点数的上限
Redis官方建议集群节点数不超过1000个。按1000个节点算,每个节点分到大约16个槽。如果需要迁移一个节点,迁移16个槽的管理开销远小于迁移65个槽。但槽太少也有问题——如果只有256个槽,每个节点分到几个槽,迁移时粒度太粗,可能导致新节点瞬间负载不均。
二、键到槽的映射算法:CRC16的表演
每个key落到哪个槽,算法只有一行:
HASH_SLOT = CRC16(key) & 16383 // 等价于 CRC16(key) % 16384
你没看错,就是CRC16然后取低14位。但真正的逻辑在key的选择上——不是对整个key算哈希,而是智能提取有效部分。
完整的 slot 计算逻辑
┌───────────────────────────────────────────┐
│ key → slot 计算流程 │
├───────────────────────────────────────────┤
│ │
│ key = "{user:1001}:profile" │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ 第一步:检查HashTag │ │
│ │ 有 {...} → 使用{}内容 │ │
│ │ 无 {...} → 使用整个key │ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ 第二步:CRC16(key_part) │ │
│ │ 标准CRC-16-CCITT多项式 │ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ 第三步:取低14位 │ │
│ │ bitwise AND 0x3FFF │ │
│ │ → slot = 0 ~ 16383 │ │
│ └────────────────────────┘ │
│ │
└───────────────────────────────────────────┘
源码中的实现(src/cluster.c):
unsigned int keyHashSlot(char *key, int keylen) {
int s, e; /* start-end indexes of { and } */
for (s = 0; s < keylen; s++)
if (key[s] == '{') break;
/* 没找到 { 就用整个key */
if (s == keylen) return crc16(key,keylen) & 0x3FFF;
/* 找到 {,再找 } */
for (e = s+1; e < keylen; e++)
if (key[e] == '}') break;
/* 没找到 } 或 {} 之间为空,退化为用整个key */
if (e == keylen || e == s+1) return crc16(key,keylen) & 0x3FFF;
/* 只对 { 和 } 之间的内容计算CRC16 */
return crc16(key+s+1, e-s-1) & 0x3FFF;
}
逻辑很清楚:
| 步骤 | 操作 | 示例key | 提取的哈希部分 |
|---|---|---|---|
| 1 | 找第一个 { |
{user:1001}:profile |
user:1001 |
| 2 | 找对应的 } |
{user:1001}:profile |
在user:1001后 |
| 3 | 取中间内容算CRC16 | user:1001 |
CRC16(user:1001) |
| 4 | 无{}则用全key |
user:1001:profile |
CRC16(user:1001:profile) |
| 5 | 空{}则用全key |
{}empty |
CRC16({}empty) |
踩坑提示:
key{val}ue这种写法,HashTag提取的是val,而不是val}ue——花括号从右向左匹配。如果花括号嵌套{{a}},只使用最左边的{和最右边的}之间的内容,即{a。这点很容易搞混!
三、HashTag:把数据"黏"在同一个槽
HashTag的设计非常巧妙。正常情况下,user:1001:name 和 user:1001:avatar 会落到不同的槽。但如果我想对这两个key做批量操作(MGET、事务等),就需要它们在一个槽里。
解决方案:
# 不用HashTag:两个key在不同槽
user:1001:name → CRC16("user:1001:name") & 16383 → slot 12182
user:1001:avatar → CRC16("user:1001:avatar") & 16383 → slot 5063 (完全不同!)
# 使用HashTag:两个key在同一个槽
{user:1001}:name → CRC16("user:1001") & 16383 → slot 8416
{user:1001}:avatar → CRC16("user:1001") & 16383 → slot 8416 (一致!)
HashTag的使用场景
| 场景 | 示例 | 说明 |
|---|---|---|
| 批量操作 | MGET {user:1001}:name {user:1001}:avatar |
保证在同一槽才能MGET |
| 事务 | MULTI / SET {order}:items ... / EXEC |
跨槽事务报CROSSSLOT错误 |
| Lua脚本 | EVAL script 1 {shop}:inventory |
脚本中所有key必须在同一槽 |
| 关联数据 | 用户信息、订单详情等需要一起读的数据 | 减少MGET跨槽重试 |
| 交集/并集 | ZINTERSTORE {rank}:weekly {rank}:daily |
聚合操作需要同一槽 |
HashTag的滥用风险
凡事有利有弊,HashTag滥用会带来严重问题:
┌──────────────────────────────────────────────────┐
│ HashTag 滥用的后果 │
├──────────────────────────────────────────────────┤
│ │
│ 1. 热点问题——所有数据集中到同一槽 │
│ 如果 {user} 是热点用户,它的所有key都在一个 │
│ 节点上,这个节点的CPU/内存压力巨大 │
│ │
│ 2. 数据倾斜——某个节点的槽特别"重" │
│ 大量使用相同HashTag前缀的key都堆到同一个槽 │
│ 导致集群数据分布极度不均 │
│ │
│ 3. 丧失扩展优势——加节点也分散不了数据 │
│ 所有数据都绑在某个槽上,加节点=加空气 │
│ │
│ 4. 增加故障范围——一个节点挂了影响所有数据 │
│ 热点槽所在的节点故障,所有相关数据不可用 │
│ │
└──────────────────────────────────────────────────┘
最佳实践:
- HashTag只用于真正需要一起操作的key
- Tag粒度不要太大(比如
{user}就太大了,应该{user:1001}) - 定期用
CLUSTER KEYSLOT验证热点key的槽分布 - 监控各节点的内存使用量和QPS,提前发现倾斜
四、CLUSTER ADDSLOTS:给节点分配槽
空有16384个槽没人认领是不行的。通过 CLUSTER ADDSLOTS 把槽分配给节点:
# 给当前节点分配 0-5460 共5461个槽
redis-cli -p 6379 CLUSTER ADDSLOTS $(seq 0 5460)
# 查看某个key属于哪个槽
redis-cli -p 6379 CLUSTER KEYSLOT "user:1001:profile"
(integer) 8416
# 查看某个槽有哪些key(只能查当前节点负责的槽)
redis-cli -p 6379 CLUSTER GETKEYSINSLOT 0 10
1) "key_in_slot_0"
2) "another_key_in_slot_0"
但手动一个个分配太累了!Redis提供了 redis-cli --cluster create 命令来自动分配:
redis-cli --cluster create \
192.168.1.101:6379 \
192.168.1.102:6379 \
192.168.1.103:6379 \
--cluster-replicas 1
背后的分配逻辑:
16384个槽分配给3个主节点:
Node A (6379): slots 0 - 5460 (5461个槽)
Node B (6380): slots 5461 - 10922 (5462个槽)
Node C (6381): slots 10923- 16383 (5461个槽)
每个主节点配1个从节点:
Slave A' (6382): 复制 Node A
Slave B' (6383): 复制 Node B
Slave C' (6384): 复制 Node C
分配过程由 redis-cli 客户端工具完成,它依次连接每个主节点执行 CLUSTER ADDSLOTS,把所有槽分完。分完后执行 CLUSTER INFO 看到 cluster_slots_ok:16384 就说明一切就绪。
五、集群搭建完整步骤
我们把整个集群搭建流程走一遍,从零到可用。
步骤1:准备配置文件
# 每个节点一个配置文件,以6379为例
cat > redis-6379.conf << EOF
port 6379
cluster-enabled yes
cluster-config-file nodes-6379.conf
cluster-node-timeout 5000
appendonly yes
daemonize yes
logfile redis-6379.log
dir /var/lib/redis/6379
EOF
为每个节点创建独立的目录和配置(端口6379-6384):
for port in 6379 6380 6381 6382 6383 6384; do
mkdir -p /var/lib/redis/$port
# 修改配置中的端口号和文件名...
redis-server redis-$port.conf
done
步骤2:创建集群
redis-cli --cluster create \
127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 \
127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 \
--cluster-replicas 1
执行后看到的输出:
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:6382 to 127.0.0.1:6379
Adding replica 127.0.0.1:6383 to 127.0.0.1:6380
Adding replica 127.0.0.1:6384 to 127.0.0.1:6381
>>> Trying to optimize slaves allocation for anti-affinity
[WARNING] Some slaves are in the same host as their master
M: ... 127.0.0.1:6379 slots:[0-5460] (5461 slots) master
M: ... 127.0.0.1:6380 slots:[5461-10922] (5462 slots) master
M: ... 127.0.0.1:6381 slots:[10923-16383] (5461 slots) master
S: ... 127.0.0.1:6382 replicates ...
S: ... 127.0.0.1:6383 replicates ...
S: ... 127.0.0.1:6384 replicates ...
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
.
>>> Performing Cluster Check (using node 127.0.0.1:6379)
M: ... 127.0.0.1:6379 slots:[0-5460] (5461 slots) master
1 additional replica(s)
...
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
每一步发生了什么:
┌───────────────────────────────────────────────┐
│ redis-cli --cluster create 执行流程 │
├───────────────────────────────────────────────┤
│ │
│ ① 分配槽位:前N个节点分到16384个槽 │
│ │
│ ② 分配从节点:后N个节点成为前N个节点从库 │
│ │
│ ③ 分配configEpoch:每个主节点一个唯一纪元 │
│ │
│ ④ 执行CLUSTER MEET:让所有节点相互认识 │
│ │
│ ⑤ 执行CLUSTER ADDSLOTS:给每个主节点分配槽 │
│ │
│ ⑥ 执行CLUSTER REPLICATE:建立主从关系 │
│ │
│ ⑦ 验证:确保16384个槽都有节点负责 │
│ │
└───────────────────────────────────────────────┘
步骤3:验证集群
# 检查集群状态
redis-cli -p 6379 CLUSTER INFO
# cluster_state:ok
# cluster_slots_assigned:16384
# cluster_slots_ok:16384
# cluster_known_nodes:6
# cluster_size:3
# 检查节点列表
redis-cli -p 6379 CLUSTER NODES
步骤4:测试数据分布
# 用集群模式连接
redis-cli -c -p 6379
127.0.0.1:6379> SET hello world
-> Redirected to slot [866] located at 127.0.0.1:6379
OK
127.0.0.1:6379> SET foo bar
-> Redirected to slot [12182] located at 127.0.0.1:6380
OK
# 注意输出中的 "Redirected" —— 这就是MOVED重定向,下一章详细讲
踩坑提示:如果你是
redis-cli --cluster create执行后发现cluster_state:fail,最可能的原因是节点之间CLUSTER MEET失败。检查三点:防火墙是否开放了客户端端口和集群通信端口(+10000端口)、cluster-announce-ip是否配置正确、所有节点是否都在运行。
六、槽与节点的运行视图
每个节点在内存中维护了一个槽到节点的映射表。这个表在 clusterState 结构体中:
// 槽→节点映射:16384个元素的数组
// slots[i] = NULL 表示槽i未分配
// slots[i] = clusterNode* 表示槽i由某个节点负责
clusterNode *slots[CLUSTER_SLOTS]; // 16384 * 8字节 = 131KB
// 迁移状态记录
clusterNode *migrating_slots_to[CLUSTER_SLOTS];
clusterNode *importing_slots_from[CLUSTER_SLOTS];
在集群正常运行时,slots 数组提供O(1)时间的路由查找:
客户端收到 GET user:1001:profile
│
▼
计算 slot = CRC16("user:1001:profile") & 16383 = 8416
│
▼
查 slots[8416] = Node B (192.168.1.102:6380)
│
▼
如果当前节点 == Node B → 直接处理
如果当前节点 != Node B → 返回 MOVED 8416 192.168.1.102:6380
这套机制的核心优势:
- 无中心节点:不需要NameNode/ZooKeeper之类的元数据服务器
- O(1)路由:算CRC16 + 查数组,微秒级完成
- 去中心化一致性:所有节点通过Gossip维护相同的slots数组
七、CLUSTER DELSLOTS 与动态调整
槽分配不是一成不变的。增加节点时需要从现有节点移走一些槽:
# 从当前节点删除槽 0-100
redis-cli -p 6379 CLUSTER DELSLOTS $(seq 0 100)
# 需要先DELSLOTS再在目标节点上ADDSLOTS
# 如果直接ADDSLOTS给新节点,旧节点不会自动释放
不过实际生产环境不会手动DELSLOTS/ADDSLOTS——太容易出错。正确做法是用 redis-cli --cluster reshard 进行在线迁移(第50篇细讲)。
完整的槽管理命令族:
| 命令 | 作用 | 适用场景 |
|---|---|---|
CLUSTER ADDSLOTS |
给当前节点分配槽 | 手动分配(慎用) |
CLUSTER DELSLOTS |
从当前节点移除槽 | 手动释放(慎用) |
CLUSTER SETSLOT |
设置槽的迁移状态 | 在线迁移(第50篇) |
CLUSTER KEYSLOT |
查看key属于哪个槽 | 调试和验证 |
CLUSTER COUNTKEYSINSLOT |
查看某槽有多少key | 迁移前评估 |
CLUSTER GETKEYSINSLOT |
列出某槽的key | 迁移中获取key |
总结
16384个哈希槽是Redis Cluster分片机制的灵魂。这个数字并非拍脑袋决定的,而是在Gossip消息开销、集群规模上限、槽迁移粒度三者之间找到的最优解。CRC16算法的低14位映射简单高效,HashTag提供了把关联数据放到同一个槽的灵活性。理解哈希槽的原理,是掌握Redis Cluster的基石。
下一篇文章,我们将进入集群运行时的核心——当key不在当前节点时,MOVED和ASK重定向机制如何让请求准确地到达正确节点。
上一篇【第47篇】Redis Cluster——官方分布式集群方案全解析
下一篇【第49篇】MOVED和ASK——Cluster重定向机制详解
更多推荐




所有评论(0)