ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis Cluster散列插槽核心机制与实战排障全解析

Redis Cluster散列插槽核心机制与实战排障全解析 Redis刚发布Cluster模式的时候我第一反应是这不就是把数据切片铺到几台机器上嘛有什么难的。真上手踩过几个环境之后才明白散列插槽这套机制才是整个分片集群的灵魂。你看完这整套思路再去看那些共用一套代码、但因为用了不同客户端就显得行为不一样的问题就会有一种“原来如此”的通透感。这篇我不打算写成概念科普直接当成一份“从底层原理到动手搭建、再到真实排障”的踩坑记录来写尽量把我理解的、实际遇到过的东西都讲清楚。先说清楚一个前提单机Redis再快内存和CPU终究是有限的十几GB的数据、每秒十几万次写单节点就算能扛主从复制链路也会很难受。主从模式解决的是“机器挂了要能继续读”解决不了“数据太多装不下、单线程CPU用尽”的问题。于是把数据切分到多个节点各自承担一部分这就是分片。早期不少团队用客户端哈希取模比如对key做hash再对节点数取余简单直接但它有一个非常难受的弱点——节点数量一变几乎所有的key映射关系都乱了缓存瞬间失效服务雪崩的概率很大。Redis Cluster选择的方式是引入一层固定的“槽”作为中间映射key只跟槽绑定槽再分配到节点上这样一来节点增减只影响一部分槽而不是全部数据。1. 为什么Redis集群选择了散列插槽1.1 单机瓶颈与主从模式的边界在做集群方案之前先想清楚你到底在解决什么问题。我见过不少团队数据量也就两个GBQPS两三千硬要上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发给其他节点。如果槽总数是65536bitmap就要占用8KB65536/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已经被迁移到了BA会返回ASK错误附带提示客户端去B节点执行一次ASKING命令后再访问。ASK是一次性的临时指引它不改变客户端对槽位映射的缓存也就是说下一次再访问同一个key时客户端依然会找A节点从而保证迁移期间请求不会因为路由变化而中断。理解这两者的差异对理解集群迁移期间的“抖动”非常有帮助后面实操部分我会再展开。3. 从零搭建一套三主三从的Redis Cluster3.1 环境准备与配置模板搭建Cluster最传统的办法是准备6台机器3主3从但本地验证完全可以用Docker在单机上跑6个容器。我这里用Docker为例因为清理方便、环境一致性高后面换机器部署时思路完全一致。先建一个网络固定容器的IP分配docker network create --subnet172.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 tagset 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个槽”到“清楚迁移抖动从哪来”整个过程里积累的东西都整理出来了。
返回列表