大促前一周,老张的后端团队收到运维的告警邮件:主Redis内存使用率已经冲到92%,QPS峰值逼近单机极限。加内存?云厂商最大规格已经顶到天花板了。上主从+哨兵?那只能解决高可用,解决不了容量问题。
老张把团队叫到白板前,画了一个圈:“我们需要把数据拆开存,一个Redis扛不住,就让多个Redis一起扛。”
这就是 Redis Cluster 的起点。但把几个 Redis 实例凑到一起,并不等于就有了一个真正的集群。数据怎么分片?节点如何发现彼此?发生故障时如何处理?扩容要不要停服?Redis Cluster 的设计回答了这些核心问题,而其答案背后蕴含的,是一套可以被迁移到其他任何分布式系统的通用架构哲学。

一、数据怎么分——哈希槽的设计智慧
第一个问题最直观:一条数据来了,应该存到哪个 Redis 节点?
1.1 最朴素的方案:节点取余
最直接的思路是:hash(key) % N,N 是节点数。三个节点,余数为 0 存第一个,余数为 1 存第二个,以此类推。
这个方案的问题出在 N 变化的时候。假如从 3 个节点扩到 4 个,N 从 3 变成 4,几乎所有 key 的取余结果都会变。原本在节点 1 的数据,现在可能被路由到节点 2。这意味着扩容时,几乎全部数据都要迁移。数据量大时,迁移时间以小时甚至天计,服务根本无法使用。
有人会说,翻倍扩容不行吗?从 3 个节点扩到 6 个,确实有一半数据不需要动。但这只是把问题推迟了,没有根治。而且现实中扩容往往是根据业务增长按需进行的,哪有那么多“刚好翻倍”的机会?
1.2 改进方案:一致性哈希
一致性哈希的思路是:把节点和数据都映射到一个 0 到 2³²-1 的哈希环上,每个 key 顺时针找到的第一个节点就是它的归属。
这个方案最大的好处是节点增减时,只影响相邻的节点。加入一个新节点,只需要从它顺时针方向的下一个节点那里接管一部分数据,其他节点的数据完全不受影响。迁移量从几乎全部降到了一小部分。
但一致性哈希也有问题。节点数量少的时候,节点在环上的分布可能很不均匀,导致某些节点分到的数据特别多,形成热点。解决方法是引入虚拟节点——每个物理节点对应环上的多个虚拟点,但这又增加了实现的复杂度。
1.3 Redis 的选择:哈希槽
Redis 没有选择一致性哈希,而是引入了一个中间层:哈希槽(Hash Slot)。
具体做法是:预先划分 16384 个槽,key 通过 CRC16 算法计算后对 16384 取模,决定它属于哪个槽。每个节点负责一部分槽。这样一来,key 和节点之间被槽这一层隔开了。
这层隔离带来了巨大的灵活性。扩容时,不需要重新计算每个 key 的去向,只需要把一部分槽从老节点迁移到新节点。槽的数量永远是 16384,key 到槽的映射关系永远不会变。变化的只是槽到节点的映射。
对比一下三种方案:
| 方案 |
扩容成本 |
负载均衡 |
实现复杂度 |
| 节点取余 |
几乎全量迁移 |
均匀 |
最简单 |
| 一致性哈希 |
只影响相邻节点 |
依赖虚拟节点 |
中等 |
| 哈希槽 |
只迁移指定槽 |
天然均匀 |
较低 |
哈希槽把 key → 节点 拆成了 key → 槽 → 节点。这个“加一层”的套路,在分布式系统中反复出现——用一层稳定的抽象,隔离变化的部分。key 到槽的关系是稳定的,变化的是槽到节点的关系。
1.4 为什么偏偏是 16384?
这个问题很多人问过。CRC16 产生的哈希值范围是 0 到 65535,为什么不直接用 65536 个槽?为什么选了一个看起来不完整的 16384?
答案藏在心跳包里。
在 Redis Cluster 中,节点之间会定期发送 PING/PONG 心跳消息,这些消息里携带着每个节点所负责的槽位信息。槽位信息是以 bitmap 的形式传输的:16384 个槽,就需要 16384 个 bit,换算下来是 2048 字节,也就是 2KB。而如果用 65536 个槽,bitmap 会膨胀到 8KB。
每个节点每秒都要发送多次心跳包,2KB 和 8KB 的差距在节点数多的时候会被放大。更何况 Gossip 协议的心跳包里还会携带多个其他节点的信息,如果每个节点的槽位 bitmap 都是 8KB,网络带宽会被心跳消息吃掉一大块。
另一个考量是集群规模。Redis 官方不建议 Cluster 节点数超过 1000 个。16384 除以 1000,每个主节点大约分到 16 个槽,这个粒度足够精细了。如果槽太少,比如 1000 个节点只有 1000 个槽,每个节点只负责 1 个槽,那哈希槽就退化成了普通的节点取余,失去了灵活性。如果槽太多,心跳包又太重。
所以 16384 是在消息大小和分配粒度之间找到的平衡点。一个看似随意的数字,背后是对性能、网络和集群规模的综合权衡。
二、节点怎么认识彼此——Gossip 协议的去中心化哲学
数据分片的问题解决了,新的问题来了:几十上百个 Redis 节点,怎么知道整个集群里都有谁?谁活着?谁负责哪些槽?
2.1 中心化方案的困境
最直观的方案是搞一个中心节点,比如用 ZooKeeper 或 etcd 做注册中心。所有 Redis 节点向它汇报自己的状态,也从它那里获取集群拓扑。
这个方案的问题是,中心节点本身就成了单点。你为了高可用又得给 ZooKeeper 搞集群,运维复杂度直接翻倍。而且所有心跳汇聚到中心节点,在大规模集群下中心节点的带宽和处理能力会成为瓶颈。
Redis 的设计哲学是简单而强大,不想引入外部依赖。所以它选择了一条不同的路:去中心化,用 Gossip 协议自己管自己。
2.2 Gossip 协议是怎么工作的
Gossip 协议的思路可以类比消息传播:一个人把消息告诉三个朋友,每个朋友再告诉三个朋友,很快所有人都知道了。
在 Redis Cluster 中,每个节点都有一个专门的集群总线端口(服务端口 +10000),通过这个端口与其他节点通信。通信的消息类型有四种:MEET(邀请新节点加入)、PING/PONG(心跳和信息交换)、FAIL(宣告某节点挂了)。
具体来说,每个节点每秒执行 10 次集群维护任务,每次会选择一个最久没有收到 PONG 的节点发送 PING 消息。PING 消息里携带两部分内容:一是发送节点自己的信息,二是它已知的集群中 1/10 其他节点的信息(至少 3 个,最多总结点数 -2 个)。收到 PING 的节点回复 PONG,PONG 里同样携带自己的信息和已知的其他节点信息。
这样一来一回,节点之间的信息就在整个集群中扩散开来了。一个新节点加入后,只需要通过 MEET 消息跟任意一个现有节点握手,它的信息就会通过 Gossip 协议逐步传播到所有节点。一个节点挂了,发现它超时的节点会通过 PING/PONG 把故障信息传播出去,最终所有节点都会知道。
2.3 去中心化的代价与回报
Gossip 协议最大的特点是:不追求强一致性,接受短暂的不一致,换来极致的扩展性和容错性。
你往集群里加一个节点,不是所有节点瞬间就知道,需要几轮 PING/PONG 才能收敛。但这个收敛时间很短,而且即使有节点还没感知到新节点,最多也就是把请求发给老节点、收到一个 MOVED 重定向——不影响服务的正确性。
去中心化的另一个好处是,集群没有单点。任意一个节点挂了,其他节点照常工作,Gossip 协议会把这个消息传播出去,触发故障转移。
这就是 CAP 定理的经典实践。Redis Cluster 选择了 AP——可用性 + 分区容错,牺牲了强一致性。
三、主节点挂了怎么办——故障检测与自动转移
去中心化通信有了,下一个问题:一个主节点真的挂了,谁来判断?怎么判断?谁来接替?
3.1 每个分片都是一个小主从
Redis Cluster 的每个分片都配置了主从复制。一个主节点配 1 到 N 个从节点,从节点实时复制主节点的数据。主节点负责读写,从节点待命。主节点一挂,从节点顶上。
3.2 两阶段故障检测:主观下线 → 客观下线
判断一个节点到底挂没挂这件事,在分布式系统中必须谨慎。网络抖动可能导致某个节点暂时不可达,但它其实还活着。如果一收到超时就判定死亡、立即切换,整个集群会陷入频繁的假死切换混乱中。
Redis Cluster 采用了两阶段判定。
第一阶段叫主观下线(PFAIL) 。节点 A 定期向节点 B 发 PING,如果在 cluster-node-timeout 时间内没收到 PONG,节点 A 就在本地把节点 B 标记为 PFAIL。注意,“主观”意味着这只是 A 的一家之言,不代表 B 真的挂了。可能是 A 和 B 之间的网络有问题,也可能是 A 自己的问题。
第二阶段叫客观下线(FAIL) 。A 标记 B 为 PFAIL 后,这条信息会通过 Gossip 协议的 PING/PONG 消息在集群中传播。其他节点收到后,会更新自己本地记录的“有多少节点认为 B 挂了”的计数器。当持有槽的半数以上主节点都认为 B 处于 PFAIL 状态时,集群将 B 标记为 FAIL——客观下线。
为什么要半数以上持有槽的主节点?因为只有持有槽的主节点才真正参与数据服务,它们的意见才是有效的。从节点只复制数据,不参与决策。半数以上则是为了防止网络分区导致的“脑裂”——假如集群被网络切成两半,任何一半都不可能凑齐半数以上主节点的同意,自然无法触发故障转移,避免了出现两个主节点同时写数据的数据错乱。
3.3 故障转移流程
节点 B 被标记为 FAIL 后,如果 B 是主节点,它的所有从节点会发起选举。每个从节点根据自己从主节点复制到的数据偏移量决定“谁最有资格”——数据最新的那个从节点会率先发起选举请求。
它需要获得集群中半数以上持有槽的主节点的投票。当选票足够后,这个从节点执行 slaveof no one,把自己晋升为新主节点,然后接管原主节点的所有槽位,并通过 Gossip 广播新的槽位归属信息。
整个过程全自动,运维不需要半夜爬起来手动切主。自动故障转移的价值不仅在于省人力,更在于响应速度——从检测到切换完成通常在一分钟以内。
四、客户端怎么找到正确的节点——MOVED 与 ASK
集群内部的事搞清楚了,剩下一个最直接的问题:客户端发请求时,发给哪个节点?
4.1 智能客户端的设计
Redis Cluster 采用“智能客户端”模式。客户端本地缓存一份“槽 → 节点”的映射表。发请求时,先对 key 做 CRC16 计算,得到槽号,然后查映射表,把请求直接发给对应的节点。
映射表怎么来的?客户端启动时,连接任意一个集群节点,执行 CLUSTER SLOTS 命令,一次性拉取完整的槽位分布信息。
4.2 MOVED 重定向:槽搬家了
但槽的分布是会变化的——扩容、缩容、故障转移都会导致槽换主人。当客户端按照本地缓存把请求发到节点 A,但节点 A 发现自己已经不再负责这个槽时,它会返回一个 MOVED 错误:
MOVED 5000 192.168.1.2:6379
意思是:“槽 5000 已经永久迁移到 192.168.1.2 了,你去那边。”
客户端收到 MOVED 后,会更新本地的槽位映射表,然后向新节点重发请求。智能客户端的核心价值就在这里——通过 MOVED 机制动态更新路由表,后续请求就不会再走错路。
4.3 ASK 重定向:槽正在搬家
还有一种更微妙的情况。槽迁移不是瞬间完成的,迁移过程中,部分 key 还在旧节点,部分 key 已经到了新节点。这时候客户端请求来了,旧节点发现这个 key 已经搬走了,但槽的所有权还没正式转移,它该怎么处理?
答案是返回 ASK 重定向:
ASK 5000 192.168.1.3:6379
ASK 和 MOVED 有一个本质区别:收到 ASK 后,客户端只会临时向新节点重试这一次请求,不会更新本地的槽位映射表。因为槽 5000 的正式所有者仍然是旧节点,只是这个特定的 key 被提前搬走了。迁移完成后,旧节点会再次发送 MOVED,那时客户端才需要更新缓存。
MOVED 是永久改地址,ASK 是临时借地址。 这个区分看起来很细,但它的价值很大:它让槽迁移这个复杂的在线操作对客户端几乎是透明的。迁移过程中,服务不停,客户端感知到的只是一次临时的重定向。
五、集群变大了怎么办——在线扩容的渐进式哲学
业务在增长,集群要加节点。能做到不停服吗?这是 Redis Cluster 的又一亮点。
5.1 扩容的本质是槽位重分配
扩容不是在集群上加个新节点然后重新计算所有 key——那样的话数据要全量迁移,代价太高。扩容的本质是把一部分槽从老节点搬到新节点。因为槽是固定的 16384 个,只需要搬槽,不需要重算 key。
5.2 渐进式迁移:边搬边服务
Redis 的槽迁移是渐进式的,核心是三个状态:NODE(正常)、MIGRATING(迁移中)、IMPORTING(导入中)。
迁移一个槽时,首先把源节点的这个槽标记为 MIGRATING,目标节点的对应槽标记为 IMPORTING。然后逐个把槽里的 key 从源节点迁移到目标节点。
迁移过程中,对于读写请求的处理是这样的:
- 源节点收到请求,先检查 key 是否还在本地。如果在,正常处理。如果已经迁移走了,返回 ASK 重定向,让客户端去找目标节点。
- 目标节点收到请求,正常情况下它不负责这个槽(因为槽的所有权还在源节点),会直接拒绝。但如果客户端在请求前先发送了
ASKING 命令,目标节点就会“破例”处理这一次请求。
这个设计非常精妙。迁移过程中,槽在两端都有服务能力:源节点继续服务还没搬走的数据,目标节点临时服务已经搬过来的数据。客户端配合 ASK 重定向在两者之间灵活切换。
当槽内的所有 key 都迁移完成后,源节点和目标节点通过 Gossip 广播:这个槽的所有权正式转移到目标节点。此后,所有对这个槽的请求都应该发给目标节点,老请求如果还到源节点,会收到 MOVED 重定向。
5.3 为什么能不停服
因为整个迁移过程是增量的,每一步都只涉及一小部分数据。迁移过程中,服务从未中断。最坏情况下,客户端遇到一次 ASK 重定向,多一次网络往返。
这种把大动作拆成小步骤的思想,是分布式系统在线变更的通用模式——先标记状态、再迁移数据、最后更新元数据。每一步都可控、可回滚。这个思路可以迁移到数据库分库分表、ES 索引重建等很多场景。
六、Redis Cluster 不是银弹——它的代价与局限
任何架构设计都有取舍。Redis Cluster 换来了水平扩展和去中心化,同时也付出了一些代价。
多键操作受限。MSET、MGET、事务、Lua 脚本,这些操作如果涉及多个 key,必须保证所有 key 落在同一个槽。原因是跨槽意味着跨节点,Redis 不做分布式事务,跨节点的原子操作不在它的设计目标内。
解决方案是使用 hash tag。在 key 中加入大括号,比如 {user:1001}:profile 和 {user:1001}:orders,Redis 只会对大括号内的部分计算哈希。这样,同一个用户的多个 key 就能落到同一个槽,也就落在同一个节点上了。
只支持一个数据库。单机 Redis 可以用 SELECT 切换 0-15 号数据库,但 Cluster 只能用 db0。这其实不算大问题,因为多数据库在实际生产中使用极少,而且跨库操作同样面临分布式事务问题。
数据一致性的妥协。Redis 的主从复制是异步的。主节点写入成功后立刻回复客户端,然后才异步同步给从节点。这意味着如果主节点在同步完成前宕机,这部分写入可能永久丢失。这是 Redis 选择高性能而牺牲强一致性的结果。对于大多数缓存场景,这点数据丢失可以接受;但如果 Redis 被当作数据库使用,就需要在业务层做好容忍设计。
运维复杂度上升。节点从几个变成几十个后,监控、备份、故障排查的复杂度成倍增加。好在这几年云厂商的托管服务越来越成熟,自动运维工具也在进步,这个问题的门槛在降低。
完整对比一下几种 Redis 部署方案:
| 方案 |
容量扩展 |
高可用 |
客户端复杂度 |
多键操作 |
运维复杂度 |
| 单机 |
垂直受限 |
无 |
低 |
全支持 |
低 |
| 主从+哨兵 |
垂直受限 |
自动切换 |
低 |
全支持 |
中 |
| 客户端分片 |
水平扩展 |
需自建 |
高 |
受限于分片规则 |
高 |
| Redis Cluster |
水平扩展 |
自动切换 |
中(需支持重定向) |
仅同槽 |
中 |
写在最后
大促结束后的复盘会上,老张在白板上写下五个词:哈希槽、Gossip、两阶段故障检测、重定向、渐进式迁移。这五个词对应了分布式系统必须回答的五个问题。
数据怎么分,用哈希槽,中间加一层,把 key 和节点解耦开。节点怎么通信,用 Gossip,消息自己传,不靠中心节点。故障怎么判,两阶段检测,先主观后客观,多数主节点说了算。客户端怎么找对节点,MOVED 处理永久迁移,ASK 应付临时搬迁。集群怎么伸缩,渐进式迁移,槽一个一个挪,边搬边服务。
这些东西,你换成 Kafka、换成 Elasticsearch、换成任何分布式系统,思想都是通的。哈希槽本质是增加间接层解耦,Gossip 本质是接受最终一致性换取扩展性,渐进式迁移本质是化整为零。学会拆解这些设计背后的取舍,比记住配置参数重要一百倍。
Redis Cluster 不是完美的,它有自己的代价和局限。但它的设计哲学——在性能、一致性、复杂度之间做清醒的权衡——值得每一个做分布式系统的人仔细琢磨。
希望这篇对 Redis Cluster 架构的深度解析,能为你理解分布式系统提供帮助。如果你想了解更多关于后端架构的实战思考和设计模式,欢迎访问 云栈社区 进行深入探讨和交流。