Skip to content

第 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 16384

redis-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) 8104

10.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 ~ 16383

10.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 个槽给 B

10.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 次重定向,性能逼近单机

但是有两种「打脸」情况:MOVEDASK

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) 1

10.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 yes

10.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}:cartuser: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(槽)抽象

  1. 把 key 的 CRC16 值对 16384 取模,得到一个 0~16383 的 slot 编号;
  2. 集群手动或自动地把 16384 个 slot 分配给若干 master 节点(如 3 master:5461 / 5461 / 5462);
  3. 客户端持有「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. 一致性哈希在节点变化时仍有 1/N 数据需要迁移(且区间大小不可控);哈希槽可以「精确控制」迁移哪些槽——这是核心理由。
  2. 元信息紧凑(固定 16384 项 vs 增长的环结构)。
  3. 实现简单(取模查表 vs 二分查找环)。
  4. 运维友好(可手动指定 slot 归属)。
  5. 支持「定向操作」(HashTag)。

加分项:哈希槽是 antirez 借鉴 Couchbase vBucket 的设计;Memcached 主流客户端用一致性哈希。


Q4:MOVED 和 ASK 重定向的区别?

考察点:客户端路由细节。

标准答案

维度MOVEDASK
触发时机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 ↗