ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis Cluster数据分片机制详解:从哈希槽到扩缩容避坑指南

Redis Cluster数据分片机制详解:从哈希槽到扩缩容避坑指南 在实际业务里Redis 单机撑不住的时候绝大多数团队的第一反应就是上 Redis Cluster。但很多人对 Cluster 的使用只停留在“redis-cli --cluster create 一把梭”的层面一旦遇到槽位迁移、CROSSSLOT 报错、CLUSTERDOWN就完全不知道怎么排查。这篇文章我想把 Redis Cluster 的数据分片机制完整拆一遍从为什么分片、怎么分片、客户端怎么路由到实际搭建和扩缩容的完整流程以及我踩过的坑。适合想把集群彻底搞明白的运维、后端开发和架构师也适合准备面试的人做系统性梳理。1. 为什么需要数据分片单机Redis的边界1.1 单机Redis的瓶颈到底卡在哪很多人以为 Redis 快所以不存在性能问题。单机 Redis 确实快但它有非常明显的三个天花板容量、吞吐、可用性。容量是最先撞上的墙。假设你要存 5 亿个用户维度的 key每个 key 加上关联数据平均按 1KB 算那就是 500GB 内存。单机不可能扛得住而且 Redis 的内存不是全部能用来装业务数据的——AOF 重写、RDB 快照、内存碎片、备份预留都要占空间实际可用容量一般只有物理内存的 60% 左右。一台 64GB 的机器能放心用的业务缓存也就 40 多 GB。吞吐方面Redis 的命令执行是单线程事件循环虽然极快但总归有上限。纯 KV 操作短连接压测能到十万级 QPS一旦命令复杂SINTERSTORE、ZUNIONSTORE、大 value 的 GETSET吞吐立刻往下掉。更要命的是慢查询会堵住后续所有请求单机上你几乎没有容错空间。可用性方面单实例挂了就是全挂只能靠主从加哨兵保命。但哨兵模式解决的是“高可用”解决不了“数据总量和吞吐上限”的问题。你内存就那么大加再多的从节点从节点的存储总量也是天花板写能力也没有本质提升。所以当数据量和流量都到一定程度时必须做分片把数据拆成多份分散到多台机器上让整个集群的容量和吞吐量可以随节点数量水平扩展。这就是 Redis Cluster 存在的根本原因。1.2 三种分片方案对比客户端分片、代理分片、官方Cluster先聊一个常见误区分片不是 Redis Cluster 发明的。在 Cluster 出现之前业界已经有各种分片方案各有各的取舍。理解这些方案你才能明白 Cluster 的设计到底好在哪。第一种是客户端分片。代表方案是 Jedis 的 ShardedJedis。客户端内置一致性哈希根据 key 直接算出该连哪台 Redis。优点是没有中间层性能最高实现也简单缺点是分片逻辑焊死在客户端代码里换语言就要重写一套而且扩缩容时存量数据的迁移完全要自己搞定一不留神就会把线上数据搞乱。很多团队用了一阵就被迁移成本劝退了。第二种是代理分片。代表方案是 Twemproxy 和 Codis。客户端无脑连代理代理负责路由和转发后面挂多少 Redis 实例客户端完全无感。好处是客户端接入简单与语言无关缺点是中间多一跳延迟和带宽都有损耗代理本身还需要额外的高可用保障而且代理对命令的支持打了折扣很多复杂命令直接不支持。第三种就是官方的 Redis Cluster。它用固定数量的哈希槽来分片数据和槽绑定槽再分配到各个主节点。客户端需要实现重定向逻辑服务端通过 gossip 协议维护集群状态支持在线扩缩容主节点故障后从节点自动提升。它把客户端分片和代理分片的优势整合了代价是功能上牺牲了一部分——跨 slot 的多 key 操作变得极其受限后面我会专门讲。三种方案我列个表方便直观对比方案路由规则在哪扩缩容多key操作性能开销代表性实现客户端分片客户端需自研迁移仅同分片可用最低ShardedJedis、一致性哈希代理分片代理层代理层规划迁移仅同分片可用中间一跳损耗Twemproxy、CodisRedis Cluster客户端服务端配合在线reshard仅同slot可用低靠客户端路由官方ClusterRedis Cluster 的核心聪明之处在于它把“数据分片”这件事抽象成了“哈希槽分配”问题。数据和槽的映射是固定算法槽和节点的映射是动态可变的。这样数据分布与节点数量解耦扩容时只需要搬动部分槽位而不是重新哈希全部数据。2. 哈希槽Redis Cluster分片的核心设计2.1 16384个槽位如何分配到节点Redis Cluster 的数据分片不是直接把 key 哈希到节点而是先哈希到一个逻辑概念——槽slot。整个集群固定有 16384 个槽编号从 0 到 16383。每个主节点负责其中一段槽位区间集群启动时通过cluster addslots命令或者redis-cli --cluster create自动分配。以一个 3 主节点的集群为例槽位分配是这样的节点 A0 - 5460节点 B5461 - 10922节点 C10923 - 16383每个 key 经过哈希计算落在其中一个槽上这个槽归哪个节点管读写请求就去哪个节点。所以槽是数据迁移和负载均衡的最小单位。数据迁移不需要按 key 一条条商量而是以槽为单位整体搬移粒度比一致性哈希的“虚拟节点”更可控也更均匀。如果你自己手动配置可以用cluster addslots 0 1 2 ...把指定槽号分配给当前节点。手动分配很麻烦所以生产上基本都用redis-cli --cluster create自动分配它会把 16384 个槽尽量平均地分给每个主节点。2.2 key如何映射到槽位CRC16与取模key 到槽的映射是固定算法保证任何客户端、任何时刻计算出来的结果都一致。公式很简单slot CRC16(key) % 16384CRC16 是一种循环冗余校验算法它能把任意长度的字符串计算成一个 16 位的整数。Redis 使用的是 CRC16-CCITT 的一个变体% 16384相当于是取这个 16 位整数的低 14 位因为 2 的 14 次方正好是 16384。源码里它做了查表优化性能极高一次计算也就几十纳秒。我用 Python 演示一下计算原理方便你本地验证import crcmod crc16 crcmod.predefined.Crc(xmodem) def hash_slot(key: str) - int: crc16.new() crc16.update(key.encode()) return crc16.crcValue % 16384 print(hash_slot(foo)) # 12182这里用xmodem多项式近似模拟 Redis 的 CRC16 算法原理一致。foo这个 key 会落到第 12182 号槽。至于这个槽在哪个节点取决于槽和节点的映射。这里必须提一个使用频率极高的特性hash tag。Redis Cluster 支持在 key 里用花括号{}指定哈希标签计算槽位时只用花括号内的部分参与哈希。举个例子{user:1000}.following{user:1000}.followers这两个 key 都只对user:1000做 CRC16所以它们必然落在同一个 slot 上。这个特性是解决跨 slot 多 key 操作问题的唯一官方钥匙后面讲批量操作时还会反复提到。2.3 为什么是16384而不是65536很多人第一次看到 16384 这个数字都会问为什么不用 65536这样槽位数更多数据分布更均匀迁移粒度也更细。Redis 作者 antirez 在 GitHub 上专门回应过这个问题核心原因有几个。第一是心跳消息的带宽成本。集群节点之间要频繁交换状态信息每个节点需要告诉其他节点“我负责哪些槽”。实现方式是一个槽位位图bitmap每个槽占 1 个 bit。16384 个槽就是 16384 bit换算下来是 2KB如果换成 65536 个槽位图就是 8KB。节点数量越多心跳消息在网络上的放大效应越明显每秒广播一次8KB 的成本翻 4 倍对带宽是实打实的压力。第二是集群规模上限。16384 个主节点的规模理论上已经够大而实际上一个集群跑到 1000 个主节点就已经非常夸张再往上光网络和故障恢复的复杂度就足以让人崩溃。既然实际规模不可能到 65536 个主节点就没必要用 65536 个槽。第三是数据迁移的粒度权衡。槽数越多每个槽包含的数据量越小迁移越平滑但槽数过多会让元数据膨胀、心跳包变大、槽位分配变得碎片化。16384 是个很均衡的选择常见千节点集群下每个节点平均十几个槽迁移粒度足够细元数据开销也控得住。2.4 槽位信息如何同步gossip协议与配置纪元槽位的分配信息不是只存在某个中心节点上而是每个节点都保存一份完整的集群视图。Redis Cluster 用 gossip 协议闲聊协议来同步这些信息节点之间通过 PING/PONG 消息交换各自的槽位 bitmap 和节点状态。gossip 的机制很有意思每个节点每隔大约 100ms 随机挑一个节点发送 PING收到后返回 PONG消息里携带自己的槽位信息、节点状态和配置纪元。这样一个集群的状态会在秒级内收敛到所有节点。它不需要中心化协调器也不存在“元数据服务器挂了集群就挂”的问题。这里有个关键概念叫配置纪元configEpoch。它本质上是一个版本号用来仲裁槽位冲突。比如某个节点发生故障转移新的主节点会把自己的 configEpoch 加一然后通过 gossip 广播。当其他节点发现同一个槽位被两个节点认领时谁的 configEpoch 大就听谁的。这套机制保证集群在分区、故障、重连等复杂情况下最终能达成一致的槽位归属。3. 客户端如何找到数据重定向与路由机制3.1 MOVED重定向槽位归属变了当客户端向节点 A 请求一个 key但 A 发现自己不负责这个 key 对应的槽时就会返回一个 MOVED 错误。比如连接 7000 端口的节点执行GET foofoo的槽是 12182而 12182 归 7001 端口的节点管响应会是这样的(error) MOVED 12182 127.0.0.1:7001这个响应告诉你两件事第一槽 12182 已经永久归属于 127.0.0.1:7001第二你本地缓存的槽位映射该更新了。Smart 客户端收到 MOVED 后会更新本地缓存然后重新向目标节点发送命令。如果你用的是命令行工具redis-cli不加-c参数就只能看到这行错误加了-c才会自动跟随重定向。MOVED 是“永久”重定向。也就是说客户端下次查这个槽的 key应该直接找到新节点不应该再问旧节点。所以收到 MOVED 后务必更新本地映射表否则每次请求都白跳一次性能损耗非常明显。3.2 ASK重定向迁移过程中的临时引导与 MOVED 容易混淆的是 ASK 重定向。它出现在槽位迁移过程中。假设槽 12182 正在从节点 A 迁往节点 B但迁移还没完成槽位的归属在集群状态里仍然算 A。此时请求访问 A 的某个 key如果这个 key 已经迁走了A 会返回(error) ASK 12182 127.0.0.1:7001ASK 和 MOVED 的本质区别是ASK 是“临时的”槽的归属并没有变只是这一次有个 key 已经搬到目标节点了你去那边找一下MOVED 是“永久的”槽的归属彻底变了。客户端的处理也不同收到 ASK 后不能更新本地缓存里的槽位映射只是临时向目标节点发起一次请求。而且收到 ASK 后客户端不能直接发 GET而是要先发送一个ASKING命令再发真正的请求。为什么因为正常来说目标节点 B 并不负责这个槽如果直接发 GETB 也会返回 MOVED 把你踢回去。ASKING 命令的作用是告诉 B“我知道这个槽还不归你管但这是迁移过程中的临时请求请破例帮我处理这一次。”这样请求才能成功。用表格对比一下就非常清晰类型触发时机槽位归属客户端是否更新缓存是否需要ASKINGMOVED槽位永久迁移完成已变更是不需要ASK槽位正在迁移中尚未变更否需要3.3 Smart客户端的槽位缓存与失效更新理解了 MOVED 和 ASK再看客户端路由就很清楚了。JedisCluster、Lettuce 这类 Smart 客户端在初始化时会随机连一个节点执行CLUSTER SLOTS命令拿到全量槽位分布然后在本地维护一张“槽号 - 节点”的映射表。之后每次请求先查本地映射表直接连对应节点不需要每一条命令都问一遍集群元数据所以性能很高。当集群发生扩缩容、故障转移、reshard 时客户端本地映射可能会短暂过期。这时它依赖两件事来恢复一是 MOVED 错误触发映射更新二是客户端自身的重试机制。很多客户端收到 MOVED 后重试一次就能成功但如果连续遇到集群状态变化可能重试多次所以客户端超时时间要设置得合理。这里有个细节值得注意Smart 客户端的映射表更新是“按槽”的不是按 key 的。某个槽被迁走后客户端后续对这个槽的所有 key 都会走新节点。这也意味着如果你手动对某个槽做了迁移要留意旧节点的连接池里可能还有残留连接不过 Redis 协议层面的重定向机制能兜住不会产生数据正确性问题。3.4 分片带来的功能限制分片的代价是实打实的一切涉及多个 key 的操作如果这些 key 不在同一个 slot 上就无法执行。最常见的是MGET、MSET、DEL多 key、RENAME、事务和 Lua 脚本。比如执行MGET user:1 user:2如果这两个 key 的槽位不同节点会直接返回错误(error) CROSSSLOT Keys in request dont hash to the same slotLua 脚本也一样Redis Cluster 会检查脚本里用到的所有 key 是否在同一个 slot不是同一个就拒绝执行。事务MULTI/EXEC虽然没有显式检查所有 key实际上在 Cluster 模式下也要求所有 key 在同一 slot否则要么报错要么行为不符合预期。应对思路有三种。第一种最推荐设计 key 时就考虑分片把业务上需要一起操作的 key 用 hash tag 固定在同一个 slot比如user:{1000}:profile、user:{1000}:orders这样单个用户的数据天然聚集第二种是客户端分组把 key 按 slot 分组每个组单独 pipeline 请求实现跨节点的聚合逻辑第三种是尽量避免跨 key 操作能分步拆就拆。我在实际项目里吃过亏早期没做 key 规范上线后想用事务批量更新用户多个维度的信息结果被 CROSSSLOT 卡住最后只能改 key 结构。所以建议在项目开始前就把 key 的命名规范定好hash tag 该加就加真等数据量起来之后再改成本高到难以想象。4. 实操搭建集群与在线扩缩容4.1 最小可用集群的搭建流程先说环境。生产环境建议至少三台物理机每台上跑一个主节点和一个从节点形成 3 主 3 从的标准结构。测试环境可以在一台机器上起多个实例模拟但要注意端口、数据目录、日志文件全部隔离。每个实例的配置最少要加这几项# redis-7000.conf port 7000 cluster-enabled yes cluster-config-file nodes-7000.conf cluster-node-timeout 15000 cluster-require-full-coverage yes appendonly yes daemonize yes pidfile /var/run/redis-7000.pid logfile /var/log/redis-7000.log解释几个关键项配置项作用建议cluster-enabled yes开启集群模式必须cluster-config-file集群状态持久化文件每次启动都会读写每个实例独立文件名cluster-node-timeout节点心跳超时时间超时判定主节点故障默认15000ms网络抖动的环境适当调大cluster-require-full-coverage槽位不全时是否停止服务默认yes要求高可用可考虑no但需评估风险appendonly yes开启AOF保证实例重启后数据不丢生产必须开所有实例启动后用一条命令完成集群创建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表示每个主节点配一个从节点。命令执行时会有一次交互展示槽位分配计划和主从配对关系确认无误后输入yes。之后可以验证集群状态redis-cli --cluster check 127.0.0.1:7000输出里会显示每个节点的 ID、负责的槽位范围、从节点是谁、是否有槽位未分配。检查信息正常后用-c模式验证读写redis-cli -c -p 7000 127.0.0.1:7000 set foo bar - Redirected to slot [12182] located at 127.0.0.1:7001 OK看到Redirected说明路由机制已经生效。如果不加-c这里就会返回 MOVED 错误。4.2 在线扩容新增节点与reshard集群跑起来之后最常用的运维操作就是扩容。比如现在要加一个 7006 端口的新节点先启动实例然后加入集群redis-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000注意新节点加入后没有任何槽位也不能服务数据。你必须手动执行 reshard 把一部分槽迁给它。执行 reshard 同样是一条命令redis-cli --cluster reshard 127.0.0.1:7000它会进入交互模式问三件事要迁移多少个槽、接收槽的节点 ID 是哪个、从哪些源节点迁出。假设我迁移 1000 个槽给 7006 节点源节点选择all就是平均从现有三个主节点各抽一部分槽新节点就获得了分布均匀的 1000 个槽。整个过程是逐槽进行的迁移期间集群对外服务不中断这是 Redis Cluster 对比客户端分片的巨大优势。缩容反过来操作先执行 reshard 把目标节点的所有槽迁移给其他节点然后用del-node把它踢出集群redis-cli --cluster del-node 127.0.0.1:7000 node-id一个我踩过的坑删节点前必须确认目标节点的槽位已经归零。如果还有槽就del-node操作会被拒绝如果强行走流程集群状态会进入异常区必须马上重新分配槽位恢复。4.3 数据迁移的核心命令MIGRATEreshard 看起来像黑盒魔法其实底层就是一条命令在反复执行MIGRATE。MIGRATE 的工作流程是这样的源节点把 key 序列化导出包含值和过期时间通过网络发送给目标节点目标节点接收后写入自己的数据集返回成功源节点再删除本地 key。命令长这样MIGRATE 127.0.0.1 7006 key 0 5000 REPLACE其中5000是超时毫秒数REPLACE表示目标节点如果已有同名的 key 就直接覆盖。由于 MIGRATE 是单 key 操作数据量大时一个个迁移会很慢所以redis-cli --cluster reshard内部会做流水线优化一次连接里连续迁移多个 key降低 RTT 的影响。手动调优时可以关注--cluster-pipeline参数它控制每个连接内连续的迁移命令数量适当调大能显著提升 reshard 速度。需要提醒的是迁移过程中如果源节点和目标节点之间网络抖动可能发生 key 同时在两边都存在的情况。MIGRATE 的底层实现已经做了比较完善的容错但为了安全迁移完一轮后建议用redis-cli --cluster check检查一遍槽位覆盖确认没有 key 落在错误节点上。5. 常见问题与避坑指南5.1 热点key与哈希槽倾斜Redis Cluster 只解决数据总量问题解决不了“所有访问都打在一个 key 上”的热点问题。由于 hash 算法的确定性同一个 key 永远落在同一个槽、同一个节点热点 key 的访问压力也全部集中在那一个节点上。表现就是某台节点 CPU 飙升、网络打满其他节点闲得发慌。排查时用redis-cli --hotkeys扫高频访问的 key再用CLUSTER INFO看各节点内存和请求分布基本能定位。处理热点 key 有几种常用手法给 key 加随机后缀分散到多个槽比如热点news:top拆成news:top:1、news:top:2等业务侧读的时候随机选一个副本。这是最立竿见影的手段代价是该 key 的批量操作会受限。加一层本地缓存把热点数据缓存在应用进程内扛住绝大多数读取。把热点 key 的从节点数增多并通过客户端配置从节点读分摊读压力。要特别警惕 hash tag 的滥用千万不要为了让多个业务 key 落在同一槽就把所有 key 都套同一个 tag。这样会导致大量不相干的数据挤在同一个槽上不仅触发节点内存倾斜还会让热点被无限放大。hash tag 的使用边界就是“确实需要在一起执行多 key 操作的 key”。5.2 批量操作与CROSSSLOT错误线上最常见的问题就是CROSSSLOT。报错信息非常直白一次请求里的 key 没有哈希到同一个槽。错误提示是(error) CROSSSLOT Keys in request dont hash to the same slot这个错误在MGET、MSET、DEL多 key、RENAME、事务和 Lua 脚本中都可能出现。我观察到的典型场景是运营后台有个聚合查询脚本一次拉取几十个用户的昵称和等级用MGET一把梭上了集群直接报错。解决思路优先级排列如下改 key 设计用 hash tag 把强相关的 key 固定到同一槽这是最省事的方案客户端按 slot 分组聚合请求比如把几十个用户按 slot 分几组每组发一次 pipeline再把结果合并不用改数据实在无法避免跨槽事务只能业务补偿或引入独立的协调服务复杂度和成本都很高。我踩坑后的原则是凡是业务上明确需要一起读写的 key必须共享同一个业务 ID并在命名上强制包含{业务ID}标签。这个规范在集群环境里是命脉越早建立越好。5.3 CLUSTERDOWN与集群可用性集群模式一个反直觉的地方默认配置下只要有一个槽位没有主人整个集群就会拒绝所有请求返回CLUSTERDOWN The cluster is down。触发条件包括某个主节点宕机且没有从节点可以顶上、手动迁移过程中槽位出现了短暂无人持有的间隙、节点网络分区导致槽位视图不一致。这个行为由cluster-require-full-coverage yes控制。它的设计逻辑是极端保守的既然数据不完整宁可整体不可用也不能让业务拿到不完整的数据。对于金融、交易类场景这是合理的但对于缓存类场景很多团队会选择把它改成no这样部分槽挂了其他槽还能正常读写代价是你得接受数据缺失的现实。排查 CLUSTERDOWN 的标准流程redis-cli -p 7000 cluster info # cluster_state:fail # cluster_slots_assigned:16384 # cluster_slots_pfail:100 redis-cli -p 7000 cluster nodes重点看cluster_state、cluster_slots_pfail和cluster_slots_fail。如果发现有主节点处于fail状态且它的从节点没有及时提升先看cluster-node-timeout是否设置过大再看从节点的数据复制是否落后太多。还有一个小坑测试环境用FLUSHALL清库后如果重启集群节点时忘了删cluster-config-file可能会出现旧节点 ID 不匹配导致的槽位丢失这种问题排查起来最耗时间。5.4 故障转移与脑裂边界主节点挂了之后从节点会在cluster-node-timeout之后发起故障转移投票。投票由集群里其他存活的 master 节点参与得票超过半数才能成为新主。这也是为什么 Redis Cluster 最少要 3 个主节点只有 2 个主时一个挂了另一个只有 1 票达不到多数无法完成转移集群就断了。这里必须说清楚一个本质Redis Cluster 是 AP 系统不是 CP 系统。在极端网络分区情况下旧主节点可能并没有真正宕机只是和集群其他节点失联了。如果旧主仍然接受了客户端的写入而这些写入没有来得及复制给从节点那么新主通过故障转移上位后旧主那些“孤岛写入”就会永久丢失。这就是脑裂丢数据的核心风险。所以涉及资金、订单这类强一致性的数据不能只靠 Redis Cluster 自身机制兜底。常见做法是给关键数据的写操作加一层应用侧校验或者直接用数据库作为强一致存储Redis Cluster 只承担缓存角色。理解了这一点你再看各种“集群数据丢失”的爆料本质上都不是 Redis 的 bug而是对 CAP 边界预期错位。6. 最后的实操心得整套数据分片机制啃下来我最大的体会是Redis Cluster 的技术难点不在搭建和命令而在对分片规则的敬畏。数据会落在哪个节点不是靠运气是 CRC16 算出来的确定性结果哪些 key 能一起操作是由哈希槽决定的硬约束。所有问题根源都在设计阶段有没有围绕这些约束把 key 结构设计好。另外运维层面一定要留好后路生产环境做 reshard 前先在一套临时集群演练一遍迁移完成后立即执行redis-cli --cluster check检查完整性CLUSTERDOWN 不是玄学大多数时候是槽位缺失或者配置纪元冲突用cluster nodes的输出基本能锁定肇事节点。这些习惯看起来简单但真到线上出问题时能帮你省下大把的抢救时间。
返回列表