想象一下,你是城市里一家快递公司的调度主管。一开始,公司在老城区只有一个小仓库,所有包裹都堆在这里。你只需要一个青册子,把快递单号记下来,就能知道包裹存哪儿、该往哪儿送。这就是单机 Redis——简单高效,库存全在一个本本上。 可是城市越来越大,双十一来了!单日包裹从几万件暴增到几千万件。小仓库的货架被塞满,青册子厚得像砖头,几个老员工忙得脚不沾地、排队取件的客户快要打起来了。单机 Redis 的 CPU 冲到 100%,内存被打爆,请求大面积超时——你被老板一顿臭骂。 冷静下来,你琢磨出一个点子:与其把所有包裹塞进一个仓库,不如在城市里开七家分拨中心,每个分拨中心负责一个区。可是问题来了:一件包裹该发往哪个分拨中心?分拨中心之间怎么打个招呼确认“货在谁家”?万一某个分拨中心突然着火了,积压的包裹怎么办?客户怎么知道自己的包裹该去哪个分拨中心取? ——这就是 Redis Cluster 要解决的核心命题。 Redis Cluster 的答案是:把整个仓库划分成 16384 个“抽屉”(哈希槽),每个抽屉贴上一个编号;分拨中心(节点)之间靠“员工小纸条”(Gossip 协议)互相传递消息,盘点各自的抽屉;某家分拨中心失火后,大家开会重新指派负责人(故障转移)。而客户拿到一张“抽屉归属表”,一查就能直奔正确的分拨中心。这套机制,让 Redis 从“单打独斗”变成了“千军万马”。 这篇文章,我会带你从零搭建这套“快递分拨网络”。全程不讲艰深术语,只用快递公司的日常运维场景,把集群的每一颗螺丝都拧给你看。

岗位关联

对后端架构与缓存工程师而言,理解 Redis Cluster 是走向高并发分布式系统设计的必修课——它几乎是缓存层支撑海量流量的唯一标准方案。

目录
  1. 可视化演示
  2. 单机瓶颈
  3. 哈希槽原理
  4. Gossip协议
  5. 故障转移
  6. 客户端路由
  7. 集群扩缩容
  8. 架构总览
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Redis Cluster的16384哈希槽分片机制、Gossip协议节点通信与故障检测、客户端MOVED重定向路由的完整集群架构。

本节配有交互动效(进入视口后自动加载,约 3KB)。

哈希槽分片机制 16384个哈希槽均分到4个节点,键经CRC16哈希定位槽位 每个节点负责一段连续槽位范围,扩缩容只需迁移槽位 slot = CRC16(key) % 16384 哈希槽分片避免全局一致性哈希复杂性

单机瓶颈

快递公司的故事从老城区的小仓库开始。仓库虽好,但有一个硬伤:扩容只能靠“堆人堆货架”,也就是垂直扩容。你买更大的仓库、雇更多的员工、加更高的货架——但房租涨了、工资涨了,仓库总有一天会封顶。况且,小仓库只有一位“总调度”(单进程),他每天要记的包裹数量有限,CPU 在高峰期被压到 100% 后,连拆快递盒的动作都开始卡顿。

单机的第二个噩梦是单点故障。仓库里的青册子就是唯一的账本,一旦被老鼠咬烂,整个公司的数据灰飞烟灭。为了保险,你请来一位“记录员”(从节点)每晚抄写账本做备份,可一旦总调度倒地,客户依然只能焦急地等待你手动把记录员扶正——停机时间是按小时计算的。

这就是为什么我们需要 Redis Cluster。它不再把宝压在“一个仓库+垂直扩容”上,而是把地盘切成七块,开七家分拨中心(主节点),每家独立记账、独立处理客户请求。你想扩容?再開一家新分拨中心,把一部分“抽屉”(哈希槽)移交过去即可——这就是水平扩容。你家的总吞吐,从“一个仓库的天花板”,变成了“N 个仓库的叠加”。

用公式来看,单机的容量上限 $C$ 由内存 $M$ 和单核计算力 $P$ 决定:

而你需要的,正是这个 $C_{集群}$——快递总公司在七个区分拨中心同时运转后,吞吐量自然翻了好几番。故事先停在这里,接下来我们去看看分拨中心之间怎么划分地盘。

C单机=min⁡(M上限, P上限)C_{单机} = \min(M_{上限},\ P_{上限})
单机能力的上限,等于内存上限与 CPU 上限中较小的那个——就像快递仓库的吞吐,受限于最大的货架和最慢的员工。
  1. 内存上限 $M_{上限}$一台服务器的物理内存是固定的,比如 32GB,存不下再多的缓存数据——这是仓库的物理货架数量。
    M上限=32 GBM_{上限} = 32\ \text{GB}
  2. 计算力上限 $P_{上限}$单进程 Redis 只能用一个 CPU 核心,每秒最多处理约 10 万条命令——这是总调度一个人处理请求的速度天花板。
    P上限=100,000 qpsP_{上限} = 100{,}000\ \text{qps}
  3. 集群化后的总容量 $C_{集群}$当你有 $N$ 个节点,总容量近似线性增长:
    C集群≈N×min⁡(Mi,Pi)C_{集群} \approx N \times \min(M_i, P_i)

哈希槽原理

七家分拨中心开业了。可问题是:一件收件地址写着“幸福路 128 号”的包裹,究竟该送进 A 分拨中心还是 D 分拨中心?你当然可以靠人工判断——但遇到高峰,判断速度根本跟不上。你需要一套“机械规则”,让任何包裹都能在 1 微秒内自动对号入座。

Redis Cluster 的规则非常简单粗暴又优雅:把整个键空间切成 16384 个槽位。每个分拨中心负责其中一段连续的槽位区间,比如 A 中心负责 0-3000,B 中心负责 3001-6000……至于某个 key 属于哪个槽位,用哈希函数算一下即可:

CRC16 是一种能把任意字符串(快递单号)打散成 0 到 16383 之间数字的哈希算法。同样的 key 永远算到同一个槽。所有分拨中心汇总起来,就是全球唯一的“槽位归属表”——某包裹单号算出槽位 1024,查表发现槽位 1024 在 A 中心,那这个包裹就直接送 A 中心,谁也抢不走。

为什么是 16384 而不是更大的数字?这是设计者的平衡术:槽位表要在每个节点间用 Gossip 协议传播,槽位越多,消息体越大。16384 个槽位的元数据约 2KB,在保证足够细的分片粒度下,不会把网络带宽压垮。也许你还会问:为什么不是 65536?如果槽位太多,一则需要更大的信息包,二则从一个节点迁移一批数据到新节点需要传输更多批次,太重了。

回到快递故事:青册子升级成了“自动分拣机”,包裹传输带经过一个摄像头,扫一下单号,自动落进对应分拨中心的传送口。16384 个抽屉,每个抽屉的门牌号固定,无论包裹从哪个口进来,分拣员都永远不会搞错。

slot=CRC16(key) mod 16384slot = CRC16(key) \bmod 16384
16384⏟槽位总数=214⏟二进制14位表示\underbrace{16384}_{槽位总数} = \underbrace{2^{14}}_{二进制14位表示}
16384 正是 2 的 14 次方——既是够用的分片粒度,又能在元数据包恰到好处地装下整个槽位表。
  1. CRC16 计算对 key 做循环冗余校验,得到一个 16 位的校验值 $h$,这是把任意长度的单号压缩成一个固定位数的数字。
    h=CRC16(key)h = CRC16(key)
  2. 取模 16384用 $h$ 对 16384 取余,得到槽位编号 $s$。由于 $h$ 均匀分布,取余后的结果也均匀分布在整个槽空间上:
    s=h mod 16384s = h \bmod 16384
  3. 查表定位节点客户端(或者任意节点)维护一张“槽位到节点”的映射表 $T$,$T[s]$ 就是负责槽位 $s$ 的节点地址。于是地址为 true 的包裹单号算完槽位后直达对应节点。

Gossip协议

七家分拨中心分布在城市各区,各自独立运营。但有一个麻烦:A 中心怎么知道 D 中心的仓库还正常? B 中心怎么知道 E 中心快要爆仓了? 如果各个分拨中心之间完全没有沟通,碰到某家中心“着火停摆”,其他中心毫不知情——包裹继续往里送,泥牛入海。

Redis 的答案是:Gossip 协议——让分拨中心之间像八卦的同事一样闲聊。每个节点每秒随机选几个“同事”发送一条问候消息:“嗨,我是 A 中心,我还活着。顺便告诉你:我听到 B 中心好像不行了,D 中心新到了一批货……” 收到消息的人,也会把它听来的其他消息再转述给下一个人。消息像病毒一样在东家长李家短的闲聊中扩散开来,最后全城的人都知道了谁家好、谁家出了状况。

Gossip 协议有几个关键参数:

  • PING/PONG:每个节点每秒向随机节点发送 ping,对方收到后回一个 pong,确认活着。
  • Gossip 消息体:会携带一部分(约 1/10 的节点)状态信息,这样信息在多次传播后在全集群收敛——像“我听说他听说她听说……”的传话游戏,几轮后所有人对“谁家正常”达成一致。

这种“闲聊式”通信有个巨大好处:没有中心协调员。任何一家分拨中心倒下,不影响其他人继续互相传递消息。全集群的状态不是靠某个“总指挥部”下发指令,而是靠无数个小道消息汇聚成一张动态更新的八卦网——这就是无中心架构的魅力和健壮性。

ΔT收敛≈O(log⁡N)\Delta T_{收敛} \approx O(\log N)
一个状态消息传遍全集群,所需轮数与节点数 N 的对数成正比——传话的人越多,消息扩散得就越快,大约只需要几轮闲聊。
  1. 心跳频率 $f$节点每秒发送一次 ping,即 $f = 1Hz$。这是“同事每秒和你打个照面确认你还活着”。
    f=1 次/秒f = 1\ \text{次/秒}
  2. 传播轮数 $k$消息从节点 A 出发,每一轮随机传给一个邻居,传 $k$ 轮后覆盖全集群。由于每轮广播是指数扩散,$k$ 约等于节点数的对数:
    k≈O(log⁡N)k \approx O(\log N)
  3. 收敛时间 $\Delta T$由轮数乘以每轮时间得到,网络快时几乎瞬时完成:
    ΔT=k/f\Delta T = k / f

故障转移

某天凌晨三点,B 分拨中心的仓库突然着火了。火势暂时不大,但总调度员已经联系不上。此刻,负责 B 中心那批槽位的包裹还在源源不断地从传送带送来——怎么办?

Redis Cluster 的故障转移机制,像极了一场“火灾演习”。首先,A 中心的员工(节点)发现自己给 B 中心发的 ping 迟迟没有 pong 回复,等超过了 node-timeout(默认 15 秒),A 中心会偷偷在自己心中给 B 记上一笔:主观下线(PFAIL)。注意,这只是 A 的“个人判断”——也许 B 只是网络抖动、也许 A 自己看花眼了。所以 A 会在闲聊时把这条猜测告诉其他分拨中心:嘿,我怀疑 B 不行了,你们也看看?

当超过半数拥有主节点的分拨中心都认为 B 主观下线时,大家达成共识:客观下线(FAIL)。注意——不是 B 自己选的,也不是某个领导拍板的,而是“群众的声音”汇聚成了决议。接下来触发最重要的动作:选举与故障转移。

B 中心平时养着一批“实习副手”(从节点)。火灾发生后,副手们会向其他分拨中心拉票:“我服务质量好、数据最新,选我接管 B 的槽位吧!” 所有拥有主节点的分拨中心按照少数服从多数的原则投票,得票最多的副手成为新主节点——这个过程叫 Raft 式选举。一旦竞选成功,新主节点立刻接管 B 负责的全部哈希槽,对外广播新的槽位归属表。

火灾演练讲解完毕。核心思维是:故障转移不是某个节点说了算,而是整个集群的集体决策。这就解释了为什么分布式系统里“少数服从多数”如此重要——因为没有一个中心大脑来做出决策。

FAIL 确认条件:count(PFAILnode)≥Nmaster2+1\text{FAIL 确认条件:}\quad count(PFAIL_{node}) \ge \frac{N_{master}}{2} + 1
故障被正式确认,需要超过一半的主节点都认为该节点主观下线——像一场火灾灾情汇报,必须要多家分拨中心打电话确认属实,才能启动应急预案。
  1. 主观下线标记节点 A 发现与节点 B 的通信超时,标记 PFAIL(主观下线)。这是“我看见 B 状态可疑”的个人证词。
    PFAILA(B)←truePFAIL_A(B) \leftarrow \text{true}
  2. 客观下线判定当集群中主节点数量 $N_{master}$ 中,有超过一半都报告 PFAIL 时,状态升级为 FAIL(客观下线)。这是“群众证词”汇聚成实锤:
    count(PFAIL(B))≥Nmaster/2+1count(PFAIL(B)) \ge N_{master}/2 + 1
  3. 从节点晋级B 的从节点 $R_B$ 向拥有投票权的主节点发起竞选(类似竞选班长),获得多数票后成为新主节点:
    RB→选举获胜RB′(新主节点)R_B \xrightarrow{\text{选举获胜}} R'_B(\text{新主节点})

客户端路由

分拨中心正常运转了,新问题出现在客户 (客户端) 这边。客户小明要寄一个包裹,单号“SY-2024-1130”。他该把包裹送到 A 中心还是 D 中心?他当然可以先打电话问任意一家分拨中心:“这个单号归谁管?”但如果每件包裹都先问一次再投递,那传送带就堵死了——效率太低。

更聪明的做法是:让客户自己带一张“槽位归属小抄”。小明的手机 App(Redis 客户端)启动时先向任意节点要一份哈希槽分布表,然后本地缓存。之后每来一个 key,客户端本地算一下 CRC16 取模,直接查小抄找到正确的节点,直奔目的地——这就是 smart client(智能客户端)。

但分发中心也会搬仓库:比如扩容时槽位从 A 中心被迁移到 D 中心。小明的小抄可能过期了,他拿着旧地址跑向 A,A 检查发现槽位已经归 D 管,会回一句:MOVED 槽位 1024 请去节点 D——小明立刻更新小抄,把包裹转投 D,并“记下教训”下次不再跑错。这就是 MOVED 重定向。

有一种更微妙的场景:槽位正在从 A 迁移往 D 的半路,小明来了。此时 A 和 D 都管着一部分 1024 槽位的 key。A 会检查这个 key 是否已经迁走:如果还没迁,A 直接自己处理;如果已经迁走了,A 就回一句:ASK 槽位 1024 去 D 节点试试。注意,ASK 和 MOVED 看似相同,但含义不同:ASK 是“你这次去问 D 吧,但别忘了下次还来先问我”,因为它暗示迁移还没完成。

对客户端工程师来说,这一节的收获是:在你的客户端里,一定要处理 MOVED/ASK 重定向逻辑——很多新手只写了单机版本,迁移到集群后缓存一塌糊涂。记住,客户端不只是“发请求的工具”,它还承担着智能路由和故障感知的职责。

路由规则:{Tclient[slot]=node⇒直达MOVED⇒更新本地映射并重发ASK⇒仅本次转发到目标节点\text{路由规则:}\quad\begin{cases}T_{client}[slot]=node & \Rightarrow \text{直达} \\MOVED & \Rightarrow \text{更新本地映射并重发} \\ASK & \Rightarrow \text{仅本次转发到目标节点}\end{cases}
智能客户端加上重定向协议,就像客户拿着地图找仓库,地图旧了被纠正一次就更新,下次就再也不会走错路。
  1. 本地计算槽位客户端先算 key 的槽位,不打电话询问——这是小明先查手机地图,心里有数。
    slot=CRC16(key) mod 16384slot = CRC16(key) \bmod 16384
  2. 查本地映射表 $T_{client}$如果本地表里直接有该槽位的节点地址,发请求过去。
    nodetarget=Tclient[slot]node_{target} = T_{client}[slot]
  3. 收到 MOVED 时更新表节点把最新槽位映射回传,客户端更新 $T_{client}$。
    Tclient[slot]←nodenewT_{client}[slot] \leftarrow node_{new}

集群扩缩容

回到快递总部的故事。一年后,城市东区发展迅猛,包裹量暴增,E 分拨中心的仓库又塞满了。你面临两个需求:一是把 E 中心的货分流一批出来;二是开一家全新的 F 分拨中心。整个过程必须在“不打烊”的前提下进行——客户还在往 E 中心寄包裹,你总不能关门搬家吧?

Redis Cluster 的扩容是一套精致的“抽屉搬运术”。第一步,新分拨中心 F 装修完毕,带着一沓空抽屉编号加入集群,但还没分配任何槽位。接着,你从 E 中心划出 1000 个槽位,让 E 把这些槽对应的数据打包,批量“搬运”到 F。搬运过程不是一蹴而就的——F 每收到一个 key 就回执一个确认,两边同步进度。搬运期间,正在被搬动的 key 会收到客户端的 ASK 重定向,保证数据不丢失也不写错。

如果你只是想给 E 中心减负,还有其他办法:比如在 E 中心内部再挂一个从节点,让从节点分担一部分读流量。但请注意:从节点不分摊写流量——如果写入量大,必须靠迁移槽位来真正卸担子。扩容的本质,永远是“槽位的再分配和数据的再平衡”。

这套“抽屉搬运术”的精妙之处在于:整个扩容过程,客户端几乎无感知。你不需要停服,不用导数据,Redis Cluster 帮你在线上把所有抽屉均匀地分到了新仓库——这也是它能支撑业务快速扩张的底气所在。

数据迁移效率⏟每个key要序列化→传输→反序列化∝迁移线程数⏟通常设为8×单批数据量⏟通过 pipe 批量传输\underbrace{数据迁移效率}_{\text{每个key要序列化→传输→反序列化}} \propto \underbrace{迁移线程数}_{\text{通常设为8}} \times \underbrace{单批数据量}_{\text{通过 pipe 批量传输}}
槽迁移的速度,取决于并行迁移的线程数乘以每批的数据量——就像搬家时派出的卡车数量乘以每辆卡车的装载量。
  1. 发起迁移计划管理员执行 cluster setslot 1024 migrating E,标记槽位 1024 从 E 迁往 F。这是告诉你“这箱货准备出库了”。
    slot1024:E→F\text{slot}_{1024}: E \rightarrow F
  2. 批量搬运 keyE 中心通过 MIGRATE 命令把指定 key 的序列化数据发给 F,F 反序列化后写入自己的内存。每搬完一批,F 回复确认。
    MIGRATE(key,F)⇒F.set(key, val)\text{MIGRATE}(\text{key}, F) \Rightarrow F.\text{set}(\text{key, val})
  3. 槽位归属切换所有 key 搬完后,E 广播新表:槽位 1024 归 F。从此客户端再查询时,收到 MOVED 重定向并更新本地小抄。
    T[slot1024]=FT[\text{slot}_{1024}] = F

架构总览

快递公司的故事讲到这里,我们把七家分拨中心、16384 个抽屉、员工八卦网络和火灾演习串成一个全景:

  • 无中心化(P2P):所有节点地位平等,没有“总部数据库”;任何节点都可以接受客户端的查询和写入请求。正如每家分拨中心都能接待来访客户。
  • 数据与路由分离:数据按哈希槽分片,路由信息靠 Gossip 和重定向机制实时同步。客户端不必代理转发,而是直连节点。
  • 线性可扩展:集群总吞吐量 ≈ 节点数 × 单节点吞吐量。数据量增长时,加节点、搬槽位、同步状态,即可近乎线性提升集群能力。

从基础设施的视角看,Redis Cluster 是一个“自带高可用、自带水平扩展、自带路由”的分布式系统。它不依赖外部的负载均衡器、配置中心或仲裁服务,所有能力都内聚在节点自身——这是它被大规模分布式应用广泛采用的根本原因。

从快递公司到分布式系统,Redus Cluster 的架构智慧可以浓缩为一句话:把大问题切成小分片,让每个分片自主运转,再用一套轻量级协议把它们粘合成一个整体。至此,你已经从零理解了 Redis Cluster 的全部核心机制。

总吞吐QPS⏟集群化后的能力=N⏟节点数×QPS单节点⏟单机吞吐\underbrace{总吞吐 QPS}_{集群化后的能力} = \underbrace{N}_{\text{节点数}} \times \underbrace{QPS_{单节点}}_{\text{单机吞吐}}
集群能力的上限,近似等于节点数量乘以单个节点的吞吐——前提是哈希槽分配均匀,各个节点负载均衡。
  1. 节点数 $N$集群中主节点的个数,每增加一个主节点,整体并发能力也近乎线性增加。
    N=主节点数量N = \text{主节点数量}
  2. 单点吞吐 $QPS_{单节点}$单个 Redis 进程每秒能处理的命令数,通常取决于 CPU 核心和内存带宽。
    QPS单节点≈10万QPS_{单节点} \approx \text{10万}
  3. 总吞吐 $QPS_{集群}$当槽位分布均匀且没有热点时,集群总吞吐约等于:
    QPS集群=N×QPS单节点QPS_{集群} = N \times QPS_{单节点}

AI 应用场景

电商大促的缓存层水平扩展

阶段:模型服务上线后的大规模推理结果缓存

解决的问题:大促期间商品推荐模型的推理结果(如“猜你喜欢”列表)需要高频读取。单机 Redis 在峰值流量下 CPU 100%、内存溢出,导致推荐接口大面积超时。传统方案是买更高配服务器,但成本和天花板都不可接受。

具体案例:某头部电商在双十一前,将推荐缓存从单机 Redis 迁移到 12 节点 Redis Cluster,按用户 ID 哈希分片存储推荐结果。峰值 QPS 从 80 万提升至 700 万。当某节点因流量洪峰疑似故障时,Gossip 探测自动触发故障转移,备用节点 5 秒内接管,用户无感知。整个扩缩容基于在线槽迁移,无需停机。

分布式 AI Agent 的对话状态管理

阶段:大模型多轮对话中的会话状态与上下文存储

解决的问题:大规模 AI Agent 应用中,每个用户的对话上下文(session state)需要快速存取。单机 Redis 无法支撑千万级用户并发会话;同时 Agent 系统需要高可用,不允许缓存层单点故障导致整个 Agent 崩溃。

具体案例:某智能客服 SaaS 平台使用 Redis Cluster 存储用户会话状态(key 为 session_id,value 为对话摘要向量和上下文窗口)。通过哈希槽自动将不同用户的会话均匀分布到集群节点,单点故障由从节点秒级接管,平台整体可用性从 99.5% 提升到 99.99%。

Python 演示

python131 行
import hashlibimport randomimport time class ClusterNode:    def __init__(self, node_id, address):        self.node_id = node_id        self.address = address        self.data = {}        self.slots = []        self.alive = True        self.replica_of = None  # 如果是从节点,记录主节点 node_id     def get_slot_count(self):        return len(self.slots)     def add_slot(self, slot):        self.slots.append(slot)     def read_key(self, key):        return self.data.get(key, None)     def write_key(self, key, value):        self.data[key] = value  class SimpleRedisCluster:    SLOT_COUNT = 16384    SLOT_TABLE = {}  # slot -> node_id     def __init__(self, masters=3, replicas_per_master=1):        self.nodes = {}        self.masters = masters        self._build_nodes(masters, replicas_per_master)        self._assign_slots_round_robin()        self._bind_replicas()     def _build_nodes(self, masters, replicas):        for i in range(masters):            node = ClusterNode(f"M{i}", f"10.0.0.{i+1}:7000")            self.nodes[node.node_id] = node        for i in range(masters):            for r in range(replicas):                rep = ClusterNode(f"R{i}_{r}", f"10.0.1.{i+1}:7{r}")                rep.replica_of = f"M{i}"                self.nodes[rep.node_id] = rep     def _assign_slots_round_robin(self):        slot = 0        while slot < self.SLOT_COUNT:            for node_id in list(self.nodes):                if node_id.startswith("M"):                    self.nodes[node_id].add_slot(slot)                    SimpleRedisCluster.SLOT_TABLE[slot] = node_id                    slot += 1                    if slot >= self.SLOT_COUNT:                        break     def _bind_replicas(self):        for node in self.nodes.values():            if node.replica_of:                pass  # 简化:replica 已与主节点建立关联     def _hash_slot(self, key):        digest = hashlib.sha256(key.encode("utf-8")).hexdigest()        return int(digest[:8], 16) % self.SLOT_COUNT     def write(self, key, value):        slot = self._hash_slot(key)        node_id = SimpleRedisCluster.SLOT_TABLE[slot]        node = self.nodes[node_id]        if not node.alive:            raise RuntimeError(f"Node {node_id} is DOWN, cluster unavailable")        node.write_key(key, value)        return (slot, node_id)     def read(self, key):        slot = self._hash_slot(key)        node_id = SimpleRedisCluster.SLOT_TABLE[slot]        node = self.nodes[node_id]        return node.read_key(key)     def simulate_failover(self, failed_node_id):        failed_node = self.nodes[failed_node_id]        if failed_node.replica_of:            raise ValueError("只能让主节点故障")        master_id = failed_node.node_id        # 找到第一个从节点,晋升为新主节点        new_master = None        for node in self.nodes.values():            if node.replica_of == master_id and node.alive:                new_master = node                break        if new_master is None:            raise RuntimeError("没有可用从节点,无法故障转移")        # 移交槽位        for slot, owner in list(SimpleRedisCluster.SLOT_TABLE.items()):            if owner == master_id:                SimpleRedisCluster.SLOT_TABLE[slot] = new_master.node_id                new_master.add_slot(slot)        # 数据同步:从节点的 data 与主节点一致        new_master.data = dict(failed_node.data)        new_master.replica_of = None        failed_node.alive = False        print(f"[FAILOVER] {new_master.node_id} 接管了 {master_id} 的槽位")  def main():    cluster = SimpleRedisCluster(masters=3, replicas_per_master=1)    print(f"集群节点数: {len(cluster.nodes)},槽位总数: {SimpleRedisCluster.SLOT_COUNT}")    for node in cluster.nodes.values():        if node.node_id.startswith("M"):            print(f"主节点 {node.node_id} 负责槽位: {len(node.slots)} 个")     keys = ["user:1001", "session:abc", "cart:888", "order:2024"]    for k in keys:        slot, node = cluster.write(k, "v")        owner = SimpleRedisCluster.SLOT_TABLE[slot]        print(f"写入 {k} -> 槽位 {slot} -> 节点 {owner}")     print("\n模拟 M0 节点故障...")    cluster.simulate_failover("M0")    for k in keys:        val = cluster.read(k)        slot = cluster._hash_slot(k)        owner = SimpleRedisCluster.SLOT_TABLE[slot]        print(f"读取 {k} -> 槽位 {slot} -> 节点 {owner} -> {val}")  if __name__ == "__main__":    main()
目的:演示 Redis Cluster 的哈希槽分片路由和故障转移核心逻辑——从 key 到槽位到节点,当主节点故障时,从节点自动接管槽位和数据。
  1. SimpleRedisCluster.SLOT_COUNT = 16384定义集群的槽位总数,对应 Redis Cluster 的真实规格
  2. def _assign_slots_round_robin()轮询给每个主节点分配槽位,模拟集群初始化时的槽位分配过程
  3. def _hash_slot(self, key)用 SHA256 哈希并取模得到槽位编号(真实 Redis 用 CRC16)
  4. SLOT_TABLE[slot] = node_id维护“槽位到节点”的全局映射表,客户端路由靠查这张表
  5. def simulate_failover(self, failed_node_id)模拟主节点故障时,从节点晋升为新主节点并接管全部槽位
  6. new_master.data = dict(failed_node.data)简化了复制同步,真实场景中从节点通过主从复制持续同步数据
  7. if __name__ == "__main__":标准 Python 入口,保证脚本可直接运行

常见误区

误区我之前看别的系统,比如一致性哈希,它们都有虚拟节点,所以我觉得 Redis Cluster 的 16384 个哈希槽也是一样的东西,是用来解决数据倾斜问题的虚拟节点。

正解哈希槽和虚拟节点是两回事。一致性哈希的虚拟节点是为了让节点在哈希环上分布更均匀;而 Redis Cluster 的哈希槽是整个集群的固定分片粒度,每个槽是真实存在的逻辑容器,节点只是槽的“持有者”。槽的数量是固定的(16384),与节点数量无关;虚拟节点数量通常随物理节点调整。数据倾斜问题的解决,是靠把槽均匀分配给各节点。

为何常见:因为两者都是“在 key 和节点之间加了一层映射”,表面结构相似,容易混淆。

误区我的客户端连上集群后,偶尔会收到 MOVED 错误,是不是我配置写错了?还是集群坏了?我要不要每次收到 MOVED 就立刻重新连接?

正解收到 MOVED 是集群中的正常现象,尤其在槽位迁移、节点扩缩容后。它实际上是客户端更新本地路由表的信号——正如快递员送错仓库后,被纠正一次下次就记住一样。正确的做法是:解析 MOVED 响应中的目标节点地址和槽位号,更新本地映射表后重发请求。如果每次都重新连接,反而会带来大量无谓的连接开销。

为何常见:单机 Redis 没有重定向的概念,习惯了“一个连接走天下”的思维后,遇到重定向错误自然会恐慌。

误区Redis Cluster 号称高可用,那主节点挂了从节点应该毫秒级顶上,用户完全无感知吧?我看那些云厂商都说“秒级切换”。

正解从主节点故障到从节点接管,通常需要 10-30 秒。因为故障检测本身需要等待 node-timeout(默认 15 秒)触发主观下线,然后再通过 Gossip 汇聚成客观下线,之后还要进行选举和槽位接管。如果你在业务代码里没有做重试和熔断,这十几秒的高峰期请求就会大规模超时。云厂商的“秒级”通常是指探测到故障到切换完成的时间,不包含主观下线的等待期。

为何常见:人们对“高可用”的理解往往过于理想化,以为像硬件热备一样瞬间切换,忽略了分布式系统检测故障需要时间。

误区集群写入太慢了,我加几个从节点,读写分离,让从节点分担写压力,性能不就上去了吗?

正解Redis Cluster 的从节点是异步复制主节点的数据,它不承担写请求。写入必须由主节点处理。加从节点只能提升读吞吐和容错性(故障转移时有备用力量),不能提升写性能。想让写性能随集群“线性扩展”,唯一的办法是增加主节点并迁移部分槽位过去,让写入请求分散到更多主节点。

为何常见:受 MySQL 主从读写分离的思维惯性影响,默认以为从节点能分担所有类型的压力。

面试题

Q1. 在 Redis Cluster 中,数据分片依赖的核心结构是什么?

单选题 · easy

  • A.一致性哈希环
  • B.16384 个哈希槽← 正确答案
  • C.跳表索引
  • D.位图数组

答案:B。Redis Cluster 用 16384 个哈希槽作为分片的基本单元,key 先哈希到槽,槽再映射到节点。一致性哈希环是其他系统(如 Memcached 客户端)的方案;跳表是 Redis 内部有序集合的实现,与分片无关;位图数组是布隆过滤器的数据结构。

Q2. 一个 key "session:9527" 要确定它属于哪个节点,正确的计算过程是?

单选题 · mid

  • A.对 key 做 MD5 哈希,再取模节点数量
  • B.对 key 做 CRC16 哈希,结果对 16384 取模,再查槽位映射表← 正确答案
  • C.对 key 求 ASCII 码之和,再取模节点数
  • D.直接向所有节点广播查询

答案:B。Redis Cluster 的定槽公式是 slot = CRC16(key) % 16384,然后再查“槽位→节点”映射表定位。取模节点数量的做法是一致性哈希的思路,不是 Cluster 的做法;ASCII 码之和碰撞概率极高;广播查询是最低效的笨办法。

Q3. 当客户端访问一个正在从 A 节点迁移到 B 节点的槽位时,A 节点返回以下哪种响应?

单选题 · mid

  • A.MOVED
  • B.ASK← 正确答案
  • C.PONG
  • D.ERROR

答案:B。当槽位正在迁移过程中,A 节点会返回 ASK,告诉客户端“这次请去 B 节点试试”。如果迁移已经完成、槽位正式归属 B,则返回 MOVED。PONG 是 Gossip 心跳的响应;ERROR 是通用错误。

Q4. 关于 Gossip 协议,以下哪个描述是准确的?

单选题 · mid

  • A.节点间通过中心协调器同步状态
  • B.每个节点定期和所有其他节点交换完整状态
  • C.节点随机选择部分节点交换元数据,状态最终收敛← 正确答案
  • D.使用 Redis 主从复制协议传递槽位信息

答案:C。Gossip 协议的核心是“随机挑选节点传播消息”,消息在数次传播后传遍全集群,达到最终一致。它不依赖中心协调器;也不与所有节点交换完整状态,那样带宽开销太大;主从复制协议用于数据同步,不用于集群元数据传播。

Q5. 一个主节点发生故障,从节点要接管槽位,触发接管前必须满足什么条件?

单选题 · mid

  • A.从节点自行决定接管
  • B.超过半数主节点确认该节点客观下线← 正确答案
  • C.客户端发起重连请求
  • D.管理员手动执行 failover 命令

答案:B。Redis Cluster 的故障转移必须经过“主观下线(PFAIL)→ 客观下线(FAIL)→ 选举”三部曲。客观下线要求超过半数主节点都确认该节点 PFAIL,然后从节点才能发起竞选。从节点不能自行决定;客户端重连和手动 failover 命令都不是自动触发的前提。

Q6. 关于 Redis Cluster 的客户端路由,smart client(智能客户端)的核心优势是什么?

单选题 · easy

  • A.不需要与集群节点建立连接
  • B.缓存槽位映射表,直连目标节点,减少转发开销← 正确答案
  • C.自动为所有 key 添加前缀做分片
  • D.每次请求先向所有节点广播

答案:B。智能客户端在本地缓存“槽位→节点”映射表,算完哈希槽后直接连接目标节点,省去了中间代理或重定向的开销。它仍然需要与节点建立 TCP 连接;不修改 key;不广播请求。

Q7. 当集群扩容加入一个新节点时,以下哪个说法是正确的?

单选题 · mid

  • A.新节点加入后自动从其他节点抢夺一半数据
  • B.需要手动迁移部分哈希槽到新节点← 正确答案
  • C.新节点自动成为所有主节点的从节点
  • D.新节点只承担读请求

答案:B。新增节点后,它初始不拥有任何槽位。管理员需要通过 CLUSTER SETSLOT 和 MIGRATE 命令把部分槽位迁移过去,不是自动发生的。新节点不会自动抢数据,也不会自动变成从节点,更不是只读节点。

Q8. 关于 Redis Cluster 故障转移过程中,以下哪些说法是正确的?

多选题 · hard

  • A.从节点晋升为新的主节点← 正确答案
  • B.旧主节点恢复后自动成为新主节点
  • C.故障期间该节点的哈希槽写入会暂时不可用← 正确答案
  • D.从节点需要获得超过半数主节点的投票← 正确答案

答案:ACD。故障转移的目标是从节点晋升为主节点,因此 A 正确。旧主节点恢复后会以从节点身份重新加入,不会自动抢回主节点身份,B 错误。故障检测需要等待 node-timeout,期间写入不可用,C 正确。从节点竞选需要超过半数主节点投票(Raft 式选举),D 正确。

Q9. 下列哪些措施可以提升 Redis Cluster 的写吞吐量?

多选题 · mid

  • A.增加从节点数量
  • B.增加主节点数量并迁移槽位← 正确答案
  • C.将不同 key 均匀分布到不同槽位← 正确答案
  • D.增加单节点的 CPU 核心数

答案:BC。增加主节点并迁移槽位,可以让写请求分散到更多节点,提升总吞吐量;key 均匀分布能避免热点集中在少数节点。增加从节点不能分担写请求(从节点异步复制主节点数据);增加单节点 CPU 核心数也不能提升单进程 Redis 的能力,因为 Redis 是单线程处理命令的。

Q10. 下面哪些属于 Redis Cluster 解决的问题?

多选题 · easy

  • A.单节点内存容量有限← 正确答案
  • B.单节点 CPU 计算能力有限← 正确答案
  • C.单节点故障导致服务不可用← 正确答案
  • D.单机 Redis 的持久化文件损坏

答案:ABC。Redis Cluster 通过水平扩展解决容量和计算力上限问题,通过主从节点和故障转移机制解决单点故障问题。持久化文件损坏是单机层面的故障,与集群架构无关——集群是解决“资源不够用”,不是解决“物理磁盘坏道”。