KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 集群分片:槽、hash tag、MOVED 与客户端路由表 — keel 龙骨
前四章的方案里,数据始终只有"一份完整副本",受限于单机内存。集群是唯一能把数据分到多台机器上的形态。这一章讲它的分片规则(16384 个槽)、请求送错节点时 Redis 怎么回答(MOVED)、客户端怎么自己算地址,以及上集群必须接受的三个能力损失。
前四章的方案里,数据始终只有"一份完整副本",受限于单机内存。集群是唯一能把数据分到多台机器上的形态。这一章讲它的分片规则(16384 个槽)、请求送错节点时 Redis 怎么回答(MOVED)、客户端怎么自己算地址,以及上集群必须接受的三个能力损失。
一、现场:内存到顶,但是删不动
单机 Redis 到了 28G / 32G,淘汰策略和大 key 都优化过了,剩下 20G 是确实需要常驻的会话与索引。主从复制帮不上忙——每个副本都是全量数据,加副本只会让"内存占用 × N"。哨兵也帮不上——它解决的是主库单点,不是容量。
这时候需要的是把这 20G 拆到 4 台机器上,每台 5G。Redis 集群的分片单位是槽(hash slot)。
二、直觉模型:16384 个槽,分给各主节点
key 落在哪个槽是算出来的: slot = CRC16(key) % 16384
集群把 16384 个槽分给各个主节点:
7000: 槽 0 - 5460
7001: 槽 5461 - 10922
7002: 槽 10923 - 16383
每个主节点只存"自己那几个槽"里的 key。
三条推论:
- key → 槽的映射是纯计算,不需要问服务器。任何客户端都能自己算,这是"客户端路由"的基础。
- 槽是迁移的最小单位(第 06 章扩容时搬的就是槽),所以上限 16384 决定了迁移粒度。
- 一个 key 永远只属于一个槽,因此也永远只在一台机器上——这是后面所有限制(多键、事务、Lua)的根源。
三、精确定义:三个必须分清的概念
| 概念 | 定义 | 常见误用 |
|---|---|---|
| hash slot | key 空间被切成 16384 份,编号 0~16383,CRC16(key) % 16384 决定归属 |
把"槽"和"节点"混为一谈——槽是逻辑单位,节点是物理承载 |
| hash tag | key 中第一个 { 与其后第一个 } 之间的子串,只有它参与 CRC16 计算 |
以为 {} 是命名空间或分组标记,其实它只是"强制同槽"的工具 |
| MOVED | 服务端返回的永久重定向:这个槽现在归别人了,请更新路由表 | 把它和 ASK 混为一谈(第 06 章区分) |
hash tag 的三条细则(写 key 设计时直接用到):
- key 里没有
{...}成对出现 → 整个 key 参与计算; - 有
{...}且中间非空 → 只算花括号里那一段; {}为空(如user:{}:profile)或只有{没有}→ 按没有 hash tag 处理,整个 key 参与计算。
四、一次完整运行:搭一个三主集群并观察路由
节点配置里必须开 cluster-enabled:
port 7000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
Redis 5 起 redis-cli 自带 --cluster 子命令(不再需要 Ruby 脚本):
redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 --cluster-yes
实测输出:
>>> Performing hash slots allocation on 3 nodes...
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
>>> Performing Cluster Check (using node 127.0.0.1:7000)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
最后一行是上线前的硬检查:只要有一个槽没人认领,集群就不能完整工作。
看集群整体状态:
127.0.0.1:7000> CLUSTER INFO
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:3
cluster_size:3
cluster_current_epoch:3
cluster_my_epoch:1
cluster_size:3 是主节点数(不含副本);cluster_slots_assigned 必须等于 16384。
看谁负责哪段槽:
127.0.0.1:7000> CLUSTER NODES
f155ef94f02bb9633e0fae90bbe03172e7817f9f 127.0.0.1:7000@17000 myself,master - 0 1790917272000 1 connected 0-5460
6eed45e7ac37a6cd9f86a3cd4c3b176b080cccdd 127.0.0.1:7001@17001 master - 0 1790917275371 2 connected 5461-10922
ee93b89b902f6fec25e8b16c397504fedb2740e6 127.0.0.1:7002@17002 master - 0 1790917274276 3 connected 10923-16383
每行结构:节点ID 地址:数据端口@集群总线端口 角色 主节点ID ... 连接状态 负责的槽。注意 7000@17000——集群总线端口 = 数据端口 + 10000,节点之间用它做 gossip。防火墙必须放行这两个端口,否则节点互相看不见(搭集群最常见的失败原因)。
本课的实测集群是 3 主 0 从(为看清槽路由)。生产环境每个主节点都要配至少 1 个副本,否则主节点挂掉后它那部分槽完全不可用。
算给你看:key 到槽
127.0.0.1:7000> CLUSTER KEYSLOT user:1000
1649
127.0.0.1:7000> CLUSTER KEYSLOT article:42
5605
127.0.0.1:7000> CLUSTER KEYSLOT cart:9
1156
再看 hash tag 的效果——两个业务前缀不同、但 {} 里都是 1000 的 key:
127.0.0.1:7000> CLUSTER KEYSLOT order:{1000}:items
11326
127.0.0.1:7000> CLUSTER KEYSLOT order:{1000}:addr
11326
同一个槽。这就是 hash tag 的全部意义:让"逻辑上属于同一组"的 key 落在同一台机器上,从而可以做多键操作。
五、MOVED:送错节点时 Redis 怎么回答
连到 7000,却要写一个属于 7001 的 key:
127.0.0.1:7000> SET article:42 hello
MOVED 5605 127.0.0.1:7001
服务端没有帮你转发,它只是告诉你"这个槽(5605)在 7001"。普通客户端会把 MOVED 当成错误抛给用户;支持集群的客户端(或 redis-cli -c)会自己跳过去重发:
$ redis-cli -c -p 7000 SET article:42 hello
OK
$ redis-cli -c -p 7000 GET article:42
hello
客户端路由表
集群客户端在内存里维护一张 槽 → 节点 的表,启动时用 CLUSTER SLOTS 拉全量,之后遇到 MOVED 就更新对应槽段:
flowchart TD
A["客户端要执行 SET article:42 hello"] --> B["本地算 slot = CRC16(key) % 16384 = 5605"]
B --> C{"路由表里有 5605 吗?"}
C -- 有 --> D["直连该节点发命令"]
C -- 没有 --> E["随便挑一个已知节点发"]
D --> F{"返回 MOVED?"}
E --> F
F -- 否 --> G["命令完成"]
F -- 是 --> H["按 MOVED 更新路由表<br/>(这个槽归别人了)"]
H --> I["向新节点重发命令"]
I --> G
I -.->|"槽正在迁移中<br/>(第 06 章)"| J["返回 ASK:一次性跳转<br/>不更新路由表"]
图上最后那条分支是第 06 章的主题:ASK 是"临时借道",MOVED 是"永久改道",客户端处理方式不同。
六、上集群必须接受的三个能力损失
损失一:跨槽多键操作被直接拒绝
127.0.0.1:7000> MGET user:1000 article:42
CROSSSLOT Keys in request don't hash to the same slot
用 hash tag 让它们同槽就能通过:
127.0.0.1:7002> MSET order:{1000}:items a order:{1000}:addr b
OK
127.0.0.1:7002> MGET order:{1000}:items order:{1000}:addr
1) "a"
2) "b"
影响面:MGET/MSET/SUNIONSTORE/SINTER 等多键命令、MULTI 事务里的所有 key、以及 Lua 脚本里的所有 key 都必须落在同一个槽。
对本站点的影响很具体:Redis 工程课第 04 章那个分布式锁只碰 1 个 key,在集群下没问题;但 ARQ 这类一次操作碰多个 key 的组件,就必须在集群下重新设计 key(第 07 章展开)。
损失二:只有 db 0
集群不支持 SELECT,多数据库概念不存在。现有代码里若有 SELECT 1 之类的切换,迁移前必须改掉。
损失三:迁移期间的行为变化
槽在迁移中时,请求可能收到 ASK 而不是 MOVED,客户端处理不当会出错——第 06 章专门讲。
七、客户端实测:redis-py 的集群模式
import redis
from redis.cluster import RedisCluster
# 只需要给一个(或几个)种子节点,其余靠 CLUSTER SLOTS 自动发现
r = RedisCluster(host="127.0.0.1", port=7000, decode_responses=True, protocol=2)
实测输出:
== 路由表(客户端本地缓存的槽分布) ==
0-1648 -> {'primary': ('127.0.0.1', 7000), 'replicas': []}
1650-5460 -> {'primary': ('127.0.0.1', 7000), 'replicas': []}
1649-1649 -> {'primary': ('127.0.0.1', 7001), 'replicas': []}
== 跨槽写入(客户端自动找节点) ==
user:1000 = alice (slot 1649)
article:42 = hello (slot 5605)
cart:9 = x (slot 1156)
== 跨槽多键:客户端自己拦下来 ==
RedisClusterException: MGET - all keys must map to the same key slot
== hash tag 让两个 key 落进同一个槽 ==
slots: 11326 11326
mget: ['a', 'b']
三点值得注意:
- 路由表是客户端自己维护的,注意这里
1649是单独一段——因为第 06 章的实验把它单独迁走了。槽段不需要连续。 - 跨槽写入对业务代码透明:
set不同 key 时客户端自动选节点,你感觉不到分片存在。这是集群最舒服的部分。 - 跨槽多键在客户端就被拦下了(
RedisClusterException),不会发到服务端。也就是说这个错误在改代码时就会暴露——前提是测试覆盖了多键路径。
读写分离在集群里同样可用(read_from_replicas=True,前提是有副本)。
八、集群自带的高可用(不需要哨兵)
集群节点之间通过集群总线做 gossip,主节点失败时由它的副本顶上:
- PFAIL(可能下线):某节点在
cluster-node-timeout内没被另一个节点响应,被单个节点标记; - FAIL:当多数主节点都认为它 PFAIL 时升级为 FAIL,并广播给全集群;
- 随后该主节点的副本发起选举,获得多数主节点同意后提升为主,接管它的槽。
所以集群方案不需要再叠一层哨兵——这是很多人重复建设的地方。
九、误判澄清
| 误解 | 核对 | 结论 |
|---|---|---|
| "16384 是最大节点数" | 它是槽数,与节点数无关 | 官方建议主节点不超过 1000 个(gossip 开销),槽始终是 16384 |
| "集群是高可用的升级版,顺便解决容量" | 恰恰相反:集群主要解决容量与写吞吐,高可用是附带的 | 只想要高可用 → 主从 + 哨兵更简单 |
| "上了集群,热点 key 就不热了" | 一个 key 只属于一个槽、一台机器 | 热点 key 要改 key 设计或加本地缓存,分片救不了 |
| "MOVED 是错误,说明出问题了" | MOVED 是正常协议的一部分,尤其客户端冷启动时 | 优秀客户端会静默处理;把它当异常抛给用户是缺陷 |
| "hash tag 用来给 key 分组" | 它只影响参与 CRC16 计算的那段字符串 | 用错会让大量 key 挤进同一个槽,形成数据倾斜 |
十、失败注入清单
- 跨槽 MGET / Lua:故意写一条跨槽命令,确认报错形态(
CROSSSLOT或客户端异常),检查代码有没有吞掉它。 - 关掉一个主节点(无副本时):观察
cluster_state是否变fail、有多少槽变成fail、客户端是否报错;然后给每个主补上副本再重复一次,对比结果。 - 堵住集群总线端口(数据端口通、总线端口不通):观察节点能否组成集群——这是验证防火墙配置最直接的一項。
十一、生产环境怎样替换
| 教学替身 | 生产替换 | 要点 |
|---|---|---|
| 3 主 0 从 | 每主至少 1 副本(常见 3 主 3 从) | 无副本时主节点故障 = 那部分槽完全不可用 |
| 本机多端口 | 跨机器、跨机架/可用区 | 主与它的副本必须在不同机器上 |
手工 --cluster create |
编排脚本 / 托管服务,并纳入变更流程 | 建群操作要可重复、可回滚 |
| 单 key 操作的教学示例 | 全量改造多键、Lua、事务 | 这是上集群最大的工程量,提前评估 |
十二、练习与验收
练习 1:给你的系统里 5 个需要多键操作的场景设计 hash tag,用 CLUSTER KEYSLOT 验证它们确实同槽,并检查有没有造成槽倾斜。
练习 2:用 redis-cli -c 连一个节点,依次访问落在不同节点的 key 观察重定向;再用 CLUSTER SLOTS 打印完整路由表,与自己算的 KEYSLOT 对照。
练习 3:列出代码里所有 MULTI / Lua / 多键命令,逐个标注"集群下能否运行",给出改造方案(加 hash tag / 拆分 / 换实现)。
验收点:不看资料,说出 16384、CRC16 取模、hash tag 三条细则、MOVED 的含义与客户端该做什么、集群的三个能力损失、以及为什么集群不需要再叠哨兵。
现在能解释什么:槽与节点的关系;key 到槽的计算与 hash tag 的作用;MOVED 语义与客户端路由表;集群下多键/事务/Lua 的限制;集群自带的 PFAIL/FAIL 与副本提升。
下一步:第 06 章做集群最核心的运维动作——在线扩容,以及迁移期间那个语义不同的 ASK。