1. 先搞清楚:单机扛不住的到底是什么
很多人第一次接触 Redis Cluster,动机都很朴素——数据量涨了,单机内存装不下;或者 QPS 涨了,一台机器的 CPU 和网卡顶不住了。这个判断没错,但不够细。真正决定你要不要上 Cluster 的,是三件事里最先撞墙的那一件:内存容量、写入吞吐、单线程阻塞。这三件事的解法完全不同,搞混了就会花大价钱买来一堆新麻烦。
先说内存。Redis 是纯内存数据库,一台 64GB 的机器,扣除主从复制 buffer、AOF rewrite 时的 fork 写时复制开销、客户端输出缓冲区,实际能安全存放的数据量大概只有 60%~70%,差不多 40GB 上下。如果你的业务数据三年后预估到 200GB,那么你至少需要 5~6 个分片,这还没算副本。这个账要先算清楚,因为 Cluster 的分片数量在你建集群那天就基本定型了,后面每次扩容都是一次带风险的运维动作。
再说吞吐。Redis 单线程处理命令,一台中等配置的机器跑 8~10 万 QPS 是比较常见的水位,再往上就要靠多核绑定多实例或者分片。这里有一个特别容易被忽略的点:瓶颈往往不在 Redis 本身,而在网卡和客户端连接数。如果每个请求平均 1KB,10 万 QPS 就是 100MB/s,千兆网卡早就打满了。所以我排查性能问题时的第一件事不是看 Redis 的 INFO,而是看sar -n DEV 1里网卡有没有跑满。
最后说阻塞。单线程模型下,一个大 key 的DEL、一次全量的KEYS *、一个耗时的 Lua 脚本,都会把整个实例卡住。这种问题不是靠分片能根治的,分片只是把「卡住全部」变成了「卡住一部分」。所以我的态度一直很明确:Cluster 解决的是容量和吞吐的横向扩展问题,不解决单条慢命令问题。这两类问题要用两套办法。
还有一个绕不开的现实:从主从复制到哨兵,再到 Cluster,其实是三种不同的可用性等级。主从只是数据冗余,主挂了要人工切;哨兵解决了自动切换,但数据仍然在一台机器上,容量没变;Cluster 才同时解决容量和可用性,代价是客户端复杂度上升、跨槽操作受限、运维成本变高。我在做技术选型时有个简单判断标准:数据量小于单机安全水位、且能接受 30 秒级的故障切换窗口,就别碰 Cluster。哨兵足够用,运维成本低一个量级。只有当单机放不下,或者写吞吐确实顶到天花板时,才值得付出 Cluster 的复杂度。
提醒一句:不要因为「集群听起来更高级」就上 Cluster。我见过不止一个项目,数据总共才 8GB,却搭了 6 个节点,结果每次扩容、每次故障排查都要多花几个小时,收益几乎为零。
2. 哈希槽:Cluster 数据分片的底座设计
2.1 为什么是 16384 个槽,而不是一致性哈希
Redis Cluster 没有用一致性哈希,而是用了固定 16384 个哈希槽(hash slot)的方案。计算方式很直接:对 key 做 CRC16 校验,然后对 16384 取模,得到 0~16383 之间的一个数字,这个数字归哪个节点管,key 就存在哪个节点。
$$\text{slot} = \text{CRC16}(key) \bmod 16384$$
这个设计的好处在于「映射关系是显式的、可枚举的」。一致性哈希的好处是增删节点时迁移量小,但它的虚拟节点环维护复杂,而且节点之间的数据分布容易不均匀。哈希槽把「数据位置」变成了一张 16384 项的映射表,这张表每个节点都完整持有,客户端也可以缓存。扩容时你迁移的是「槽」而不是「key」,粒度可控,进度可观测,出问题能回滚。
那 16384 这个数字是怎么来的?这是社区里被问得最多的问题之一。核心原因是集群总线的心跳包要携带槽位归属信息。每个节点在 gossip 心跳里用 bitmap 表示自己负责哪些槽,16384 个槽就是 16384 bit,也就是 2KB。如果改成 65536 个槽,这个 bitmap 就变成 8KB,心跳包体积翻四倍,在几百个节点的大集群里,光心跳就能吃掉可观的带宽。另一方面,16384 个槽配到几十上百个节点上,每个节点也还能分到几百个槽,粒度足够细,分布足够均匀。所以这是一个「带宽成本」和「分布粒度」之间的折中值,不是随便定的。
集群规模的上限也来自这里。官方建议 Cluster 的节点数不要超过 1000,实践中我更保守,主节点控制在 3~30 个之间。原因很实在:gossip 协议是 O(N) 的消息扩散,节点越多,每个节点每秒处理的 ping/pong 消息越多,CPU 和带宽占用呈平方级增长。我见过一个跑到 60 多个主节点的集群,CLUSTER INFO里cluster_stats_messages_received每秒上万,节点本身的业务 QPS 反而不高,等于把一半资源喂给了集群自身的通信。
2.2 槽的三种状态与数据分布的可控性
集群里每个槽只有三种归属状态:被某个节点负责、正在迁移中、无人负责。CLUSTER INFO里的cluster_slots_assigned就是已分配槽的数量,cluster_slots_ok是正常服务的数量,cluster_slots_pfail和cluster_slots_fail是需要重点盯的指标。只要cluster_slots_assigned不等于 16384,集群状态就是fail,客户端会直接报CLUSTERDOWN Hash slot not served。这是新手最容易踩的第一个坑:集群建起来了,节点都在,但槽没分完。
手动控制槽的分配是理解 Cluster 的关键。下面这些命令我在排查和演练时用得最多:
# 查看集群整体状态与槽分配情况 redis-cli -p 7000 cluster info redis-cli -p 7000 cluster nodes # 查看某个 key 落在哪个槽 redis-cli -p 7000 cluster keyslot user:1001 # 手动把槽指派给节点(node-id 从 cluster nodes 里取) redis-cli -p 7000 cluster addslots 0 1 2 3 redis-cli -p 7000 cluster delslots 0 1 2 3 # 让集群自动平衡(慎用,会在业务高峰期产生迁移流量) redis-cli --cluster rebalance 127.0.0.1:7000 --cluster-use-empty-masterscluster nodes的输出信息量很大,值得逐字段拆开看。一行大概是这个格式:
<node-id> <ip:port@cport> <flags> <master-id> <ping-sent> <pong-recv> <config-epoch> <link-state> <slot> <slot> ...flags里的组合最能反映问题:myself,master表示当前节点自己是主节点;fail?是 pfail,表示疑似下线,还需要其他节点投票确认;fail是确认下线;handshake表示正在握手还没加入集群;noaddr表示地址未知。我排查时会先扫一遍有没有fail?和fail,再看link-state是不是connected,最后核对槽区间有没有重叠或缺漏。
槽的迁移过程有一个细节必须理解:迁移中的槽,旧节点和新节点会同时「认领」。旧节点上还有没迁走的 key,新节点上已经有迁过来的 key,客户端访问时就会收到ASK重定向。这个机制保证了迁移过程中的可用性,但也意味着迁移不是原子操作,中途中断会留下一个「半迁移」的槽,需要人工介入清理。这一点我在第 6 节会详细展开。
3. 手把手搭一套三主三从集群
3.1 配置文件里必须写对的几行
我用单机多实例的方式演示,端口 7000~7005,实际生产一般是 6 台独立机器或者 3 台每台两个实例。配置文件里跟集群相关的参数就那么几个,但每一个写错都能让你卡半天。
# redis-7000.conf 的关键配置 port 7000 daemonize yes pidfile /var/run/redis-7000.pid logfile /var/log/redis/redis-7000.log dir /data/redis/7000 # 集群三件套,缺一不可 cluster-enabled yes cluster-config-file nodes-7000.conf cluster-node-timeout 15000 # 网络与地址通告,多网卡环境下必配 bind 0.0.0.0 protected-mode no cluster-announce-ip 192.168.10.11 cluster-announce-port 7000 cluster-announce-bus-port 17000 # 持久化按业务需要,但 AOF 建议开 appendonly yes appendfsync everysec这里有几个我踩过的坑,逐个说。
cluster-config-file是节点自己维护的集群状态文件,绝对不能手工编辑,也绝对不能被多个实例共用。它记录了这个节点视角下的整个集群拓扑、槽归属、epoch 编号。如果两个实例配了同一个文件路径,或者这个文件被误删,节点重启后会带着过期的拓扑信息回来,轻则被集群踢出,重则引发槽冲突。我的习惯是把它放在dir目录下,文件名带上端口号,并且和 RDB/AOF 放在一起统一备份。
cluster-node-timeout默认 15000 毫秒,很多人出于「快速发现故障」的心理改小到 3000 或 5000,这是很危险的做法。这个值同时决定了三件事:节点判定下线的等待时间、故障转移的触发时机、网络抖动时的容错窗口。设得太小,一次 GC 停顿或者网络瞬断就能让集群误判主节点挂了,触发无谓的切换;设得太大,主节点真挂了要等 15 秒才开始切,业务那边早就超时了。我的经验值是:同机房 10000~15000,跨可用区 20000~30000,具体要结合你的网络 P99 延迟来定,原则是「远大于正常网络抖动,小于业务可接受的中断时长」。
cluster-announce-ip这些通告参数,在 Docker、NAT、多网卡环境下是救命配置。节点之间是通过CLUSTER MEET交换地址的,如果它通告的是容器内的 172.x 地址,别的节点就连不上,集群会一直卡在handshake状态。我遇到过最折腾的一次,是一台机器上有内网和专线两张网卡,节点互相通告了错误的地址,cluster nodes里一堆noaddr,最后就是靠这几个参数钉死的。
3.2 建集群、验集群、模拟一次故障
六个实例都启动之后,用redis-cli --cluster create一条命令建起来。--cluster-replicas 1表示每个主节点配一个从节点,总共 6 个节点就是 3 主 3 从。
redis-cli --cluster create \ 192.168.10.11:7000 192.168.10.11:7001 192.168.10.11:7002 \ 192.168.10.11:7003 192.168.10.11:7004 192.168.10.11:7005 \ --cluster-replicas 1执行过程中会打印出槽的分配方案,3 个主节点分别是 0-5460、5461-10922、10923-16383。这里 16384 除以 3 是 5461.33,不能整除,所以前两个主各拿 5461 个槽,最后一个拿 5462 个,差一个槽,完全不影响。
注意:这个 create 命令会先做一次集群检查,如果某个节点上已有数据,或者
cluster-config-file已经存在旧拓扑,它会直接拒绝。演练时经常忘了清dir目录,导致一直报Node xxx is not empty。
建完之后,第一件事不是急着压测,是验证。
# 1. 看整体状态 redis-cli -p 7000 cluster info | grep -E "cluster_state|cluster_slots_assigned|cluster_known_nodes|cluster_size" # 期望输出 # cluster_state:ok # cluster_slots_assigned:16384 # cluster_known_nodes:6 # cluster_size:3 # 2. 看拓扑,确认每个主都有对应的从 redis-cli -p 7000 cluster nodes # 3. 写几个 key 验证路由 redis-cli -c -p 7000 set user:1001 "hello" redis-cli -c -p 7000 get user:1001注意-c这个参数,它代表 follow redirects,客户端会自动跟随 MOVED 跳转。不加-c的话,一旦 key 不在 7000 这个节点上,你会收到(error) MOVED 12345 192.168.10.11:7001。很多人第一次看到这个报错以为是集群坏了,其实这是 Cluster 的正常工作方式。
然后是节点的下线演练,这一步一定要在预发环境做过。手动把某个主节点 kill 掉,观察集群多久完成切换:
# 杀掉一个主节点 redis-cli -p 7001 shutdown nosave # 立刻在另一个节点上反复观察 watch -n 1 'redis-cli -p 7000 cluster nodes | grep -E "fail|master"' # 关键日志,能看到完整的选举与晋升过程 tail -f /var/log/redis/redis-7002.log | grep -i "failover\|election\|promoted"正常情况下,cluster-node-timeout到期后,从节点会发起选举,拿到多数主节点的投票后晋升为新主,接管原主的槽,全程大概 10~20 秒。这个过程中的写请求会失败,客户端会收到CLUSTERDOWN或者连接错误。这个中断窗口是 Cluster 固有的,任何声称「零中断」的说法都是不准确的。如果真的要做到业务无感,需要在客户端侧做重试和降级,这部分放到第 4 节讲。
演练完记得把挂掉的节点拉起来,它会自动以从节点身份重新加入,并且开始同步新主的数据。如果发现它没有自动加入,检查cluster-config-file里记录的旧拓扑是不是和当前集群冲突了,必要时删掉这个文件让它重新握手。
4. 客户端路由:MOVED、ASK 和 smart client 的门道
4.1 两个重定向,含义完全不同
Cluster 模式下客户端有两条路可走。一条是「笨客户端」,随便连一个节点,收到重定向再跳转,redis-cli -c就是这么干的。另一条是「smart client」,启动时执行CLUSTER SLOTS(新版用CLUSTER SHARDS)把槽位映射全量拉到本地缓存,之后请求直接发到正确的节点,只有映射变更时才刷新缓存。生产环境毫无疑问要用后者,Jedis、Lettuce、redis-py 的 cluster 模式都支持。
但即使是 smart client,也一定会遇到重定向,因为映射缓存总会过期。这里有两个错误码,含义完全不同,混淆了就会写出错误的客户端逻辑。
| 错误码 | 出现场景 | 客户端应该怎么做 | 是否更新槽缓存 |
|---|---|---|---|
MOVED <slot> <ip:port> | 槽的归属已经永久变更,比如故障转移、扩容完成 | 重发请求到新地址 | 是 |
ASK <slot> <ip:port> | 槽正在迁移中,这个 key 恰好在目标节点上 | 先发ASKING,再重发请求 | 否,只对本次请求生效 |
CLUSTERDOWN | 集群状态为 fail,槽没分配完或多数主不可达 | 直接失败或按策略重试 | 不适用 |
CROSSSLOT | 一次操作涉及的多个 key 不在同一个槽 | 改造 key 设计或用 hash tag | 不适用 |
TRYAGAIN | 多 key 操作涉及迁移中的槽,部分 key 在旧节点 | 短暂等待后重试 | 否 |
ASKING这个命令的作用,是告诉目标节点「我知道这个槽还不归你,但这个 key 已经迁过来了,这次请求你处理一下」。它是一次性的,只对紧随其后的那一条命令生效。为什么需要这个机制?因为迁移中的槽如果同时被两个节点接受写入,会破坏数据一致性。ASKING相当于一次临时的授权。
这里有个实操细节:如果你手动用redis-cli测试迁移中的槽,必须连着发两条命令,而且ASKING之后不能有任何其他命令插进来,否则又得重新ASKING。我一般直接用-c参数,客户端会自动处理。
4.2 客户端必须做好的三件事
第一,刷新策略要正确。Lettuce 默认在收到 MOVED 时会重连并刷新拓扑,Jedis 3.x 之后也支持自动刷新。但有些老版本客户端只在启动时拉一次映射,主从切换后就一直报错,必须重启应用才能恢复。这个问题我遇到过一次,现象是故障转移后部分请求持续命中旧主,日志里全是READONLY You can't write against a read only replica,最后是靠升级客户端版本加显式配置拓扑刷新周期解决的。上线前一定要用CLUSTER FAILOVER手动切一次,验证客户端能不能自动恢复。
第二,超时要分层设置。连接超时、读超时、重试次数、重试间隔,这四个参数要一起考虑。集群故障转移的窗口是十几秒,如果读超时设成 2 秒、重试 3 次,业务侧大概 6 秒后就彻底失败了。合理做法是让重试覆盖住转移窗口,比如读超时 1000ms、最多重试 15 次、指数退避,同时在业务层做一个「读取降级到本地缓存」的兜底。
第三,multi-key 操作必须改造。Cluster 下不存在「一个命令原子地操作多个节点」这种事。以下这些操作全部受限:
MGET、MSET涉及多个 key,必须要求同槽,否则客户端直接抛CROSSSLOTMULTI/EXEC事务的所有 key 必须同槽- Lua 脚本里用到的所有 key 必须同槽,而且必须通过
KEYS[1]、KEYS[2]声明,不能自己在脚本里拼 key 名 SUNIONSTORE、ZUNIONSTORE这类跨集合操作,要求源和目标都同槽KEYS *、SCAN、FLUSHALL只能作用于单个节点,要遍历整个集群得自己循环所有主节点
真正能用的技巧是 hash tag。用花括号把 key 的一部分包起来,CRC16 只计算花括号里的内容,就能把一组 key 强制打到同一个槽:
# 这三个 key 都会落到同一个槽,因为只对 {order:8891} 做 CRC16 order:{order:8891}:info order:{order:8891}:items order:{order:8891}:amount # 验证一下 redis-cli -p 7000 cluster keyslot "order:{order:8891}:info" redis-cli -p 7000 cluster keyslot "order:{order:8891}:items"hash tag 用得爽,但要克制。如果某个业务对象的所有 key 都打上同一个 tag,你就等于在集群里造了一个「单点热点」,这个槽所在节点的内存和 QPS 会远超其他节点,集群的意义就没了。我的原则是:只有确实需要原子操作的 key 组才用 tag,而且 tag 的基数要足够大(比如按订单号、按用户 ID 分),保证槽的分布依然均匀。
5. 故障转移:集群自己怎么选新主
5.1 从 pfail 到 failover 的完整链路
理解故障转移,才能理解为什么有时候「明明机器还活着,集群却把它踢了」。整个过程分四步。
第一步是主观下线(pfail)。节点 A 在cluster-node-timeout时间内没收到节点 B 的 pong 回复,就在自己的视角里把 B 标记为fail?,然后在 gossip 心跳里把「我认为 B 挂了」这个消息带上。
第二步是客观下线(fail)。当集群里超过半数的主节点都认为 B 挂了,B 才会被标记为fail。注意是主节点的多数,从节点的意见不计入。这个设计是为了防止单个节点的网络问题导致误判。
第三步是从节点发起选举。B 的从节点发现自己主节点挂了,且自己与主节点断连的时间没超过cluster-node-timeout × cluster-replica-validity-factor(默认 factor 是 10,也就是 150 秒),就向所有主节点拉票。主节点在同一个 epoch 内只会投给第一个请求者。
第四步是晋升与接管。拿到多数票的从节点晋升为主节点,接管原主的全部槽,并通过心跳把新拓扑扩散到全网。
这里有一个容易被忽略的竞争条件:从节点的数据新鲜度。选举时,主节点会优先投给复制偏移量(replication offset)更靠前的从节点。所以如果你有一个主配了三个从,其中一个从因为网络问题长期同步滞后,它几乎不可能当选。这也意味着,从节点的同步状态本身就是可用性的一部分,INFO replication里的master_link_status和slave_repl_offset要纳入监控。
还有一个参数cluster-migration-barrier,默认值是 1。它的作用是「当某个主节点没有任何从节点时,允许一个有多个从的主节点分一个从给它」。这个机制叫 replica migration,是 Cluster 自愈能力的一部分。如果主节点数量不多但每个都配了从,建议保持默认值 1,让集群自己平衡。
5.2 两个常见误判场景与收敛方法
场景一:主节点假死被降级。现象是业务侧突然出现大量READONLY写入失败,日志里显示某个节点从 master 变成了 replica,但机器好好的。原因通常是这个主节点执行了一次长耗时的操作,比如大 key 删除、BGREWRITEAOF、SAVE,导致它阻塞超过cluster-node-timeout,没有及时回复心跳。解决办法不是简单调大 timeout,而是根治慢操作:用UNLINK替代DEL,把 AOF rewrite 放到从节点做,关掉定时SAVE,改用从节点备份。
场景二:网络分区导致集群永久 fail。假设集群 3 主 3 从跨两个机房,某天机房之间的链路断了,一侧有 2 主 1 从,另一侧有 1 主 2 从。少数派那一侧的 1 个主节点收不到多数派的心跳,会主动拒绝写入(cluster-node-timeout之后进入fail状态),少数派那侧的从节点也拉不到票,无法晋升。这是设计上的正确行为,Cluster 用的是多数派原则,宁可让少数侧不可写,也不允许脑裂。但如果链路恢复后集群仍然是 fail,那就需要人工介入了。常用的收敛手段是在故障侧节点上执行CLUSTER RESET SOFT让它清空本地拓扑重新加入,硬重置(HARD)会清空数据,只能用在从节点上。
我处理这类问题时会先看CLUSTER INFO里的cluster_current_epoch和cluster_my_epoch。如果某个节点的 epoch 远小于集群当前 epoch,说明它的拓扑信息是过期的,直接重置它比修别的更快。
注意:生产环境一定要在机房维度上做规划。3 个主节点如果全在同一个机架、同一台交换机下,那么这个交换机就是整个集群的单点。合理布局是 3 个主分布在 3 个故障域,从节点和主节点交叉放置。
6. 在线扩缩容与槽迁移实操
6.1 扩容的两种姿势:自动 reshard 与手动迁移
扩容的完整流程是:先加节点(空节点,槽数为 0),再把槽挪过去。加节点有两条命令,一条是CLUSTER MEET,一条是--cluster add-node,后者封装了前者加握手等待。
# 新增一个主节点 7006 和一个从节点 7007 redis-cli --cluster add-node 192.168.10.12:7006 192.168.10.11:7000 redis-cli --cluster add-node 192.168.10.12:7007 192.168.10.11:7000 \ --cluster-slave --cluster-master-id <7006的node-id>新节点加进来时槽数是 0,cluster nodes里它的 flags 是master,后面没有任何槽区间。这时候集群状态仍然是 ok 的,因为槽确实全被分配了。接下来才是重头戏:迁移槽。
自动方式用--cluster reshard,交互式会问你迁多少个槽、从哪些节点迁、迁给谁。生产环境我更倾向于把参数全写死,避免手抖:
redis-cli --cluster reshard 192.168.10.11:7000 \ --cluster-from <源node-id1>,<源node-id2>,<源node-id3> \ --cluster-to <目标node-id> \ --cluster-slots 4096 \ --cluster-yes \ --cluster-pipeline 10 \ --cluster-timeout 30000几个参数值得说明。--cluster-pipeline 10表示迁移时一次批量取 10 个 key,这个值设大了会占用源节点的输出缓冲区,设小了迁移慢,10 是我实测比较均衡的值。--cluster-timeout 30000是单个MIGRATE命令的超时,如果槽里有大 key,这个值要往上调,否则会反复超时重试。
手动迁移单个槽的流程更能体现原理,我建议每个运维都至少手动做过一遍:
# 假设把 slot 100 从 A(旧节点id=aaa) 迁到 B(新节点id=bbb) # 1. 在目标节点 B 上标记:我要接管这个槽 redis-cli -h B -p 7006 cluster setslot 100 importing aaa # 2. 在源节点 A 上标记:我要迁走这个槽 redis-cli -h A -p 7000 cluster setslot 100 migrating bbb # 3. 循环取出这个槽上的 key 并逐个迁移 redis-cli -h A -p 7000 cluster getkeysinslot 100 100 # 拿到 key 列表后批量迁移 redis-cli -h A -p 7000 migrate B_IP 7006 "" 0 5000 KEYS key1 key2 key3 # 4. 确认槽上没 key 了,在双方都执行归属确认 redis-cli -h B -p 7006 cluster setslot 100 node bbb redis-cli -h A -p 7000 cluster setslot 100 node bbb第 4 步的SETSLOT ... NODE要在所有相关节点上执行,不只是新旧两个节点。因为其他节点的槽映射还需要通过心跳慢慢扩散,而这两个节点是立刻变更的。如果只在一个节点上执行,会出现「两个节点都认为自己拥有这个槽」的冲突状态,触发槽冲突修复机制。
MIGRATE这个命令有几个细节:目标地址必须写 IP,不能写主机名;AUTH参数在集群模式下一般不需要,因为节点之间用集群总线认证;REPLACE参数用于覆盖目标端已有的同名 key,正常迁移不应该需要它,如果需要了说明数据有脏写,要查原因。
6.2 迁移过程中最容易翻车的五个点
大 key 阻塞。迁移一个 500MB 的 hash,MIGRATE会用DUMP+RESTORE的方式整块搬运,源节点在这个过程中是阻塞的,业务会出现秒级抖动。根治办法是提前拆分大 key,或者改用UNLINK分批删。我在迁移前一定会跑一遍大 key 扫描:redis-cli --bigkeys先粗筛,再对可疑的 key 用MEMORY USAGE <key>精确看。
客户端映射过期。迁移中的槽会产生大量ASK重定向,如果客户端对ASK处理不正确(比如错误地更新了缓存),会在迁移完成后继续往旧节点发请求,表现为持续的MOVED循环。看到这个现象,先检查客户端版本和缓存刷新逻辑,别急着怀疑集群。
迁移到一半中断。运维脚本 Ctrl+C 或者网络抖动,导致槽处于「importing / migrating」状态但没完成。这时候cluster nodes里能看到这两个节点都标记了这个槽,需要手动清理:在新节点执行CLUSTER SETSLOT <slot> STABLE,在旧节点执行同样的命令,槽会退回给旧节点,数据不会丢。
slot 相关的拒绝服务。迁移期间,如果某个 key 刚被迁走,源节点上其实还能通过ASKING访问到一些残留 key。这种状态下,多 key 操作会返回TRYAGAIN。客户端要能识别这个错误并做短暂重试,不能当成致命错误。
忘记迁移从节点。新加的主节点 7006 有了槽,但 7007 这个从节点还没跟上,这时候 7006 挂了,这批槽就会彻底不可用。加从节点的动作必须在迁槽之前完成,而且迁完之后要检查INFO replication里 7007 的master_replid是不是和 7006 一致。
7. 高频故障与排查实录
这一节我把这几年踩过的坑整理成一张速查表,后面再挑几个展开讲。
| 报错/现象 | 根因 | 排查命令 | 处理方式 |
|---|---|---|---|
CLUSTERDOWN Hash slot not served | 槽没分配完,或多数主节点失联 | cluster info看cluster_slots_assigned | 补齐槽分配,恢复失联节点 |
CROSSSLOT Keys in request don't hash to the same slot | 多 key 操作跨槽 | cluster keyslot key1、cluster keyslot key2 | 用 hash tag 改造 key 设计 |
MOVED循环刷新 | 客户端拓扑缓存更新异常 | 抓包看请求目标节点 | 升级客户端,显式配置刷新 |
READONLY You can't write against a read only replica | 请求打到了从节点,或主节点被降级 | INFO replication看 role | 检查客户端拓扑,或排查假死 |
| 集群状态长期 fail | 网络分区后少数派无法收敛 | cluster nodes看 flags、epoch | CLUSTER RESET SOFT重置异常节点 |
| 某个槽数据量暴涨 | hash tag 使用过度,分布不均 | cluster countkeysinslot逐个对比 | 拆分 tag,重新设计 key |
| 迁移超时 | 大 key 或网络问题 | MEMORY USAGE、CLUSTER GETKEYSINSLOT | 拆大 key,调大 timeout |
| 写入偶发超时 | 节点阻塞(BGREWRITEAOF / 大 key 删除) | SLOWLOG GET 20、LATENCY DOCTOR | 用 UNLINK,rewrite 放从节点 |
7.1 CLUSTERDOWN 的三类成因
第一类是槽没分完。建集群时中途失败,或者有人手动执行了CLUSTER DELSLOTS。这种情况最简单,把缺的槽补上即可,redis-cli --cluster fix 127.0.0.1:7000能自动修复大部分不一致。
第二类是多数主节点不可达。比如 3 主里挂了 2 个,剩下的 1 个凑不出多数票,整个集群进入 fail,即使还有部分节点活着也不提供服务。这是多数派协议的代价。应对办法是至少部署 3 个主节点,并且监控要能在第一个主挂掉时就报警——因为从 3 个挂到 2 个就已经失去容错能力了(好消息是 3 主里挂 1 个仍然可用,2 主 2 从的集群里挂 1 个主就只剩 1/2,等于半数,勉强可用但没有任何冗余了,所以 3 主是最低配置)。
第三类是cluster-require-full-coverage配置的影响。这个参数默认是yes,含义是「只要有一个槽不可用,整个集群就拒绝服务」。改成no的话,只有出问题的槽返回错误,其他槽正常服务。这听起来很诱人,但我不建议轻易改:改成no之后,故障变得「安静」了,可能有一批业务一直报错而没人发现,监控不告警的话,问题会拖很久。是否要改,取决于你的业务能不能接受「部分 key 不可用但整体不中断」。我的做法是保持默认yes,把监控压在cluster_state这个指标上,fail立刻告警。
补充一个排查技巧:
redis-cli --cluster check <ip:port>会一次性把拓扑、槽覆盖、数据一致性全检查一遍,输出比cluster nodes直观得多。新接手一个集群,先跑这一条,心里就有底了。
7.2 事务、Lua 和 Pipeline 在集群下的改造
事务:MULTI/EXEC的所有 key 必须同槽,而且客户端必须把事务整体发到同一个节点。Jedis 的 cluster 模式会自动校验,不满足就直接抛异常。如果业务上确实需要跨对象的事务,只能上分布式锁或者用业务补偿,Cluster 本身给不了。
Lua 脚本:除了 key 必须同槽,还有一个隐藏要求——脚本里用到的所有 key 必须通过KEYS数组传入,不能靠字符串拼接生成 key。因为 Redis 需要提前解析出所有 key 来判断槽的归属。我见过一个脚本用KEYS[1]..':lock'拼了个新 key,单机跑得好好的,上集群直接报CROSSSLOT。改造办法是把:lock这个 key 也作为参数传进去,或者用 hash tag 让两者同槽。
Pipeline:这个坑最隐蔽。Pipeline 里混合了多个槽的 key 时,客户端无法把它们发到同一个节点,Jedis 会直接报错,Lettuce 则是按节点分组后分别发送——后者看起来能用,但批量返回的结果顺序会乱。所以 Pipeline 在集群下要自己按槽分组:先算每个 key 的槽,按目标节点分桶,每个桶单独发一个 Pipeline,最后按原顺序合并结果。虽然麻烦,但这是唯一正确的做法。
# 按目标节点分桶的一个思路示意 from collections import defaultdict def group_by_node(keys, slot_cache, node_of_slot): buckets = defaultdict(list) for k in keys: slot = slot_cache[k] buckets[node_of_slot[slot]].append(k) return buckets8. 落地选型与几条不写在文档里的经验
8.1 Cluster、哨兵、代理方案怎么选
把三种方案放在一起对比,选择就清晰了。
| 维度 | 主从 + 哨兵 | Redis Cluster | 代理分片方案 |
|---|---|---|---|
| 数据容量上限 | 单机内存 | 横向无限(受节点数限制) | 横向扩展 |
| 客户端复杂度 | 低(哨兵地址即可) | 高(需支持重定向) | 低(当成单机用) |
| 跨节点多 key 操作 | 支持 | 不支持(需同槽) | 支持(代理层做归并) |
| 故障转移时间 | 10~30 秒 | 10~20 秒 | 取决于组件自身 |
| 运维复杂度 | 低 | 高 | 中(多一层要维护) |
| 数据一致性 | 强(单机) | 最终一致(异步复制) | 取决于分片策略 |
| 适用规模 | 数据 < 50GB | 数据大、写并发高 | 想屏蔽分片细节 |
我的判断逻辑是:如果数据量在单机能放下的范围内,永远优先选哨兵,能省下大量精力和故障面。如果确实超了,才考虑 Cluster。代理方案现在用得少了,因为它多一跳网络、多一个要自己维护的组件,而且分片逻辑一旦写死,扩容比 Cluster 还麻烦。但它的一个优势仍然存在——业务代码完全不用改,对于老系统改造是个务实的选择。
还有一个常被忽略的问题:Cluster 只能使用 db0。SELECT命令在集群模式下直接报错。如果你的应用依赖多 db 做环境隔离(比如 db0 生产、db1 测试),迁移到 Cluster 时必须改成用 key 前缀隔离。这个改造量往往比想象中大,因为前缀会改变 key 的槽位分布,可能引发热点。
8.2 我的几条实践经验
单节点内存别超过 16GB。这不是 Redis 的限制,是运维经验的总结。节点越大,fork 做持久化时的内存开销越大、全量同步越慢、故障恢复越久。宁可多分几个片,也不要造一个 64GB 的巨型节点。
主从必须跨故障域。3 主 3 从如果全在一台物理机上,那这台机器就是单点。理想布局是 3 个可用区各放 1 主 1 从,且主从交叉——A 区的主,从在 B 区;B 区的主,从在 C 区;C 区的主,从在 A 区。
监控这几个指标就够了。cluster_state(必须是 ok)、cluster_slots_assigned(必须 16384)、cluster_known_nodes(不该无故变化)、每个节点的connected_clients、used_memory与maxmemory的比值、master_link_status、keyspace_misses。前两个是生死线,后面几个是预警线。
定期做故障演练。每季度至少手动 kill 一次主节点,验证三件事:集群能否自动切换、客户端能否自动恢复、监控能否正确告警。演练要记录时间线,我一般会记「kill 时刻 → pfail 时刻 → fail 时刻 → 新主晋升时刻 → 客户端首个成功请求时刻」,这条时间线就是你的真实 RTO。
备份不要在主节点做。BGSAVE会 fork,fork 在高内存占用下可能阻塞几百毫秒,足以触发一次误判。把备份任务指向从节点,并且在从节点上关掉 AOF(如果从节点只做备份用途),能减少不少麻烦。
版本对齐。集群里所有节点的 Redis 版本必须一致,至少大版本一致。混版本最典型的问题是CLUSTER SLOTS和CLUSTER SHARDS的返回格式差异,以及某些新命令在旧节点上不存在。滚动升级时,先升从节点,再CLUSTER FAILOVER切主,最后升原来的主——这个顺序可以保证全程有可用副本。
最后说一个我自己的体会。Cluster 最难的从来不是搭起来,而是在它出问题时能快速判断「这是集群的问题还是业务的问题」。我遇到过好几次业务方说「Redis 集群挂了」,一查发现是他们的 key 设计导致某个槽数据量是其他槽的 20 倍,那个节点内存打满触发了淘汰,业务读到空值就以为集群故障。这种问题用redis-cli --cluster check加cluster countkeysinslot逐个槽比对,一眼就能看出分布不均。所以我现在接手任何集群,第一件事就是拉一张「每个主节点的槽数、key 数、内存占用」的表,把这张表存成基线,后面所有的异常判断都拿它做参照。这张表比任何监控大盘都直观。