☰
Redis Cluster散列插槽核心机制与实战排障全解析
2026/10/5 13:33:22 网站建设 项目流程

Redis刚发布Cluster模式的时候,我第一反应是:这不就是把数据切片铺到几台机器上嘛,有什么难的。真上手踩过几个环境之后才明白,散列插槽这套机制才是整个分片集群的灵魂。你看完这整套思路,再去看那些共用一套代码、但因为用了不同客户端就显得行为不一样的问题,就会有一种“原来如此”的通透感。这篇我不打算写成概念科普,直接当成一份“从底层原理到动手搭建、再到真实排障”的踩坑记录来写,尽量把我理解的、实际遇到过的东西都讲清楚。

先说清楚一个前提:单机Redis再快,内存和CPU终究是有限的,十几GB的数据、每秒十几万次写,单节点就算能扛,主从复制链路也会很难受。主从模式解决的是“机器挂了要能继续读”,解决不了“数据太多装不下、单线程CPU用尽”的问题。于是把数据切分到多个节点各自承担一部分,这就是分片。早期不少团队用客户端哈希取模,比如对key做hash再对节点数取余,简单直接,但它有一个非常难受的弱点——节点数量一变,几乎所有的key映射关系都乱了,缓存瞬间失效,服务雪崩的概率很大。Redis Cluster选择的方式是:引入一层固定的“槽”作为中间映射,key只跟槽绑定,槽再分配到节点上,这样一来节点增减只影响一部分槽,而不是全部数据。

1. 为什么Redis集群选择了散列插槽

1.1 单机瓶颈与主从模式的边界

在做集群方案之前,先想清楚你到底在解决什么问题。我见过不少团队,数据量也就两个GB,QPS两三千,硬要上Cluster,结果把复杂度全部引了进来:多键操作受限、客户端升级、运维脚本重写。真正需要Cluster的场景,无非是下面几点:

  • 内存容量超过单机物理上限,比如单机64GB仍然不够,需要多机分摊。
  • 写性能受限于单线程模型,CPU单核被打满,需要多节点分摊写请求。
  • 需要在线横向扩展,扩容时不能停服务。
  • 希望故障时自动完成主从切换,业务无需介入。

而主从复制(Replication)解决的是可用性问题:主节点挂了,从节点可以升级为主,但所有数据还是放在同一组节点里,写入的能力上限没有变,容量也没有变。哨兵模式也一样,只是把“谁来做主”的决策自动化了,数据的承载能力没有任何提升。这就像原来只有一个仓库,你安排了一个保安负责主仓库、另一个保安负责备用仓库,但仓库本身还是只有一个,货多了照样放不下。

1.2 几种分片方案的对比与取舍

把数据分散到多节点,业内大致有三种常见思路:客户端取模、一致性哈希、服务端槽位管理。它们在实现难度、伸缩性和运维体验上差异很大。

客户端取模的实现非常朴素,客户端根据key的hash值对节点数取余,直接决定请求打到哪台机器。问题说过了:节点数一调整,映射关系大面积变化,缓存分区后重建,这在大规模集群里是不可接受的。一致性哈希通过引入哈希环,让节点增减只影响环上局部范围的key,听起来很棒,但它在实际部署中会有数据分布不均的问题,一般需要引入虚拟节点来缓解。而且一致性哈希通常还是客户端侧实现,服务端对数据的分布位置没有全局认知,对多键操作、事务这类需要数据局部性的能力支持不足。

Redis Cluster走的是第三条路:把整个键空间划分为16384个固定槽位,每个节点负责其中一段连续的区间。key经过计算映射到某个槽,槽由哪个节点服务,由Gossip协议在集群内部维护。这样做的直接好处是:

  • 槽的数量是固定的,不会随节点数量变化,大部分key的映射关系天然稳定。
  • 节点加入或下线时,只需要迁移槽位,迁移单位是槽,而不是没头绪的数据文件。
  • 服务端知道每个槽的位置,可以做精准的路由转发和重定向。

我个人的理解是:散列插槽本质上是把“数据分布”从客户端代码里抽离出来,交给集群自己做统一管理。对业务侧来说,你只需要关心key长什么样,完全不用关心数据落在哪个节点上,这份松耦合同样容易被“集群扩容不需要重启客户端”这个事实反映出来。

2. 散列插槽的工作机制

2.1 key到槽位的计算:CRC16与掩码

散列插槽的计算公式其实非常直白:

slot = CRC16(key) & 16383

在Redis源码里,实际调用是crc16(key) & 0x3FFF,因为16383的二进制就是低14位全1。任何输入key经过CRC16之后得到一个16位的值,再与上0x3FFF,就得到一个0到16383之间的槽编号。为什么是16384而不是更大或者更小的数,这个我后面单独说。

这里有个非常实用的细节:hash tag。语法是{tag},Redis在计算槽位时,如果key里有一对大括号,那么它只取大括号内部的内容来计算CRC16,从而实现让多个key保证落在同一个槽里。最简单的一个场景:

mset user:10001:name "张三" user:10001:points 100

在Cluster模式下这样直接写会报错,因为两个key大概率不在同一个槽。但改成:

mset user:{10001}:name "张三" user:{10001}:points 100

两个key都按照10001这个tag计算槽位,必定落在同一个槽,MSET就能执行了。这就是为什么很多做集群迁移的人,在规划key时就开始把公共维度放进hash tag里。但也要注意,hash tag使用不当会造成热点,把所有流量都打到一个槽上,那就失去了分片的意义。

2.2 为什么槽位总数偏偏是16384

很多初学Cluster的人会问:CRC16可以产生65536种结果,为什么槽总数不是65536而是16384?这个问题曾经在网络上引发过不少讨论,我自己也翻过源码和文档,比较认可的解释有三个。

第一,网络包开销。集群节点之间要靠Gossip协议交换槽位信息,每个节点要把自己维护的槽位bitmap发给其他节点。如果槽总数是65536,bitmap就要占用8KB(65536/8),而16384只需要2KB。集群每秒要互相交换大量心跳包,减少数据包体积意味着更低的带宽占用和更快的传播速度。

第二,集群规模的实际约束。官方设计时认为1000个节点以内的集群已经够绝大多数场景使用了,对性能和稳定性也有更成熟的预期。16384个槽分配给1000个节点,平均每个节点也有16个槽,足够分散;但如果是65536个槽,平均每个节点要处理65个槽,在重分片时消息量大得多,但没有必要。

第三,槽位需要被压缩存储在数据结构里。16384个槽作为bitmap只需要2KB,在内存和网络复制时都很轻量。这个数字不是随手写的,是权衡了“存储开销、网络开销、集群规模和热平衡度”之后的一个折中值。实际使用中,16384个槽已经能够把数据分布得相当均匀,很少出现某几个槽承载了绝大部分流量的情况,除非业务在key设计上人为地制造热点。

2.3 客户端如何知道key在哪个节点:MOVED与ASK

散列插槽机制的关键在于客户端与集群之间的路由协调。客户端第一次请求某个key时并不知道槽位于哪个节点,它会随意连接集群中的任意节点,如果节点发现这个槽不在自己的负责范围内,就会返回一条MOVED错误,告诉客户端正确的节点地址。

(error) MOVED 3999 192.168.31.101:7000

一个合格的Cluster客户端(比如lettuce、jedis的Cluster模式)收到MOVED后会更新本地路由缓存,然后把请求发到正确节点。这意味着第一次访问特定槽时有一次额外的网络往返,之后客户端会缓存槽映射关系,后续访问直接命中。

ASK与MOVED有一点本质区别。MOVED表示槽位已经固定迁移到了别的节点,以后所有访问这个槽的请求都应该发往新节点;ASK则发生在槽位迁移过程当中。当一个槽正在从节点A迁移到节点B时,如果客户端连接到A,而某个key已经被迁移到了B,A会返回ASK错误,附带提示客户端去B节点执行一次ASKING命令后再访问。ASK是一次性的临时指引,它不改变客户端对槽位映射的缓存,也就是说下一次再访问同一个key时,客户端依然会找A节点,从而保证迁移期间请求不会因为路由变化而中断。

理解这两者的差异,对理解集群迁移期间的“抖动”非常有帮助,后面实操部分我会再展开。

3. 从零搭建一套三主三从的Redis Cluster

3.1 环境准备与配置模板

搭建Cluster最传统的办法是准备6台机器(3主3从),但本地验证完全可以用Docker在单机上跑6个容器。我这里用Docker为例,因为清理方便、环境一致性高,后面换机器部署时思路完全一致。

先建一个网络,固定容器的IP分配:

docker network create --subnet=172.20.0.0/16 redis-cluster-net

然后准备6个节点的配置模板。我习惯把每个节点的端口、目录分开,避免配置残留互相污染。配置文件核心参数如下:

port 7000 cluster-enabled yes cluster-config-file nodes-7000.conf cluster-node-timeout 5000 appendonly yes appendfsync everysec protected-mode no bind 172.20.0.x

参数说明:cluster-enabled yes开启集群模式;cluster-config-file保存集群节点状态;cluster-node-timeout是节点超时时间,超过这个时间后会触发故障转移,不是越大越好,也尽量不要太小,否则正常网络抖动就容易导致主从切换。生产环境我会设置为5000-15000毫秒之间,本地测试用5000毫秒即可。

配置里我额外开了appendonly yes,这是个人习惯。集群模式下如果不做持久化,一旦节点重启就是空数据,配合故障转移会把从节点提升为主节点后出现“读到一堆空槽”的问题,真实事故里见过,不值当。

3.2 创建集群与验证槽位分布

6个容器各自启动之后,用redis-cli --cluster create命令一次性构建集群。我习惯先为所有节点设置密码(生产建议开启),但本地验证时先保持无密码,减少变量:

redis-cli --cluster create 172.20.0.101:7000 172.20.0.102:7001 \ 172.20.0.103:7002 172.20.0.104:7003 172.20.0.105:7004 172.20.0.106:7005 \ --cluster-replicas 1

--cluster-replicas 1表示每个主节点配1个从节点。命令执行后,工具会自动把16384个槽尽量平均地分配到3个主节点上,再根据主节点组自动分配对应的从节点。这一步如果配置文件名、端口不一致,可能会报节点ID冲突,先把旧的nodes-*.conf清掉再重新执行即可。

创建成功后,用cluster info可以查看集群状态:

cluster_state:ok cluster_slots_assigned:16384 cluster_known_nodes:6 cluster_size:3

再看槽位分配:

redis-cli --cluster check 172.20.0.101:7000

输出里每一行说明某个节点的起始槽与结束槽,例如:

172.20.0.101:7000 (3c0f...) M: 0-5460 172.20.0.103:7002 (a1d2...) M: 5461-10922 172.20.0.105:7004 (9e4b...) M: 10923-16383

注意这里有一个很多人会误读的点:槽区间是左闭右闭的还是右开的,并不是重点,重点是三个区间必须连续覆盖0到16383,不能有缺口,不能有重叠。一旦出现缺口,cluster_state会变成fail,整个集群拒绝新写入。

3.3 键分布实测与hash tag验证

集群建好之后,随便写几个key验证一下散列情况:

redis-cli -c -h 172.20.0.101 -p 7000

加上-c表示进入集群模式,客户端会自动处理MOVED重定向。我实测写入几十个key,观察它们分布的槽位和节点,大致会看到每个节点都分到了一些key,而不是集中在一起:

192.168.31.101:7000> set order_id:1001 "A" -> Redirected to slot [8912] located at 172.20.0.103:7002 OK

这说明order_id:1001经过CRC16计算后落在8912槽,当前由第三台节点服务。如果手算一遍,会用redis-cli --cluster key-slot order_id:1001来验证,结果是完全一致的。

接着验证hash tag:

set order_id:{1001}:amount 99.5 set order_id:{1001}:status PAID

两个key的槽位一定一致,因为计算时只取1001部分。我在测试环境里验证过数十次,还没有出现过tag一致但槽位不同的情况,这是设计保证的结果,不是概率事件。

3.4 集群模式下多键操作的边界

集群模式下,能够在一条命令里操作多个key的能力被限制了。MSET、MGET、DEL多个key、SUNIONSTORE这类命令,必须要求所有key都在同一个槽才能执行,否则直接报CROSSSLOT错误。这是为了保持每个命令的数据局部性,因为跨节点事务会让实现复杂到不可接受。

对需要保证原子性的业务,通常有两条路:一条是用hash tag把相关key聚集到同一个槽,另一条是接受使用Lua脚本的限制。Lua脚本在集群模式下的限制更严格,脚本里所有涉及到的key必须显式通过KEYS数组传入,并且同样要求落在同一个槽。实际写业务代码时,我见过不少人拿“集群不支持Lua”来定方案,其实并不是,只是要求你比单机模式更严格地设计key。

4. 扩容缩容与在线迁移实操

4.1 新增一个节点并手动分配槽位

集群跑了一段时间,内存告急,需要加节点。假设新增一个主节点172.20.0.107:7006,先把它加入集群:

redis-cli --cluster add-node 172.20.0.107:7006 172.20.0.101:7000

前面的参数是待加入节点,后面的参数是集群内任意一个已知节点。执行成功后会返回一个新的节点ID。此时新节点虽然加入了集群,但还没有分配到任何槽位,我们可以用reshard来搬运槽:

redis-cli --cluster reshard 172.20.0.101:7000

按照交互提示,输入要迁移的槽数量。比如我从三个旧节点各迁500个槽,总共1500个槽给新节点。工具会先列出可取槽的节点来源,再逐个迁移。这个过程的底层逻辑是:对每个槽做一次cluster setslot相关操作,先把源槽状态改为migrating,再把目标槽状态改为importing,然后把该槽下的所有数据用MIGRATE命令从源节点迁移到目标节点,迁移完成后广播槽归属变更。

集合数据迁移的时间取决于数据量和网络带宽。我遇到过迁移过程中槽内还有大量大value(几MB的序列化对象),每个都要整条MIGRATE,耗时被长尾拖死。对这种场景,建议先治理大key,再执行迁移。

4.2 迁移期间访问抖动的原因

这是散列插槽机制中一个很容易被忽视的点。槽迁移不是瞬间完成的,中间会有一个窗口期:同一个key,可能在源节点已经被删掉了,但在目标节点还没有完全写入,出现短暂的外观矛盾。实际上MIGRATE命令在源节点执行时是原子的,它会序列化并发送数据,只有等目标节点回复OK后,源节点才删除本地副本,所以不会丢数据。

但客户端在迁移期间可能遇到两类错误:

  • 如果客户端连接的是源节点,而目标key已被迁走,源节点会返回ASK,客户端需要跳转。
  • 如果客户端缓存里的槽映射还指向源节点,但源节点槽状态已经是migrating,业务请求会被引导;而在极端情况下,如果请求刚好在槽已迁走后到达旧客户端,也可能看到CLUSTERDOWN之类的报错,视版本有所不同。

线上扩容时,常见做法是维护一个“低峰期迁移 + 客户端重试”的策略。迁移期间允许业务侧对个别请求做一两次重试,配合客户端的重定向机制,绝大多数场景是可以平滑度过的。

4.3 缩容与安全下线顺序

缩容比扩容更敏感,因为下线一个节点往往牵扯主从关系、槽位所属、故障转移逻辑。先把要下线的节点上的槽全部迁走,确认该节点不再服务任何槽,再执行下线操作。

redis-cli --cluster reshard 172.20.0.101:7000

把槽全部迁移给其他节点后,再用命令删除节点:

redis-cli --cluster del-node 172.20.0.101:7000 <待删除节点ID>

这里有一个人人都会踩的坑:如果你要下线的是一对主从节点里的从节点,先del从节点,再del主节点;反过来如果直接把主节点下线,集群会重新选举新的主节点,容易造成一些不必要的全量同步。还有,永远不要让集群中只剩一个主节点带着零个从节点的状态长时间运行,一旦这个节点宕机,整个集群直接不可写。

5. 集群场景的常见问题与排查技巧

5.1 集群不可写:CLUSTERDOWN与槽缺口

前面提到过,集群健康的核心标志是16384个槽都有归属,且每个主节点至少有一个在线的从节点。当出现槽缺口或主节点故障后短时间无法完成Failover时,集群会进入fail状态,所有写请求都会拒绝返回CLUSTERDOWN。

我处理过一个生产事故:运维一次性重启了多个节点,导致超过半数的从节点与主节点同时离线,集群认为无法满足多数原则,直接拒绝写入。恢复流程是先拉起节点,让节点重新加入集群,再等槽恢复完整。这个过程中业务会持续报错,所以更合适的办法是做节点分批滚动重启,而不是同时重启多个机器。

还有一个很隐蔽的情况:旧版本Redis在集群状态正常后,如果某个主节点上配置了多个从节点同时进行全量同步,主节点CPU会因为fork做快照而飙升,同步期间主节点响应变慢,可能会被其他节点判定超时,从而触发一连串的Failover。解决方案是控制全量同步并发,或者在同步高峰期临时调大cluster-node-timeout。

5.2 客户端侧的真实坑:pipeline与Lua

很多团队从单机Redis迁移到Cluster之后,最容易踩的是pipeline和Lua脚本的兼容性问题。单机模式下,pipeline可以把一批命令打包发送,一次性拿到回执,性能很高。但Cluster模式下,pipeline里的key可能分布在不同槽、不同节点,大部分客户端不能自动把这些命令拆分到对应节点执行。

如果用lettuce,可以手动按槽分区,把属于同一个节点的命令分别pipeline,或者用ClusterPipeline重定向。我看过一种高效做法:业务在写入前,先用key-slot把key分组,再按节点组分别发送pipeline,虽然代码量大一些,但性能降幅很小。千万不要想着“ChatGPT都能生成,直接用客户端默认api就行”,集群模式下客户端选项多数情况下会自动分发,但也要小心它可能在内部做循环等待与重试,导致超时时间被放大。

Lua脚本的坑更细。Redis在集群模式下,Lua脚本里用到的key必须通过KEYS数组传入且同槽,否则直接报SCRIPTFLAGS相关错误。另外,如果脚本里做了一次写一次读再写,即便槽一致,脚本执行期间也会持有对应key的锁,跨槽操作会拖慢整个集群的响应。所以集群场景下,我的原则是:能不用Lua就不用,非用不可就把脚本控制在单个槽范围内。

5.3 排查大key与热点槽

集群节点间数据是分片的,但分片不均匀最常见的原因是大key和hash tag热点。假如某个业务把所有用户都放在user:{common}:profile这种固定tag下,那这个槽的访问量会远高于其他槽,最终导致该节点CPU打满、内存膨胀,其他节点却闲着。

排查热点槽的思路很简单:用redis-cli --hotkeys(需要开启LFU策略)或对节点监控看commands per second的分布。也可以在Sentinel、Prometheus这类监控面板上看每个Redis实例的核心使用率,如果只有一个节点CPU接近100%,而其他节点都很低,基本可以断定热点槽存在。

大key的定位我常用redis-cli --bigkeys,虽然扫描过程会有一定开销,但在低峰期跑一次不会对集群造成太大影响。找到之后,可以对key做拆分或压缩,把大对象拆成多个小key,再用hash tag把它们聚到同一个槽,避免跨槽访问即可。这里必须提醒一句:迁移前处理大key比迁移后再处理要省太多事,槽迁移过程对单个大key的迁移耗时可能比几千个小key还要长。

5.4 集群节点故障切换与脑裂风险

Cluster的Failover机制也是建立在槽位基础上的。当一个主节点超过cluster-node-timeout没有响应,它的从节点会发起选举,获得多数派同意后提升为主节点,并继承原主节点的所有槽位。这个过程通常能在几秒内完成,比手动恢复快得多。

但脑裂的风险依然存在。有一种场景:主节点所在机器网络抖动,与集群其他节点失联,但主节点自己还在运行;此时从节点被提升为新主节点,旧主节点恢复网络后如果还保留着槽归属,它就与集群产生分歧。Redis通过cluster-require-full-coverage和节点id生成规则来尽量避免这种问题,但单个主节点内存里已经接收的新写入,如果还没同步给从节点,那么在Failover之后这部分数据就丢了。这也是为什么多副本部署时,一般建议min-replicas-to-write可以让主节点在从节点离线时拒绝写入,虽然牺牲部分可用性,但能减少数据不一致的场景。

我在实际部署里会把min-replicas-to-write设置为1,再配一个min-replicas-max-lag为10秒。好处是系统不会因为挂掉一个从节点就直接拒绝写入,但在极端情况下(主从同时长时间失联)会限制一下无脑写入,从而给后续数据恢复留出余地。

6. 一些真正提升集群稳定性的做法

到这里,散列插槽的基本原理、搭建方法、扩容迁移、故障排查基本都覆盖了。我再补几个实操中发现很值得养成的习惯。

第一,上线前对key做一轮设计评审。这不是形式主义。是否有固定维度可以归并、是否能用hash tag把高频关联查询聚到一起、是否要对大对象做拆分,这些问题在单机Redis时代不致命,但在Cluster模式下不提前设计,后面每次扩容都要为数据歪斜和大key付出代价。

第二,监控要到位。节点内存、CPU、命中率、主从同步延迟、槽迁移进度,这些都要有指标。我个人的经验是,不要只监控单个Redis实例的QPS和内存,要额外监控每个节点负责的槽数变化和主从复制字节数。槽数变化能直接反映分摊是否合理,主从复制字节数则可以提前发现全量同步是否频繁发生。

第三,备份不要停。Cluster模式下RDB和AOF同样需要定期备份,不过要注意的是,每个节点备份的只是自己负责的槽位数据,所以恢复时必须是整个集群所有节点的备份组合,单节点恢复没有意义。这也决定了:做恢复演练时,要整套集群一起恢复,不要只拿一个节点练手。

第四,迁移和扩容操作,永远先在测试集群走一遍。每次以为“这次没问题”的时候,往往就是出问题的时候。我见过有人直接在生产环境执行reshard,结果因为源节点上还有未清理的旧配置,迁移中途报错,最后花了几个小时对账。测试集群的作用不只是验证命令可执行,更是验证你的操作顺序、超时配置、以及异常时怎么回退。

最后说一个常常被忽略的细节:Redis Cluster的key设计会直接影响后续所有运维动作的质量。把用户ID、订单ID这类天然区分度高的维度作为key的一部分,让数据散列得足够均匀,那么在16384个槽的背景下,每个节点的负载自然就均衡了。这和散列插槽本身的设计是一致的:它只负责均匀映射,不能让所有key都长成一个样子还期待它魔法般均匀分布。

对初学者,我建议动手搭建一次Cluster,多用redis-cli --cluster check、cluster nodes这些命令观察槽位变化,亲手迁移一次槽,感受一下MOVED和ASK的区别。纸上得来终觉浅,散列插槽这套机制如果不亲手操作一遍,理解起来始终隔着一层。这篇内容写到这,也算是把我从“知道16384个槽”到“清楚迁移抖动从哪来”整个过程里积累的东西都整理出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询