上一篇【第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:nameuser: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重定向机制详解


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐