主题
第 10 章 Cluster 集群与数据分片
学习目标:彻底搞清楚 Redis Cluster 的「为什么、怎么分、怎么找、怎么转」四件大事。看完之后能解释「为什么是 16384 个槽不是 65536」「为什么选哈希槽不选一致性哈希」「MOVED 和 ASK 到底差在哪」「HashTag 强制同槽是怎么实现的」「Cluster vs Sentinel 怎么选」这类追问。
10.1 单机/主从/哨兵的瓶颈:为什么需要 Cluster
📦 生活类比:从「家里的小冰箱」升级到「连锁仓储」。
- 单机 Redis 就像家里那台 200 升小冰箱,存得下日常蔬菜。
- 主从就是「再放一台备用冰箱在邻居家」,原来那台坏了能顶上,但容量还是 200 升。
- 哨兵就是「请了 24 小时巡查的物业」,冰箱坏了能立刻搬出邻居家那台来用,容量仍然 200 升。
- Cluster 就是开连锁仓储:东、南、西三个仓库分担存货,每个仓库自己管自己那一片货架——容量、吞吐都翻倍。
前面 9 章我们的 Redis 一直是「一台机器在战斗」:
Ch1~Ch7:单机 Redis(一台 master 包打天下)
Ch8: 主从复制 (读扩展,但写仍是单点)
Ch9: 哨兵 Sentinel(master 挂了能自动切,但「数据」还是一份)
Ch10: Cluster ← 数据本身被「切片」分散到多台机器,写也能扩展10.1.1 单机的两道天花板
① 内存上限
┌────────────────────────────────────────────────────┐
│ 物理内存就这么大(主流 64GB ~ 256GB), │
│ 真要塞 1TB 数据?买不到那么大的内存条,价格也劝退 │
│ 实例越大 → fork RDB / AOF 重写阻塞越严重 │
└────────────────────────────────────────────────────┘
② 写入 QPS 上限
┌────────────────────────────────────────────────────┐
│ 命令执行仍是单线程(6.0 多线程只优化网络 I/O), │
│ 单实例写 QPS 上限 ~10 万(小命令) │
│ CPU 主频天花板,纵向扩容收益有限 │
└────────────────────────────────────────────────────┘10.1.2 主从 + 哨兵也救不了写吞吐
读 QPS 翻倍 ✅(slave 分流读) 写 QPS 仍单点 ❌(全打 master)
高可用 ✅(master 挂了能切) 内存仍单点 ❌(slave 是完整副本)| 方案 | 容量扩展 | 读吞吐 | 写吞吐 | 高可用 |
|---|---|---|---|---|
| 单机 | ❌ | ❌ | ❌ | ❌ |
| 主从(Ch8) | ❌ | ✅ | ❌ | ❌ |
| 哨兵(Ch9) | ❌ | ✅ | ❌ | ✅ |
| Cluster | ✅ | ✅ | ✅ | ✅ |
结论:主从 + 哨兵解决「可用性」,解决不了「容量 + 写吞吐」。 唯一出路:把数据「切」成 N 份分散到不同机器上 —— 这就是 数据分片(Sharding)。
10.2 数据分片的两种思路
把数据切片散到多台机器上,业界主流有两种思路:「一致性哈希」与「哈希槽」。Redis 选了后者,本节讲清楚两者的差异以及选择背后的理由。
10.2.1 朴素方案的弊病:hash(key) % N
最朴素的分片方案是直接对节点数取模:
key1 → hash("key1") % 3 = 1 → 节点 1
key2 → hash("key2") % 3 = 0 → 节点 0
key3 → hash("key3") % 3 = 2 → 节点 2看起来很美,问题在于扩缩容:
3 节点扩到 4 节点:
key1 → hash("key1") % 4 = ? → 重新算
key2 → hash("key2") % 4 = ? → 重新算
...
❌ 几乎所有 key 都要换节点 → 全量数据迁移 → 缓存击穿 + 数据库被打挂所以真正的方案必须做到「节点变化时只迁移少量数据」。
10.2.2 思路一:一致性哈希(Consistent Hash)
把整个 hash 空间想象成一个 0 ~ 2³² - 1 的环:
0 / 2³²
│
┌──────────┴──────────┐
│ │
[Node A]│ │
│ │[Node B]
│ │
│ hash 环 │
key1 ● │ │ ● key2
│ │
│ │
│ │
└──────────┬──────────┘
│
[Node C]
key 落到环上某点,顺时针找到的第一个节点 = 归属节点节点变化时的影响范围:
新增 Node D 在 A 和 B 之间:
┌──────────┬──────────────────────┐
│ 受影响 │ 原本顺时针落到 B 的 key │
│ │ 现在落到 D(被 D 截胡) │
├──────────┼──────────────────────┤
│ 不受影响 │ 落到 A、C 的 key 完全不动│
└──────────┴──────────────────────┘
⇒ 节点增删时,仅影响「环上相邻区间」的 key
⇒ 平均迁移量 ≈ 1/N 数据但有两个痛点:
痛点 1:数据倾斜
节点 hash 在环上分布是「随机」的,节点少时会出现:
Node A 占据 90% 圆弧 → 90% 的 key 都打到 A
解决:引入「虚拟节点」,每个物理节点在环上挂 N 个副本
N 越大越均匀(典型 100~200 个),但元信息也越多
痛点 2:迁移粒度不可控
新加 Node D 后,从 B 那里「截」走的 key 数取决于
D 在环上的位置——你说不准到底搬多少10.2.3 思路二:哈希槽(Hash Slot)
Redis 没用一致性哈希,它发明了一个更「离散化、可控化」的方案——哈希槽:
① 总共 16384 个槽(slot),编号 0 ~ 16383
② key 通过哈希算法映射到一个槽:
slot = CRC16(key) % 16384
③ 16384 个槽被「人为分配」给 N 个节点
Node A : slot 0 ~ 5460 (5461 个槽)
Node B : slot 5461 ~ 10922 (5462 个槽)
Node C : slot 10923 ~ 16383 (5461 个槽)
④ 客户端拿着 key → 算 slot → 查槽-节点映射表 → 找到节点核心区别:一致性哈希是「计算式」(hash → 环上位置 → 顺时针找节点);哈希槽是「查表式」(hash → slot → 查表找节点)。
10.2.4 灵魂之问:为什么 Redis 选哈希槽?
💬 antirez 在 GitHub Issue #2576 的原话整理:
1. 一致性哈希在节点变化时仍有 1/N 数据需要迁移
且这部分迁移是「环上相邻区间」,到底搬多少数据 → 不可控
哈希槽:可以「精确控制」迁移哪些槽
- 想把 200 个槽从 A 搬到 D?精确指定
- 想让 A 担负更多写入?多分点槽给 A 即可
- 运维拥有「最小迁移单元 = 1 个槽」的控制力| 维度 | 一致性哈希 | 哈希槽(Redis Cluster) |
|---|---|---|
| 抽象 | hash 环 + 虚拟节点 | 16384 个槽 + 槽-节点表 |
| 数据均衡 | 依赖虚拟节点数量,节点少时易倾斜 | 16384 一刀切,精确可控 |
| 迁移粒度 | 区间,不可控 | 以 slot 为最小单元,精确指定 |
| 扩容影响 | 只动「环上相邻」的 key | 只动「被划走的那批 slot」 |
| 元信息 | 完整的 hash 环(节点 × 虚拟副本) | 固定 16384 大小的映射表 |
| 实现复杂度 | 虚拟节点 + 二分查找 | 取模 + 数组下标 |
| 运维干预 | 节点位置由 hash 决定,人工干预难 | 手动指定槽归属,运维友好 |
| 定向操作 | 难支持「多 key 同节点」 | HashTag {tag} 天然支持 |
一句话:哈希槽是一致性哈希的「离散化、可控化」版本,更适合 Redis 这种「需要事务/Lua/HashTag 等定向操作」的场景。
🍱 生活类比:
- 一致性哈希像「邮政分拣中心按大区」——上海的快递落在「华东」,但上海某个具体区到底落在哪个分拣员手上是看「自然分布」的。
- 哈希槽像「邮政编码」——16384 个邮编预先定好,每个邮编归哪个分拣员是邮政人为定的,要调整就「把这 100 个邮编给老李」。
10.3 16384 个槽
10.3.1 核心公式
CRC16(key) 查 slot → node 表
┌─────┐ ┌─────────────┐ ┌────────────────┐
│ key │ ──→ │ % 16384 │ ──→ slot ──→ │ Node B (5461..) │
└─────┘ └─────────────┘ └────────────────┘
slot = CRC16(key) mod 16384redis-cli 实测:
bash
127.0.0.1:7000> CLUSTER KEYSLOT "user:1001"
(integer) 1903
127.0.0.1:7000> CLUSTER KEYSLOT "user:1002"
(integer) 7232
127.0.0.1:7000> CLUSTER KEYSLOT "order:8888"
(integer) 810410.3.2 为什么是 16384 而不是 65536?
CRC16 算出来的值范围是 0 ~ 65535(2¹⁶ 种),那为什么对 16384 取模呢?面试高频追问。
antirez 的原文回答可以浓缩成 3 条:
① 心跳包(PING/PONG)大小
节点心跳包内携带「我负责哪些 slot」的 bitmap
16384 槽 = 16384 / 8 = 2048 字节 = 2 KB
65536 槽 = 65536 / 8 = 8192 字节 = 8 KB
↑ 4 倍带宽差距!集群越大、心跳越频繁 → 网络浪费越显著
每秒每节点对其他节点都发心跳 → 流量呈 O(N²) 增长
② 集群规模上限
Redis 官方明确说 Cluster 不建议超过 1000 节点
16384 / 1000 ≈ 16 槽/节点 → 已经够细粒度
再用 65536 没收益,心跳反而更重
③ bitmap 压缩率
节点持有的 slot 通常是连续的若干段
16384 在节点数较少时(典型几十~几百节点)压缩比更高一句话:16384 是「心跳带宽 vs 集群粒度」之间的最佳折中点。
心跳大小 集群规模
│ │
小│ 大│
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ 16384 槽 │ ←最佳折中→ │ 65536 槽 │
│ 2 KB 心跳 │ │ 8 KB 心跳 │
│ ≤1000 节点│ │ 没收益 │
└───────────┘ └───────────┘10.3.3 注意:CRC16 vs 取模
易错点:别把「16384」说成「CRC16 的取值上限」!
CRC16 → 0 ~ 65535(共 65536 种)
再 % 16384 才落到 0 ~ 1638310.4 集群拓扑:3 主 3 从最小可用集群
10.4.1 推荐拓扑
┌──────────────────────────────┐
│ Redis Cluster 集群 │
└──────────────────────────────┘
│ │ │
┌────────┴───┐ ┌───────┴────┐ ┌──────┴─────┐
│ Master A │ │ Master B │ │ Master C │
│ slot 0~5460│ │5461~10922 │ │10923~16383 │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│主从复制 │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Slave A1 │ │ Slave B1 │ │ Slave C1 │
└──────────┘ └──────────┘ └──────────┘
↑↑↑ 任一 Master 挂了,对应 Slave 自动顶上 ↑↑↑为什么至少 3 主?因为故障判定需要「过半 master 投票」(详见 10.8)。
- 1 主:单点
- 2 主:1:1 平票,无法选举
- 3 主:3 个里活着 2 个就能选 → 最小可用集群
为什么每个主都要带从?Master 挂了,其下的 slot 才有人接班;否则那段 slot 不可用。
10.4.2 槽位分配
16384 槽 / 3 master ≈ 5461.33
实际分配:
┌─────────┬───────────┬─────────┬─────────────────┐
│ Master │ slot 范围 │ 槽数 │ 占比 │
├─────────┼───────────┼─────────┼─────────────────┤
│ A │ 0 ~ 5460 │ 5461 │ 33.33% │
│ B │5461~10922 │ 5462 │ 33.34%(多 1) │
│ C │10923~16383│ 5461 │ 33.33% │
├─────────┼───────────┼─────────┼─────────────────┤
│ 合计 │ 0 ~ 16383 │ 16384 │ 100% │
└─────────┴───────────┴─────────┴─────────────────┘
16384 不能被 3 整除,差出来的 1 个槽给 B10.4.3 一键搭建
redis-cli 自带集群创建工具:
bash
# 启动 6 个 Redis 实例(端口 7000~7005,每个 cluster-enabled yes)
# 然后一行命令搭起 3 主 3 从集群:
redis-cli --cluster create \
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1
# --cluster-replicas 1 表示「每个 master 配 1 个 slave」输出类似:
>>> 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:7004 to 127.0.0.1:7000
Adding replica 127.0.0.1:7005 to 127.0.0.1:7001
Adding replica 127.0.0.1:7003 to 127.0.0.1:7002
...
[OK] All 16384 slots covered.10.4.4 状态查看
bash
127.0.0.1:7000> CLUSTER INFO
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_known_nodes:6
cluster_size:3 ← 3 个 master
...
127.0.0.1:7000> CLUSTER NODES
abc123... 127.0.0.1:7000@17000 myself,master - 0 1700... 1 connected 0-5460
def456... 127.0.0.1:7001@17001 master - 0 1700... 2 connected 5461-10922
ghi789... 127.0.0.1:7002@17002 master - 0 1700... 3 connected 10923-16383
jkl012... 127.0.0.1:7003@17003 slave ghi789... 0 ... 3 connected
mno345... 127.0.0.1:7004@17004 slave abc123... 0 ... 1 connected
pqr678... 127.0.0.1:7005@17005 slave def456... 0 ... 2 connected每个节点两个端口:客户端口 P(如 6379)+ Gossip 总线端口 P+10000(如 16379)。
10.5 Gossip 协议:节点间通信
10.5.1 怎么让所有节点知道「全集群拓扑」?
集群里有 N 个节点,每个节点都需要知道:
① 集群里有哪些其他节点(IP、port、节点 ID)
② 每个节点负责哪些 slot
③ 每个节点是 master 还是 slave,slave 的 master 是谁
④ 每个节点是否还活着(心跳延迟、PFAIL/FAIL 状态)两种思路:
- 中心化:找一台「主控节点」收集所有信息再下发——主控挂了集群就瞎了,违反 Cluster「无中心」的设计哲学。
- 去中心化(Gossip):节点之间互相「八卦」,渐进式收敛。Redis 选了这种。
🗣️ 生活类比:办公室八卦
- 你不需要 HR 群发邮件「张三今天离职了」
- A 听到张三走了,茶水间随口告诉了 B
- B 出去吃饭告诉 C 和 D
- 一传十、十传百,几轮之后整个公司都知道了
- 每个人手里都不知道全员信息,但通过「随机和别人聊几句」,最后整个办公室都收敛到一致状态
- 这种渐进收敛就是 Gossip 协议的精髓。
10.5.2 四种 Gossip 消息
┌──────┬────────────────────────────────────────────────┐
│ MEET │ 「认识一下」 │
│ │ CLUSTER MEET ip port 触发 │
│ │ 把陌生节点「拉入集群」的第一条消息 │
├──────┼────────────────────────────────────────────────┤
│ PING │ 「你还活着吗?顺便告诉你些八卦」 │
│ │ 每秒每节点选 5 个最久没收到 PONG 的节点 │
│ │ → 各发一个 PING │
│ │ PING 包内捎带 ~1/10 已知节点的状态信息 │
├──────┼────────────────────────────────────────────────┤
│ PONG │ 「我活着,这是我知道的最新八卦」 │
│ │ 收到 PING 或 MEET 时回应 │
│ │ 也可主动广播 PONG 通知拓扑变更 │
├──────┼────────────────────────────────────────────────┤
│ FAIL │ 「XXX 节点客观下线了,全员知晓!」 │
│ │ 过半 master 把某节点标 PFAIL → 升级为 FAIL │
│ │ → 广播 FAIL 通知全集群 │
└──────┴────────────────────────────────────────────────┘10.5.3 PING 的「随机 5 选 1」
为什么不让每个节点都给所有其他节点发 PING?因为 N 节点 × N 节点 = O(N²) 消息,集群一大就爆。所以:
每秒每节点:
1. 从「已知节点列表」里随机抽出几个
2. 选一个「最久没收到 PONG」的发 PING
3. 总共每秒约 1 个主动 PING 包
补充规则:
若某节点超过 cluster-node-timeout / 2 没收到 PONG
→ 立即对它发 PING(防止被误判 PFAIL)
PING 包内 gossip 字段:
携带约 1/10 已知节点的状态信息
N 节点集群约 N/10 个 gossip 条目10.5.4 收敛速度
T=0: A 加入集群 → A/B 互识;C/D/E/F 各自独立
T=1: B → C 发 PING(捎带 "我认识 A")→ C 学到 A
D → C 发 PING → C 学到 D
T=2: C → A 发 PING(捎带 "我认识 D")→ A 学到 D
E → C 发 PING → C 学到 E
T=3~∞: 几轮之后,所有节点彼此都认识 → 集群收敛N 个节点的集群,Gossip 收敛时间 ≈ O(log N) 轮。这就是「八卦」的妙处——传播速度是指数级的。
10.6 客户端路由:MOVED 与 ASK 重定向
10.6.1 智能客户端:本地缓存路由表
成熟的客户端(jedis、lettuce、go-redis、redis-py 的 RedisCluster)都会在内部维护一张「slot → node」表:
客户端启动:
随便连一个节点 → 发 CLUSTER SLOTS → 拿到全集群 slot 分布
缓存到本地,形成一张大小 16384 的数组:
slotMap[0..5460] → Node A
slotMap[5461..10922] → Node B
slotMap[10923..16383] → Node C
后续操作:
GET key → CRC16(key) % 16384 → 查 slotMap → 直连正确节点
↑ 稳态下,请求 0 次重定向,性能逼近单机但是有两种「打脸」情况:MOVED 和 ASK。
10.6.2 MOVED 重定向:「永久性搬家通知」
场景:客户端连到了「错误的」节点(路由表过时了)
127.0.0.1:7000> SET user:1001 alice
(error) MOVED 1903 127.0.0.1:7001
↑ ↑
槽号 这个槽现在归 7001 管
含义:
- 这个 key(slot 1903)确实不在我(7000)这
- 它属于 7001
- 你以后访问这个 slot 都应该去 7001
↑ 智能客户端应该立即「更新本地缓存的 slot→node 表」MOVED 的语义:「这个 slot 已经永久 归别人管了,更新你的路由表」。
客户端 7000 7001
│ │ │
│ SET user:1001 alice │ │
├───────────────────────►│ │
│ │ │
│ MOVED 1903 7001 │ │
│ ◄──────────────────────┤ │
│ │ │
│ ① 更新本地路由表 │ │
│ slotMap[1903] = 7001 │
│ │ │
│ ② SET user:1001 alice │
├─────────────────────────────────────────►│
│ +OK │
│ ◄────────────────────────────────────────┤10.6.3 ASK 重定向:「临时性搬家通知」
场景:slot 正在迁移中(resharding 进行时)
127.0.0.1:7000> GET user:1001
(error) ASK 1903 127.0.0.1:7001
↑ ↑
槽号 这个 key「这一次」要去 7001
含义:
- 这个 slot 1903 正在从 7000 迁到 7001
- 这个具体的 key 已经搬过去了,但 slot 还没整体迁完
- 你「这一次」请求去 7001 取
- 但路由表「不要更新」!下次访问别的 key 还是先走我(7000)ASK 的语义:「这个 key 临时去那边一下,但 slot 整体还没搬完,路由表别乱改」。
10.6.4 ASK 的两步握手
收到 ASK 后客户端不能直接 GET,要先发一个 ASKING 命令:
客户端 7000 7001
│ │ │
│ GET user:1001 │ │
├───────────────────────►│ │
│ │ │
│ ASK 1903 7001 │ │
│ ◄──────────────────────┤ │
│ │ │
│ ASKING │
├─────────────────────────────────────────►│
│ +OK │
│ ◄────────────────────────────────────────┤
│ │
│ GET user:1001 │
├─────────────────────────────────────────►│
│ $5\r\nalice\r\n │
│ ◄────────────────────────────────────────┤为什么要 ASKING?
因为目标节点 7001 的路由表里 slot 1903 还不是它的——直接 GET 会回个 MOVED 把客户端踢回 7000,变成死循环。ASKING 就是个「一次性通行证」,告诉 7001:「我知道你还没正式接管,但这次破例给我执行下」。
10.6.5 MOVED vs ASK 一图对比
┌──────────┬──────────────────────┬──────────────────────┐
│ │ MOVED │ ASK │
├──────────┼──────────────────────┼──────────────────────┤
│ 时机 │ 槽 已经迁移完成 │ 槽 正在迁移中 │
│ 永久性 │ ✅ 永久 │ ❌ 仅本次 │
│ 客户端动作│ 更新本地路由表 │ 不更新路由表 │
│ 是否ASKING│ 不需要 │ 需要先发 ASKING │
│ 触发场景 │ 路由表过时 │ resharding 进行中 │
└──────────┴──────────────────────┴──────────────────────┘10.7 多 Key 操作 与 HashTag
10.7.1 跨 slot 的多 Key 操作不允许
127.0.0.1:7000> MSET a 1 b 2 c 3
(error) CROSSSLOT Keys in request don't hash to the same slot
↑
a/b/c 算出来的 slot 大概率不在同一个节点所有「多 Key 原子语义」操作都受影响:
❌ MGET k1 k2 k3
❌ MSET k1 v1 k2 v2
❌ DEL k1 k2 k3
❌ MULTI / EXEC(事务里访问多个不同 slot 的 key)
❌ EVAL(Lua 脚本里访问多个不同 slot 的 key)
❌ SINTER / SUNION / SDIFF(多个集合做运算)为什么? 因为每个 slot 只属于一个节点,跨 slot 等于跨节点,跨节点就不能保证原子性(要保证就得分布式事务,性能炸裂)。
10.7.2 HashTag:用 {...} 强制把 key 路由到同一槽
Redis Cluster 提供了一个特殊语法:如果 key 中包含 {xxx},只用花括号里的部分计算 slot:
普通 key:
user:1001 → CRC16("user:1001") % 16384 = 1903
带 HashTag 的 key:
{user:1001}:profile → CRC16("user:1001") % 16384 = 1903
{user:1001}:cart → CRC16("user:1001") % 16384 = 1903
{user:1001}:orders → CRC16("user:1001") % 16384 = 1903
↑ 三个 key 强制落到同一个 slot,进而同一个节点实测:
bash
127.0.0.1:7000> CLUSTER KEYSLOT "{user:1001}:profile"
(integer) 1903
127.0.0.1:7000> CLUSTER KEYSLOT "{user:1001}:cart"
(integer) 1903
127.0.0.1:7000> CLUSTER KEYSLOT "{user:1001}:orders"
(integer) 1903
# 现在可以原子地多 key 操作了
127.0.0.1:7000> MSET {user:1001}:profile p1 {user:1001}:cart c1 {user:1001}:orders o1
OK
127.0.0.1:7000> MULTI
OK
127.0.0.1:7000> HSET {user:1001}:profile name Alice
QUEUED
127.0.0.1:7000> HINCRBY {user:1001}:cart count 1
QUEUED
127.0.0.1:7000> EXEC
1) (integer) 1
2) (integer) 110.7.3 HashTag 的解析规则
1. 第一个 { 之后到第一个 } 之间的内容 → 用作 hash 计算
2. 必须是 "{...}",且中间不能为空({} 视为无效,回退到整个 key)
3. 只看第一对 {},后面的 {} 当普通字符
4. 没有 {} 时退化为对整个 key 计算
示例:
"{abc}:def" → CRC16("abc") // 取 abc
"abc{def}ghi" → CRC16("def") // 取 def
"{}foo" → CRC16("{}foo") // {} 为空,无效
"{a}{b}" → CRC16("a") // 只看第一对
"abc" → CRC16("abc") // 没 {},用整个10.7.4 HashTag 的副作用:热点风险
✅ 好处:相关 key 强制同节点 → 支持事务/Lua
❌ 坏处:tag 选不好就成「热点制造机」
- {global}:counter:xxx → 所有 counter 挤同一 slot
- 那个节点直接被打爆,其他节点闲置最佳实践:HashTag 选「业务上必然要一起原子操作」的维度(如同一个用户、同一个订单),不要为了「方便」而无脑加。
✅ {user:1001}:cart // user 维度,每个用户独立 → 散得开
✅ {order:8888}:items // order 维度
❌ {global}:user:1001 // 所有 user 挤一起 → 单点热点
❌ {2026-04-17}:logs:xxx // 当天所有日志挤一起10.8 集群下的故障转移
集群里每个 master 都有 1+ 个从节点。Master 挂了之后,从节点会自动接管——这套流程和 Sentinel 类似,但完全内置,不需要额外部署。
10.8.1 故障判定:PFAIL → FAIL
①「主观下线」(PFAIL, Possibly Fail)
节点 A 在 cluster-node-timeout 内(默认 15 秒)
收不到节点 B 的 PONG → A 单方面认为 B 挂了
A 把 B 标记为 PFAIL,并通过 Gossip 告诉其他节点
②「客观下线」(FAIL)
集群中过半的 master 都把 B 标记为 PFAIL
→ 集群达成共识 "B 真的挂了"
广播 FAIL 消息,所有节点把 B 标 FAIL💬 似曾相识? 这正是 Sentinel 里 SDOWN(主观下线)→ ODOWN(客观下线)的同款机制,只不过这里参与判定的是 master 节点本身,而不是哨兵进程。
10.8.2 从节点选举:epoch 投票(Raft-like)
B(master)挂了,B 有 3 个 slave:B1、B2、B3
↓
① 从节点先等一会儿(避免「网络抖动恢复」就乱发起选举)
等待时间 = 500ms + random(0~500) + rank * 1000ms
rank:从节点和 master 的「复制偏移量」差距排名
→ 越接近 master 的从节点 rank 越小,先发起选举(数据最新)
② 候选者 B1 自增 currentEpoch(任期号),向所有 master 广播
FAILOVER_AUTH_REQUEST(epoch=N)
③ 每个 master 在一个 epoch 内只能投一票(先到先得)
master 收到请求后回 FAILOVER_AUTH_ACK(epoch=N)
④ B1 收到过半 master 的 ACK → 当选
⑤ 当选后:
- B1 把自己 promote 为 master
- 接管 B 原来负责的所有 slot
- 通过 Gossip 广播新的 slot 归属
- 客户端下次访问这些 slot → 收到 MOVED → 更新路由表Raft-like 体现在:
currentEpoch≈ Raft 的 term- 一个 epoch 一票、过半当选 ≈ Raft 的 leader election
- 但 Cluster 没用完整的 Raft(不需要 log replication,因为复制是 master→slave 单向的)
10.8.3 关键配置
ini
# 节点超时时间(毫秒)—— 超过这个时间没 PONG 算 PFAIL
# 也用作「slave 多久没和 master 通信就放弃竞选」的判据
cluster-node-timeout 15000
# 是否要求所有 slot 都可用才接受请求
# yes(默认):任何一个 slot 没人管 → 整集群拒写
# no :部分 slot 不可用时,其他 slot 仍能正常读写
# 生产建议:no(避免一处故障拖垮全部)
cluster-require-full-coverage no
# slave 自动迁移:master A 有 3 从、master B 一个从都没了
# → 自动调一个 A 的从节点过去补 B
cluster-allow-replica-migration yes10.9 集群 vs 哨兵 vs 主从 选型
10.9.1 三种方案对比
| 维度 | 主从 | 哨兵(Sentinel) | 集群(Cluster) |
|---|---|---|---|
| 部署复杂度 | 低(只配 replicaof) | 中(哨兵 + 主从) | 高(≥6 节点 + 集群感知客户端) |
| 数据容量 | 单点 | 单点 | 横向扩展 |
| 写吞吐 | 单点 | 单点 | N 倍 master |
| 读吞吐 | 多 slave 分流 | 多 slave 分流 | 多 master 各自分流 |
| 高可用 | ❌ 需手工切换 | ✅ 自动 failover | ✅ 内置 failover |
| 故障切换时长 | 人工分钟级 | 30 秒级 | 秒~十秒级 |
| 客户端要求 | 无 | 哨兵感知 | 集群感知 |
| 多 Key 操作 | ✅ 任意 | ✅ 任意 | ⚠️ 需同 slot 或 HashTag |
| 事务/Lua | ✅ 任意 | ✅ 任意 | ⚠️ 涉及的 key 需同 slot |
| 运维成本 | ★ | ★★ | ★★★ |
10.9.2 选型流程图
┌──────────────────┐
│ 你的数据 < 50GB? │
└────────┬─────────┘
yes │ no
┌──────────────────┴────────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ 写 QPS < 5 万? │ │ Cluster │
└────────┬─────────┘ │ (没得选,必须分片) │
yes │ no └──────────────────────┘
┌───────┴──────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ 哨兵 │ │ Cluster │
│Sentinel │ │ │
└──────────┘ └──────────┘10.9.3 实战经验法则
小项目、内存能装下、QPS 不大 → 主从 + 哨兵就够
电商/社交/中等业务,能装下但写多 → 哨兵;除非确认要分片
数据 100GB+ 或 写 QPS > 10 万 → Cluster
要严格事务/Lua/多 key 操作很多 → 优先非 Cluster 方案
(或精心设计 HashTag)
遗留代码客户端不支持 Cluster → 先上哨兵10.10 实战:跑一遍配套代码 + Demo
实战代码见 10_cluster/code/:
01_crc16_slot.py—— 自己实现 CRC16-CCITT(XMODEM),对若干 key 计算 slot;演示 HashTag{x}:y把多个 key 算到同一槽;附 1 万随机 key 在 3 节点上的分布均衡度测试02_cluster_client.py—— 用redis.RedisCluster演示集群客户端的 API;若环境只有单机,用纯 Python 模拟一个 6 节点集群的读写、MOVED 重定向、节点故障转移03_hashtag_demo.py—— 演示「跨槽 MGET 失败」vs 「用 HashTag 后成功」的完整对比(事务、Lua、MGET 三种场景)
浏览器演示见 10_cluster/demo.html,4 个交互 Tab:
- Tab ① 16384 槽位分配可视化:6 个 master 节点分段着色的进度条,可加节点演示槽位重新分布
- Tab ② CRC16 路由实验:实时输入 key,显示 CRC16 值 + 取模 + 落到哪个节点;对比
{user:1}:cart和user:1的不同路由 - Tab ③ MOVED / ASK 重定向动画:动画展示客户端被重定向、更新路由表、ASK 两步握手的完整流程
- Tab ④ Gossip 八卦传播动画:6 个节点圆形布局,每秒每节点随机和 5 个节点 PING;点「节点 X 状态变化」看消息扩散全集群
10.11 本章小结
┌────────────────────────────────────────────────────────────┐
│ 本章核心要点 │
├────────────────────────────────────────────────────────────┤
│ │
│ ① 单机/主从/哨兵的瓶颈:内存 + 写吞吐 → 唯一出路是「分片」 │
│ │
│ ② 数据分片两种思路: │
│ • 一致性哈希:hash 环 + 虚拟节点 │
│ → 节点变化仍有 1/N 数据迁移,且粒度不可控 │
│ • 哈希槽 16384:slot = CRC16(key) % 16384 │
│ → 可「精确控制」迁移哪些槽,运维友好 │
│ │
│ ③ 为什么 16384? │
│ - 心跳带宽:2KB vs 8KB │
│ - 节点上限 1000,16 槽/节点已够细 │
│ - bitmap 压缩率 │
│ │
│ ④ 最小可用集群:3 主 3 从(5461/5461/5462 槽分配) │
│ │
│ ⑤ Gossip 协议:4 种消息(MEET/PING/PONG/FAIL) │
│ 每秒每节点选 5 个发 PING,O(log N) 轮收敛 │
│ → 「办公室八卦」式扩散 │
│ │
│ ⑥ 客户端路由: │
│ - 智能客户端缓存 slot→node 表,稳态零重定向 │
│ - MOVED:永久重定向,更新路由表 │
│ - ASK :临时重定向,先发 ASKING(一次性通行证) │
│ │
│ ⑦ 多 Key 操作必须同 slot │
│ HashTag {tag} 强制同槽,但小心热点 │
│ │
│ ⑧ 故障转移:PFAIL → FAIL → 从节点 epoch 投票(Raft-like) │
│ cluster-require-full-coverage no(生产建议) │
│ │
│ ⑨ 选型:< 50GB + QPS < 5 万 → 哨兵;否则 Cluster │
│ │
└────────────────────────────────────────────────────────────┘10.12 面试高频题
Q1:Redis Cluster 是怎么分片的?
考察点:哈希槽机制 + 经典的「16384 数字之谜」。
标准答案:
Redis Cluster 在「key」和「node」之间加了一层 slot(槽)抽象:
- 把 key 的 CRC16 值对 16384 取模,得到一个 0~16383 的 slot 编号;
- 集群手动或自动地把 16384 个 slot 分配给若干 master 节点(如 3 master:5461 / 5461 / 5462);
- 客户端持有「slot → node」映射表,请求时:CRC16(key) % 16384 → 查表 → 直连节点。
带来的好处:
- 节点扩缩容时只迁移被划走的那部分 slot,其余 key 完全不动
- 运维可以「精确指定」迁移哪些槽,最小迁移单元就是 1 个 slot
- HashTag
{tag}让多 key 必落同一 slot → 支持事务/Lua
易错点:
- CRC16 输出范围是 0~65535(2¹⁶),「16384」是取模上限不是「CRC16 上限」
- 16384 = 2¹⁴,方便位运算
Q2:为什么是 16384 个槽?
考察点:底层细节理解 + antirez 设计意图。
标准答案(antirez 在 GitHub Issue #2576 的原话整理):
1. 心跳包大小
节点 PING/PONG 包内携带「我负责哪些 slot」的 bitmap
- 16384 槽 → 2 KB(16384/8)
- 65536 槽 → 8 KB
集群越大、心跳越频繁,4 倍带宽差距越显著
每秒每节点对其他节点都发心跳 → 流量 O(N²) 增长
2. 集群规模上限
Redis 官方明确说 Cluster 不建议超过 1000 节点
16384 / 1000 ≈ 16 槽/节点,已经够细粒度
再用 65536 没收益,心跳反而更重
3. bitmap 压缩率
节点持有的 slot 通常是连续的若干段
16384 在节点数较少时压缩率更高一句话:16384 是「心跳带宽 vs 集群粒度」的最佳折中。
加分项:16384 = 2¹⁴,对 16384 取模可以用位运算 & 0x3FFF 加速。
Q3:一致性哈希和哈希槽的区别?为什么 Redis 选哈希槽?
考察点:分片方案选型理解。
标准答案:
| 维度 | 一致性哈希 | 哈希槽 |
|---|---|---|
| 抽象 | hash 环 + 虚拟节点 | 16384 个槽 + 槽-节点表 |
| 数据均衡 | 依赖虚拟节点数量,节点少时易倾斜 | 16384 一刀切,精确可控 |
| 迁移粒度 | 区间,不可控(看节点 hash 位置) | 以 slot 为单元,精确指定 |
| 元信息 | 完整 hash 环(含虚拟节点) | 固定 16384 大小映射表,紧凑 |
| 实现复杂度 | 二分查找 hash 环 | 取模 + 数组下标,简单 |
| 运维干预 | 节点位置由 hash 决定,难干预 | 可手动指定 slot 归属 |
| 多 key 同节点 | 难支持 | HashTag 天然支持 |
为什么 Redis 选哈希槽:
- 一致性哈希在节点变化时仍有 1/N 数据需要迁移(且区间大小不可控);哈希槽可以「精确控制」迁移哪些槽——这是核心理由。
- 元信息紧凑(固定 16384 项 vs 增长的环结构)。
- 实现简单(取模查表 vs 二分查找环)。
- 运维友好(可手动指定 slot 归属)。
- 支持「定向操作」(HashTag)。
加分项:哈希槽是 antirez 借鉴 Couchbase vBucket 的设计;Memcached 主流客户端用一致性哈希。
Q4:MOVED 和 ASK 重定向的区别?
考察点:客户端路由细节。
标准答案:
| 维度 | MOVED | ASK |
|---|---|---|
| 触发时机 | slot 已经迁移完成 | slot 正在迁移中 |
| 语义 | 这个 slot 永久归别人管了 | 这个 key「就这一次」去那边 |
| 客户端行为 | 必须更新本地 slot→node 表 | 不更新,下次还走原节点 |
| 是否需要 ASKING | 不需要 | 需要先发 ASKING 再发命令 |
ASK 流程:客户端 → 源节点 GET → 收 ASK → 连目标节点 → 先发 ASKING(一次性通行证)→ 再发 GET → 目标节点正常返回。
为什么需要 ASKING:目标节点路由表里这个 slot 还不是它的——直接 GET 会回 MOVED 把客户端踢回去,死循环。
易错点:
- 别说 ASK 也要更新路由表(绝对不能更新!)
- ASKING 是独立命令,不是 GET 的 flag
- 迁移完成后源节点回 MOVED 而不是 ASK
Q5:集群下能用事务/Lua 吗?HashTag 怎么用?
考察点:Cluster 的限制 + 实战技巧。
标准答案:
默认情况:Cluster 中事务、Lua 脚本里访问的所有 key 都必须同 slot,否则报错:
(error) CROSSSLOT Keys in request don't hash to the same slot受影响的操作:MGET / MSET / DEL k1 k2 / 事务 / Lua / SINTER 等多 key 集合运算。
HashTag 是 Redis Cluster 的「强制同槽」语法:key 中包含 {xxx} 时,只用花括号里的内容算 CRC16:
bash
{user:1001}:profile ── slot = CRC16("user:1001") % 16384 = 1903
{user:1001}:cart ── slot = 1903
{user:1001}:orders ── slot = 1903
↑ 三个 key 强制同 slot 同节点 → 可 MGET / 事务 / Lua解析规则:取第一对完整非空 {...};{} 为空时无效;{a}{b} 只看 a。
热点风险:tag 选不好(如 {global})会让所有 key 都挤一个 slot 形成单点热点。HashTag 应选「业务上必然要一起原子操作」的维度(同 user / 同 order)。
Q6:Cluster 和哨兵该怎么选?
考察点:架构选型理解。
标准答案:
| 哨兵 | Cluster | |
|---|---|---|
| 解决问题 | 高可用 | 分片 + 高可用 |
| 写吞吐 | 单 master 上限 | N 个 master 横向扩展 |
| 数据容量 | 单机内存上限 | 横向扩展 |
| 部署复杂度 | 中(哨兵 + 主从) | 高(≥6 节点 + 集群感知客户端) |
| 客户端 | 哨兵感知 | 集群感知(必须支持 MOVED/ASK) |
| 多 key 操作 | 任意 | 必须同 slot 或 HashTag |
| 切换速度 | 30 秒级 | 秒~十秒级 |
选型流程:
1. 数据 ≥ 50GB 或 写 QPS > 10 万? → Cluster(被迫)
2. 否则数据装得下、写 QPS 在单机能力内? → 哨兵(简单够用)
3. 业务大量依赖事务/Lua/多 key 操作? → 优先哨兵
(上 Cluster 必须做好 HashTag 设计)
4. 遗留代码客户端不支持 Cluster? → 哨兵实战经验:能不上 Cluster 就不上 Cluster——Cluster 的复杂度(运维 + 客户端 + HashTag 设计)比想象中高。
加分项:提到「Codis / Twemproxy 这种代理分片方案是中间形态」「Cluster 和 Sentinel 不能同时使用,前者已内置 failover」。
Q7:HashTag 滥用会有什么问题?
考察点:架构落地经验。
标准答案:
HashTag 把多个 key 强制路由到同一 slot 同一节点。滥用的后果是「热点节点」:
❌ 所有 key 都用 {global} 前缀
→ 所有 key 落同一 slot
→ 集群退化成单机,其他节点闲置
❌ 用「日期」做 tag 如 {2026-04-17}:logs:xxx
→ 当天所有日志挤一个节点
✅ 用「业务实体」做 tag:{user:1001}、{order:8888}
→ 不同实体散布到不同节点判断原则:HashTag 应选「业务上必然要一起原子操作」的维度——既保证多 key 同节点(满足事务/Lua/MGET),又保证不同实体能散布到全集群。
监控:定期看每个节点的 key 数 / 内存 / QPS,发现单节点显著高于平均 → 排查 HashTag。
📌 下一章预告:第 11 章我们看 分布式锁——
SETNX的演进史、SET key value NX PX 30000的标准姿势、Redisson 看门狗的「锁续命」设计、以及那个有点争议的 Redlock 算法。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
Ch10 配套代码 1 / 3 —— CRC16 算法 + Slot 计算 + HashTag 演示
演示:
1. 自己实现 Redis 用的 CRC16-CCITT(XMODEM 变体)算法
2. 对若干 key 计算 slot = CRC16(key) % 16384
3. HashTag {tag}:xxx 强制同槽 —— 业务关联 key 必落同节点
4. HashTag 解析的边界 case({}、{a}{b}、嵌套)
无需连接 Redis Server,纯算法演示。
"""
from typing import Optional
# ──────────────────────────────────────────────────────────────
# Redis 集群使用的 CRC16-CCITT (XMODEM) 算法
# 多项式:0x1021;初始值:0x0000;不反转
# 这张 256 项的查找表是 Redis 源码 src/crc16.c 里的原表
# ──────────────────────────────────────────────────────────────
CRC16TAB = [
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0,
]
CLUSTER_SLOTS = 16384
def crc16(data: bytes) -> int:
"""Redis 用的 CRC16-CCITT (XMODEM) 算法"""
crc = 0x0000
for b in data:
crc = ((crc << 8) & 0xFFFF) ^ CRC16TAB[((crc >> 8) ^ b) & 0xFF]
return crc
def extract_hashtag(key: str) -> Optional[str]:
"""提取 HashTag:第一对非空 {...} 之间的内容
若无有效 HashTag,返回 None(用整个 key 计算)"""
start = key.find("{")
if start == -1:
return None
end = key.find("}", start + 1)
if end == -1 or end == start + 1: # } 不存在 或 {} 中间为空
return None
return key[start + 1:end]
def key_slot(key: str) -> int:
"""模拟 Redis 的 keyHashSlot:先抽 HashTag,再 CRC16 % 16384"""
tag = extract_hashtag(key)
target = tag if tag is not None else key
return crc16(target.encode("utf-8")) % CLUSTER_SLOTS
def section(title: str) -> None:
print("\n" + "=" * 64)
print(title)
print("=" * 64)
def demo_basic_slot() -> None:
section("Demo 1: 普通 key 的 slot 计算")
keys = ["user:1001", "user:1002", "user:1003",
"order:8888", "product:42", "session:abc",
"foo", "bar", "baz", "hello world"]
for k in keys:
s = key_slot(k)
print(f" {k:25} → slot = {s:5}")
def demo_hashtag() -> None:
section("Demo 2: HashTag 强制同槽")
keys = [
"{user:1001}:profile",
"{user:1001}:cart",
"{user:1001}:orders",
"{user:1001}:wishlist",
]
print(" 同一 user 的 4 个相关 key(带 HashTag {user:1001}):")
for k in keys:
s = key_slot(k)
print(f" {k:35} → slot = {s:5}")
print(" ✅ 全部落在同一 slot —— 同节点 → 支持事务/MGET/Lua")
print("\n 对比:不带 HashTag 时 4 个 key 各自飘散:")
for k in ["user:1001:profile", "user:1001:cart",
"user:1001:orders", "user:1001:wishlist"]:
s = key_slot(k)
print(f" {k:35} → slot = {s:5}")
def demo_hashtag_edge_cases() -> None:
section("Demo 3: HashTag 解析的边界 case")
cases = [
("{abc}:def", "abc", "正常 HashTag"),
("abc{def}ghi", "def", "中间含 HashTag"),
("{}foo", "{}foo", "{} 为空 → 无效,回退整个 key"),
("{a}{b}", "a", "多对花括号 → 只看第一对"),
("{a{b}c}", "a{b", "嵌套 → 第一个 } 截断"),
("abc", "abc", "无花括号"),
("{abc", "{abc", "缺右括号 → 无效"),
("abc}", "abc}", "缺左括号 → 无效"),
]
print(f" {'输入 key':<15} {'实际参与 hash 的内容':<20} {'slot':>6} 说明")
print(" " + "─" * 70)
for k, expected, desc in cases:
s = key_slot(k)
actual = extract_hashtag(k) or k
ok = "✓" if actual == expected else "✗"
print(f" {k:<15} {actual:<20} {s:>6} {desc} [{ok}]")
def demo_distribution() -> None:
section("Demo 4: 1 万个随机 key 在 3 节点上的分布均衡度")
# 3 个节点平分 16384 槽
ranges = [(0, 5460), (5461, 10922), (10923, 16383)]
counters = [0, 0, 0]
for i in range(10000):
k = f"key:{i}"
s = key_slot(k)
for idx, (lo, hi) in enumerate(ranges):
if lo <= s <= hi:
counters[idx] += 1
break
total = sum(counters)
print(f" {'节点':<12} {'slot 范围':<15} {'key 数':>8} 占比")
print(" " + "─" * 50)
for i, ((lo, hi), c) in enumerate(zip(ranges, counters)):
pct = c / total * 100
bar = "█" * int(pct / 2)
print(f" Node-{i+1:<7} {lo:5}~{hi:<8} {c:>8} {pct:5.2f}% {bar}")
print(" 💡 CRC16 + 16384 槽 → 分布相当均匀(理想 33.33%)")
if __name__ == "__main__":
demo_basic_slot()
demo_hashtag()
demo_hashtag_edge_cases()
demo_distribution()python
"""
Ch10 配套代码 2 / 3 —— Cluster 客户端 API 演示
演示:
1. 用 redis.RedisCluster (redis-py 4.0+) 连接 Redis Cluster
2. 集群感知客户端的核心特性:
- 自动 CRC16 计算 + slot 路由
- 自动处理 MOVED / ASK 重定向
- 节点拓扑自动感知(CLUSTER SLOTS)
3. 当无真实 Cluster 可用时,用纯 Python 模拟一个 6 节点(3 主 3 从)集群
使用:
- 真集群: python 02_cluster_client.py --real --host 127.0.0.1 --port 7000
- 模拟器: python 02_cluster_client.py (默认)
"""
import argparse
import random
from typing import Dict, List, Optional, Tuple
# 复用 01 里的 CRC16 + slot 计算
from importlib import import_module
_crc = import_module("01_crc16_slot") if False else None # Python 不允许 01_ 开头的模块名
# 简单内联:避免动态导入受限于文件名
CRC16TAB = [
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0,
]
CLUSTER_SLOTS = 16384
def crc16(data: bytes) -> int:
crc = 0x0000
for b in data:
crc = ((crc << 8) & 0xFFFF) ^ CRC16TAB[((crc >> 8) ^ b) & 0xFF]
return crc
def extract_hashtag(key: str) -> Optional[str]:
s = key.find("{")
if s == -1:
return None
e = key.find("}", s + 1)
if e == -1 or e == s + 1:
return None
return key[s + 1:e]
def key_slot(key: str) -> int:
tag = extract_hashtag(key)
target = tag if tag is not None else key
return crc16(target.encode("utf-8")) % CLUSTER_SLOTS
def section(title: str) -> None:
print("\n" + "=" * 64)
print(title)
print("=" * 64)
# ──────────────────────────────────────────────────────────────
# 模式 A:真实 RedisCluster
# ──────────────────────────────────────────────────────────────
def run_real_cluster(host: str, port: int) -> None:
try:
from redis.cluster import RedisCluster, ClusterNode
except ImportError:
print("❌ redis-py 未安装或版本过低(需要 4.0+)")
print(" pip install 'redis>=4.0'")
return
try:
rc = RedisCluster(
startup_nodes=[ClusterNode(host, port)],
decode_responses=True,
require_full_coverage=False,
)
section("Demo A1: 集群基本读写(自动路由)")
for i in range(5):
k, v = f"user:{1000 + i}", f"name-{i}"
rc.set(k, v)
slot = rc.keyslot(k) if hasattr(rc, "keyslot") else key_slot(k)
print(f" SET {k:12} = {v:8} slot={slot:5} → 自动路由到正确 master")
section("Demo A2: 集群拓扑")
nodes = rc.get_nodes()
for n in nodes:
print(f" {n.host}:{n.port} role={n.server_type} name={n.name}")
section("Demo A3: 跨槽 MGET 会失败,HashTag 拯救")
try:
rc.mget("user:1000", "user:1001", "user:1002")
print(" (部分 redis-py 版本会自动拆分 mget,看不到原始 CROSSSLOT 错误)")
except Exception as e:
print(f" ❌ 跨槽 MGET 错:{e}")
rc.mset({
"{user:9999}:profile": "p",
"{user:9999}:cart": "c",
"{user:9999}:orders": "o",
})
result = rc.mget(
"{user:9999}:profile",
"{user:9999}:cart",
"{user:9999}:orders",
)
print(f" ✅ 带 HashTag 的 MGET:{result}")
rc.delete(
"{user:9999}:profile", "{user:9999}:cart", "{user:9999}:orders",
*[f"user:{1000 + i}" for i in range(5)],
)
except Exception as e:
print(f"❌ 连接 Redis Cluster 失败: {e}")
print(" 请确认提供的是「集群模式」节点(cluster-enabled yes)")
# ──────────────────────────────────────────────────────────────
# 模式 B:纯 Python 模拟一个 6 节点(3 主 3 从)集群
# ──────────────────────────────────────────────────────────────
class FakeNode:
"""一个 master 或 slave 节点。master 维护 (slot 范围) 内的 KV"""
def __init__(self, name: str, port: int, role: str):
self.name = name # "A", "B", "C", "A1"...
self.port = port # 7000~7005
self.role = role # "master" | "slave"
self.alive = True
self.master_of: Optional["FakeNode"] = None # slave→master
self.slaves: List["FakeNode"] = []
self.slot_range: Optional[Tuple[int, int]] = None
self.kv: Dict[str, str] = {}
def __repr__(self) -> str:
sr = f"{self.slot_range[0]}~{self.slot_range[1]}" if self.slot_range else "-"
return f"<{self.name}/{self.port}/{self.role}/slot={sr}/alive={self.alive}>"
class FakeCluster:
"""6 节点 3 主 3 从模拟集群(一切只在内存里)"""
def __init__(self) -> None:
self.nodes: List[FakeNode] = []
# 3 master
for i, name in enumerate(["A", "B", "C"]):
self.nodes.append(FakeNode(name, 7000 + i, "master"))
# 3 slave,分别挂到 A/B/C
for i, (name, master_idx) in enumerate(zip(["A1", "B1", "C1"], [0, 1, 2])):
sl = FakeNode(name, 7003 + i, "slave")
sl.master_of = self.nodes[master_idx]
self.nodes[master_idx].slaves.append(sl)
self.nodes.append(sl)
# 槽位均分
masters = [n for n in self.nodes if n.role == "master"]
ranges = self._distribute_slots(len(masters))
for m, r in zip(masters, ranges):
m.slot_range = r
@staticmethod
def _distribute_slots(n: int) -> List[Tuple[int, int]]:
base = CLUSTER_SLOTS // n
extra = CLUSTER_SLOTS % n
out, cursor = [], 0
for i in range(n):
size = base + (1 if i < extra else 0)
out.append((cursor, cursor + size - 1))
cursor += size
return out
def find_master(self, slot: int) -> Optional[FakeNode]:
for n in self.nodes:
if n.role == "master" and n.alive and n.slot_range:
if n.slot_range[0] <= slot <= n.slot_range[1]:
return n
return None
def get(self, key: str) -> Tuple[Optional[str], FakeNode]:
slot = key_slot(key)
node = self.find_master(slot)
if node is None:
raise RuntimeError(f"slot {slot} 无可用 master!")
return node.kv.get(key), node
def set(self, key: str, val: str) -> FakeNode:
slot = key_slot(key)
node = self.find_master(slot)
if node is None:
raise RuntimeError(f"slot {slot} 无可用 master!")
node.kv[key] = val
return node
def fail_node(self, name: str) -> None:
"""模拟节点宕机 + 故障转移"""
for n in self.nodes:
if n.name == name:
n.alive = False
if n.role == "master" and n.slaves:
# 选第一个还活着的 slave 接管
for sl in n.slaves:
if sl.alive:
print(f" 💥 {name} 宕机,从节点 {sl.name} 当选新 master")
sl.role = "master"
sl.slot_range = n.slot_range
sl.master_of = None
n.slot_range = None
return
print(f" 💥 {name} 宕机,但无可用 slave 接管 → slot 不可用!")
def topology(self) -> str:
out = []
for n in self.nodes:
sr = f"slot {n.slot_range[0]:5}~{n.slot_range[1]:5}" if n.slot_range else " " * 19
mark = "✓" if n.alive else "✗"
mast = f" → master={n.master_of.name}" if n.master_of else ""
out.append(f" [{mark}] {n.name:3} :{n.port} {n.role:6} {sr}{mast}")
return "\n".join(out)
def run_simulator() -> None:
cl = FakeCluster()
section("Demo B1: 集群拓扑(6 节点 3 主 3 从)")
print(cl.topology())
section("Demo B2: 集群读写 —— 客户端自动算 slot 找节点")
keys = ["user:1001", "order:8888", "product:42",
"session:abc", "cart:7", "stock:gpu"]
for k in keys:
node = cl.set(k, f"val-of-{k}")
slot = key_slot(k)
print(f" SET {k:13} → slot={slot:5} → 路由到 {node.name}({node.port})")
section("Demo B3: 模拟 master B 宕机 + 故障转移")
print(" 宕机前 B 上的数据:", {k: v for k, v in cl.nodes[1].kv.items()})
cl.fail_node("B")
print(cl.topology())
print("\n 下次写 slot 5461~10922 的 key 自动路由到新 master(原 B1):")
n = cl.set("order:8888", "rewritten")
print(f" SET order:8888 → 路由到 {n.name}({n.port})")
print(" ⚠️ 但是宕机前的数据丢了 —— 因为模拟器没实现复制;真实 Cluster 中 slave 是异步复制 master 的")
section("Demo B4: HashTag 强制同节点 演示")
keys2 = ["{user:1001}:profile", "{user:1001}:cart", "{user:1001}:orders"]
nodes_set = set()
for k in keys2:
n = cl.set(k, "x")
nodes_set.add(n.name)
print(f" SET {k:30} → 落到 {n.name}({n.port})")
print(f"\n ✅ 三个 key 全部落到同一节点:{nodes_set} —— 可以原子地 MGET / MULTI / EVAL")
def main() -> None:
parser = argparse.ArgumentParser(description="Ch10 Cluster 客户端演示")
parser.add_argument("--real", action="store_true", help="连接真实 Redis Cluster")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=7000)
args = parser.parse_args()
if args.real:
print(f">>> 模式:连接真实 Cluster {args.host}:{args.port}")
run_real_cluster(args.host, args.port)
else:
print(">>> 模式:纯 Python 模拟器(无需真实 Redis Cluster)")
run_simulator()
if __name__ == "__main__":
main()python
"""
Ch10 配套代码 3 / 3 —— HashTag 实战对比
演示「跨槽 多 key 操作 失败」vs「用 HashTag 后成功」的完整对比,覆盖:
1. MGET / MSET 跨槽报错 → HashTag 拯救
2. 事务 MULTI / EXEC 跨槽报错 → HashTag 拯救
3. Lua EVAL 脚本跨槽报错 → HashTag 拯救
4. HashTag 滥用 → 单节点热点(慎用 {global})
无需真实 Redis Cluster:本脚本用 02_cluster_client.py 中的 FakeCluster
模拟集群行为,对每条命令做 slot 检查;真实环境的报错信息和这里一样。
"""
import sys
import os
from typing import List, Tuple
# 复用 02 中的 FakeCluster + key_slot
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import importlib.util
spec = importlib.util.spec_from_file_location(
"cluster_client",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "02_cluster_client.py"),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
FakeCluster = mod.FakeCluster
key_slot = mod.key_slot
extract_hashtag = mod.extract_hashtag
# ──────────────────────────────────────────────────────────────
# 模拟「Cluster 跨槽检查」
# ──────────────────────────────────────────────────────────────
class CrossSlotError(Exception):
pass
def check_same_slot(keys: List[str]) -> int:
"""所有 key 必须落在同一 slot,否则抛 CROSSSLOT 错"""
slots = {key_slot(k) for k in keys}
if len(slots) > 1:
detail = ", ".join(f"{k}→{key_slot(k)}" for k in keys)
raise CrossSlotError(
f"CROSSSLOT Keys in request don't hash to the same slot ({detail})"
)
return slots.pop()
def section(title: str) -> None:
print("\n" + "=" * 64)
print(title)
print("=" * 64)
def show_keys_with_slot(keys: List[str]) -> None:
for k in keys:
tag = extract_hashtag(k)
tag_info = f" [HashTag: {tag!r}]" if tag is not None else ""
print(f" {k:35} → slot={key_slot(k):5}{tag_info}")
# ──────────────────────────────────────────────────────────────
# Demo 1:MGET 跨槽 vs HashTag
# ──────────────────────────────────────────────────────────────
def demo_mget(cl: FakeCluster) -> None:
section("Demo 1: MGET 跨槽失败 → HashTag 拯救")
print("\n ▶ 场景 1A:不带 HashTag 的多 key MGET")
bad_keys = ["user:1001:profile", "user:1001:cart", "user:1001:orders"]
show_keys_with_slot(bad_keys)
try:
check_same_slot(bad_keys)
print(" (槽相同,不报错 —— 但实际上靠运气,不可依赖)")
except CrossSlotError as e:
print(f" ❌ MGET 报错:{e}")
print("\n ▶ 场景 1B:带 HashTag 的多 key MGET")
good_keys = ["{user:1001}:profile", "{user:1001}:cart", "{user:1001}:orders"]
show_keys_with_slot(good_keys)
slot = check_same_slot(good_keys)
for k in good_keys:
cl.set(k, f"val-of-{k}")
values = [cl.get(k)[0] for k in good_keys]
node = cl.find_master(slot)
print(f" ✅ 三个 key 都在 slot {slot},节点 {node.name} ({node.port})")
print(f" MGET 结果:{values}")
# ──────────────────────────────────────────────────────────────
# Demo 2:事务跨槽 vs HashTag
# ──────────────────────────────────────────────────────────────
def demo_transaction(cl: FakeCluster) -> None:
section("Demo 2: MULTI / EXEC 事务 跨槽失败 → HashTag 拯救")
print("\n ▶ 场景 2A:事务里访问不同 slot 的 key(典型「下单扣库存」业务)")
tx_bad = [
("DECRBY", "stock:gpu", "1"),
("HSET", "order:8888", "user", "1001"),
("LPUSH", "user:1001:orders_log", "8888"),
]
keys_bad = [c[1] for c in tx_bad]
show_keys_with_slot(keys_bad)
try:
check_same_slot(keys_bad)
print(" (恰好同 slot —— 但生产环境不能赌)")
except CrossSlotError as e:
print(f" ❌ EXEC 报错:{e}")
print("\n ▶ 场景 2B:用同一个 user HashTag 把所有相关 key 框到一起")
tx_good = [
("DECRBY", "{user:1001}:stock:gpu", "1"),
("HSET", "{user:1001}:order:8888", "status", "paid"),
("LPUSH", "{user:1001}:orders_log", "8888"),
]
keys_good = [c[1] for c in tx_good]
show_keys_with_slot(keys_good)
slot = check_same_slot(keys_good)
print(f" ✅ 全部落在 slot {slot} → 事务可以原子执行")
for cmd in tx_good:
cl.set(cmd[1], "fake")
print(f" {' '.join(cmd):60} OK")
# ──────────────────────────────────────────────────────────────
# Demo 3:Lua EVAL 跨槽 vs HashTag
# ──────────────────────────────────────────────────────────────
def demo_lua(cl: FakeCluster) -> None:
section("Demo 3: Lua EVAL 跨槽失败 → HashTag 拯救")
print("\n ▶ 场景 3A:限流脚本同时操作两个 key(计数 + 时间窗)")
print(' EVAL "..." 2 ratelimit:user:1001 ratelimit:user:1001:ts ...')
bad_keys = ["ratelimit:user:1001", "ratelimit:user:1001:ts"]
show_keys_with_slot(bad_keys)
try:
check_same_slot(bad_keys)
print(" (恰好同 slot —— 但 redis 6.0+ 客户端会做更严格的检查)")
except CrossSlotError as e:
print(f" ❌ EVAL 报错:{e}")
print("\n ▶ 场景 3B:用 HashTag 把脚本里访问的所有 key 框定")
good_keys = ["{rl:user:1001}:counter", "{rl:user:1001}:ts"]
show_keys_with_slot(good_keys)
slot = check_same_slot(good_keys)
print(f" ✅ 全部落在 slot {slot} → EVAL 可以正确执行")
# ──────────────────────────────────────────────────────────────
# Demo 4:HashTag 滥用 = 单节点热点
# ──────────────────────────────────────────────────────────────
def demo_hot_key(cl: FakeCluster) -> None:
section("Demo 4: HashTag 滥用 → 单节点热点(反面教材)")
print("\n ▶ 反面教材:所有 key 都用 {global} 做 tag")
bad_template = "{global}:counter:%s"
counters = [bad_template % i for i in range(1, 11)]
nodes_used = set()
for k in counters:
n = cl.set(k, "0")
nodes_used.add(n.name)
print(f" 示例 key:{counters[:3]} ... (共 {len(counters)} 个)")
print(f" ❌ 全部落到 {nodes_used} —— 集群退化成单机!其他节点闲置!")
print("\n ✅ 正确做法:HashTag 选「业务实体」维度")
good_template = "{user:%d}:counter"
user_counters = [good_template % i for i in range(1, 11)]
nodes_used2 = set()
for k in user_counters:
n = cl.set(k, "0")
nodes_used2.add(n.name)
print(f" 示例 key:{user_counters[:3]} ... (共 {len(user_counters)} 个)")
print(f" ✅ 散布到 {len(nodes_used2)} 个节点:{nodes_used2}")
print(" ↑ 不同 user 散到不同节点;同一 user 的多 key 仍能保持原子操作")
def main() -> None:
cl = FakeCluster()
print(">>> 用纯 Python FakeCluster 演示(无需真实 Redis)")
print(" 集群拓扑:")
for n in cl.nodes:
if n.role == "master":
print(f" {n.name} ({n.port}) slot {n.slot_range[0]:5}~{n.slot_range[1]:5}")
demo_mget(cl)
demo_transaction(cl)
demo_lua(cl)
demo_hot_key(cl)
print("\n" + "=" * 64)
print("✓ 全部 demo 完成。HashTag 是 Cluster 模式下「多 key 原子操作」的唯一正解,但要慎选 tag 维度。")
print("=" * 64)
if __name__ == "__main__":
main()01_crc16_slot.py ↗ · 02_cluster_client.py ↗ · 03_hashtag_demo.py ↗