Redis集群MOVED异常:Jedis重定向排查与JedisCluster改造

1. 先搞清楚这个异常到底在说什么

去年做一次压测的时候,凌晨两点多监控面板上突然一阵红色,日志里刷得最快的一行就是redis.clients.jedis.exceptions.JedisMovedDataException: MOVED 8742 10.0.0.12:6379。当时业务侧的现象很简单:一部分请求正常,另一部分请求偶发 500,抛出的就是这个JedisMovedDataException。前后折腾了差不多一天才彻底理清,后来又在几个不同项目里反复踩过同类问题,所以这篇文章我打算把这件事从头到尾讲透——它是什么、为什么会冒出来、怎么在客户端和服务端两侧同时处理干净,以及我在实际改造过程中记录下来的参数选择和踩坑细节。不管你是刚接手一个老项目、第一次把单机 Redis 换成集群模式,还是已经在集群上跑了一段时间突然开始报这个错,下面的内容都能直接对照着排。

先把最基础的一层说清楚。Redis 集群把 16384 个哈希槽分散在多个主节点上,每个主节点只负责一部分槽。当客户端把一条命令发到了"不该管这个 key"的节点上,那个节点不会帮你转发,它只会回一个错误字符串,格式是-MOVED <slot> <ip>:<port>,意思是"这个槽归后面这个地址管,你去那边问"。Jedis 在解析服务端返回的错误前缀时,识别到MOVED这个标记,就把它包装成JedisMovedDataException抛给调用方。所以严格来讲,这个异常不是 Redis 坏了,也不是网络断了,它是一张"你找错人了,去隔壁窗口"的指路条,只不过客户端要么没接住这张条子,要么压根没打算按条子走。理解这一点非常关键,因为它决定了后面所有的排查方向:问题几乎总是出在"客户端有没有能力跟随重定向"上,而不是出在"服务端凭什么重定向"上。

1.1 一个真实感很强的翻车现场

我遇到过最典型的一次,是一个运行了两年多的老服务。早期它是单机 Redis,代码里用的是JedisPool,包了一层工具类,全项目几百处调用都走这个工具类。后来数据量涨了,运维把实例换成了三主三从的集群模式,连接地址给了一个 VIP,应用侧只改了一行配置文件,把 IP 换掉就上线了。头两天没出问题,因为压测流量小、命中的 key 恰好落在客户端连上的那个节点负责的槽里。等到正式放量,大概是原先流量的六倍,立刻开始零星报JedisMovedDataException,而且是"这个 key 报、那个 key 不报",很有迷惑性。

迷惑点在于:很多人第一反应是"某个 key 有问题",于是拿着那个 key 去查数据、查过期时间、查是否有大 key。其实跟 key 本身毫无关系。JedisPool底层的Jedis对象是一条直连某个节点的普通连接,它完全不知道集群拓扑的存在,也没有槽位映射表。它把命令发出去,对方回MOVED,它除了把异常往上抛之外没有别的选择。所以命中的 key 是不是"有问题"根本不重要,重要的是这个 key 归哪个槽、这个槽归哪个节点、而你的连接连的是哪个节点——三者不一致就会报,一致就不报。这就解释了为什么现象看起来是随机的。

一个快速验证的方法:把报错日志里的 key 拿出来,用redis-cli -c -h <节点> -p <端口> get <key>试一下。带-c参数时客户端会自动跟随重定向,如果能正常取到值,基本可以确定就是客户端没有跟随重定向的能力,而不是数据或集群本身出了故障。

1.2 MOVED 和 ASK 不是一回事,别混着处理

搞这个异常绕不开的另一个概念是ASK。它俩长得很像,格式都是"标记 + 槽号 + 目标地址",但语义完全不同,处理方式也不能一样。MOVED表示槽的归属已经永久变更了,客户端应该更新本地缓存的槽位映射表,以后这个槽的所有请求都发到新地址去。ASK表示槽正在迁移过程中,这个 key 已经被搬到了目标节点,但槽本身还没正式交割,客户端这次请求需要先给目标节点发一个ASKING命令,再发真正的命令,而且不能更新本地缓存——因为迁移结束后这个槽可能还会回到原节点,也可能去别的地方。

Jedis 里对应的是JedisMovedDataException和JedisAskDataException,两者都继承自JedisRedirectionException,都能通过getTargetNode()拿到目标地址。用JedisCluster的时候这两种情况它内部都处理掉了,你基本看不到;用裸Jedis的时候两种都会抛出来。区分它们很重要,因为处理策略不一样:MOVED是"改地址簿",ASK是"临时问一嘴"。如果在自己的重试逻辑里把ASK当成MOVED去刷新缓存,在槽迁移期间会造成缓存频繁抖动,严重的会让客户端在迁移窗口里反复横跳,延迟曲线拉出一个锯齿。

1.3 这个异常家族的成员对照

排查的时候光盯着一个异常名容易走偏,我把 Jedis 在集群场景下常见的几个异常整理成一张表,遇到问题时先对号入座,能省掉大量猜测时间。

异常类触发场景本质原因处理方向
JedisMovedDataException命令发到了非槽主节点客户端不知道槽位归属换JedisCluster或刷新拓扑
JedisAskDataException槽迁移中,key 已搬到目标节点迁移窗口期的临时重定向发ASKING后重试,不刷缓存
JedisNoReachableClusterNodeException启动时所有种子节点都连不上地址、端口、密码、网络、安全组检查连通性与认证配置
JedisClusterMaxAttemptsException重试次数耗尽仍失败重定向次数超限或集群持续抖动调大maxAttempts,同时查集群健康度
JedisConnectionException连接超时、连接被重置网络、连接池耗尽、服务端拒绝查池参数、超时、服务端连接数
JedisDataException: NOAUTH密码缺失或错误认证失败配置密码,与 MOVED 无关

表格里最后一行特意放进来,是因为我见过不止一次有人把NOAUTH也当成"集群配置问题"去查拓扑。认证失败和槽位重定向完全是两条线,先把异常 message 看清楚再动手,比什么都重要。

2. 根因拆解:四条最常见的触发路径

JedisMovedDataException表面上只有一种表现,背后其实是四五条完全不同的路径。搞清楚自己的项目落在哪条上,改造方案才有意义。下面这四条是我这些年在不同项目里实际遇到过的,按出现频率从高到低排。

2.1 用单机客户端连集群:新手最常踩的第一脚

这是绝对的高频场景,没有之一。表现为:应用启动正常,小流量下一切正常,流量一上来就开始报,而且报错的 key 分布看起来很随机。根因就是前面说的,JedisPool提供的是直连连接,不具备槽位路由能力。

很多人会问,那我配了多个节点地址行不行?比如spring.redis.cluster.nodes里写了一串地址,是不是就行了?这里有个容易被忽略的细节:在 Spring Boot 里,spring.redis.cluster.nodes这个配置项只对集群模式的客户端生效,如果你同时保留了spring.redis.host和spring.redis.port,某些版本的自动配置会因为属性优先级或条件注解的判断,最终装配出来的是一个单机连接工厂。配置文件里两套属性并存、看起来"都配了应该没问题",实际生效的却是单机那一套,这个坑我至少见过三次,每次都是靠打印实际注入的 Bean 类型才定位到。

还有一种变体:项目里同时引入了两个 Redis 客户端依赖,一个是比较新的、支持集群的,一个是老的工具类依赖,业务代码里图省事直接用了老工具类。这种情况下编译不报错、启动不报错,只在特定 key 上出错,排查成本极高。我的建议是,接手任何项目先做一件事——把JedisPool、Jedis、JedisCluster这几个类名的引用全项目搜一遍,看清楚到底有几种客户端在跑。

2.2 拓扑变了,客户端还揣着旧地图

即便你用的是JedisCluster,这个异常也不是完全不会出现。JedisCluster内部维护一份槽位到节点的缓存,第一次连上时会从种子节点拉取CLUSTER SLOTS建立这张表。此后如果集群发生主从切换、节点扩容、槽迁移,这张表就过期了。JedisCluster的设计是"懒刷新":只有当你把一个请求发到了错误的节点、收到MOVED之后,它才会去刷新缓存然后重试。所以在拓扑变更的那一瞬间,你会看到少量MOVED相关的日志,这是正常的。

不正常的是两种情况。第一种,刷新频率过高,说明集群一直在抖,可能是有节点反复上下线,也可能是网络分区导致频繁选主。第二种,永远刷不过来,每次请求都在重定向,最后抛JedisClusterMaxAttemptsException。后者往往意味着客户端看到的地址和服务端返回的地址对不上——比如服务端返回的是内网真实地址,而客户端只能访问另一套地址,重定向之后连不上,重试几次耗尽。

较新版本的 Jedis 提供了周期性刷新拓扑的能力,配置项名字里带topologyRefresh之类的关键字,可以让客户端按固定间隔主动拉取一次槽位映射,而不是等到报错才被动刷新。这个能力在集群经常变动的环境里很有价值,具体配置项名称和可用性以你手上那个版本的官方说明为准,各小版本之间有差异,不要照抄博客里的写法。我自己的做法是先确认版本,再去看对应版本的构造器和配置类,能省掉一堆编译不过的麻烦。

2.3 槽迁移窗口期,正常重定向被当成故障

集群扩容或者缩容的时候,运维要把一部分槽从一个节点搬到另一个节点。迁移过程是逐步的:先标记源节点的槽为MIGRATING、目标节点的槽为IMPORTING,然后逐个 key 搬过去,全部搬完之后才正式交割。在这个窗口里,客户端访问已经搬走的 key 会收到ASK,访问还没搬的 key 正常返回,访问已经交割完成的槽会收到MOVED。

这个阶段的异常量取决于两件事:迁移的 key 数量和客户端的处理能力。如果一次迁几十万个 key,窗口可能持续十几分钟,客户端会一直处在重定向状态。用JedisCluster的话这些都被吞掉了,你只会在监控上看到延迟的小幅上升;用裸Jedis的话就是满屏异常。我一般建议把大规模槽迁移安排在业务低峰,并且分批做,一次迁一两个槽,观察一轮再继续。图快一次性迁完,客户端扛不住,反而更容易出事。

还有一种更隐蔽的情况:迁移做了一半,操作用的会话断开了,槽卡在MIGRATING状态没人收尾。这时候集群状态其实是不完整的,客户端会持续收到ASK,表现为某些 key 的访问延迟明显高于其他 key。这种情况得靠CLUSTER NODES的输出里有没有残留的[slot->-nodeid]标记来发现,正常情况下这个标记应该很快消失。

2.4 中间挂了透明代理,地址对不上

这个坑比较高级,但踩到就很疼。有些环境会在 Redis 集群前面放一层四层代理做统一入口,客户端连的是代理地址。问题在于,服务端返回的MOVED里带的地址是节点自己的真实地址,不是代理地址。客户端拿到这个地址去连,如果网络不通,就会变成连接超时;如果勉强通了但走的不是同一条路径,就会在重定向和超时之间反复横跳,日志里异常和超时交替出现。

判断方法很简单:把MOVED后面那个地址抄下来,在应用服务器上直接telnet <ip> <port>试一下,能不能通、通到的是不是你以为的那个东西。如果不通或者通到的不是同一个服务,那就别在客户端层面折腾了,先解决地址可达性问题。稳妥的做法是让客户端直连所有集群节点,或者在代理层做完整的地址映射,保证服务端返回的地址在客户端侧是可路由的。

3. 方案选型:三条路,各自的代价

知道原因之后,可选的处理方式其实就那么几条,各有各的适用场景和代价。我按照推荐程度排一下,顺便说说为什么不推荐某些看起来"很省事"的做法。

3.1 路线 A:换成 JedisCluster,一劳永逸

这是标准答案,也是我在所有新项目里的默认选择。JedisCluster内部维护槽位缓存、自动处理MOVED和ASK、自动重试、连接池按节点分别管理,你要做的就是正确初始化它并给它合适的参数。改造工作量取决于项目里有多少地方直接用了Jedis类型——如果原来就有统一的工具类封装,通常只需要改工具类内部实现,业务代码一行不动;如果是散落各处的直接调用,那就得先把调用收敛到统一入口,这个重构本身也是好事。

需要提醒的是,JedisCluster并不是"绝对不抛JedisMovedDataException"。在拓扑剧烈变动、缓存还没刷新过来的极短窗口里,理论上你仍然可能在日志里瞥见它,但JedisCluster内部会捕获并重试,正常情况下不会抛到你的业务代码。如果业务代码里 catch 到了这个异常,那说明重试次数已经耗尽,问题就不在客户端了,得去查集群。

3.2 路线 B:保留单机连接,手动跟随重定向

有些项目因为历史包袱太重,短期内换不了客户端,就想着在工具类里 catch 住JedisMovedDataException,解析出目标地址,再新建一条连接发过去。这个思路能跑通,但我把它归为"应急手段",不建议长期保留。

原因有三。第一,它只处理了MOVED,没处理ASK,槽迁移期间照样出问题;迁移中的 key 需要先发ASKING再发命令,少这一步就会收到新的重定向。第二,它没有缓存槽位信息,每次重定向都要新建连接,等于把集群的槽位映射每次都重新发现一遍,QPS 一高连接数就爆。第三,JedisMovedDataException.getMessage()的格式在不同版本间可能变化,靠字符串解析拿目标地址很脆弱,虽然getTargetNode()这个方法是稳定的,但很多人写代码时图省事直接切字符串。应急可以,别当成终态方案。

3.3 路线 C:服务端收敛,减少重定向的发生

前两条都是客户端侧的动作,其实服务端也有可以做的事。最直接的就是合理设计 key 的命名。Redis 集群支持 hash tag,用花括号把 key 的一部分括起来,只有花括号里的内容参与槽位计算。比如order:{10086}:detail和order:{10086}:status会落到同一个槽,这样就避免了"同一笔订单的两个 key 在不同节点、需要跨节点操作"的麻烦。

hash tag 用好了能解决很多问题:批量操作不用跨槽、Lua 脚本可以同时操作多个 key、事务能正常开。但它是一把双刃剑,用不好会造成数据倾斜——所有订单都打在同一个 tag 上,那部分槽全压在一个节点,其他节点闲着。设计的时候要把 tag 的粒度想清楚,是按用户 ID 分还是按订单号分,基数够不够大,热点会不会集中。

3.4 三条路线的对比

维度路线 A:JedisCluster路线 B:手动重定向路线 C:hash tag 收敛
改造工作量中(需收敛调用入口)小小到中(涉 key 设计)
是否处理 ASK自动处理需自己实现,易漏不影响
性能影响可忽略连接数上升明显视 tag 设计而定
长期可维护性好差好
适用场景所有集群环境紧急止血需要多 key 原子操作
我的推荐度首选仅应急配合 A 使用

实际项目里,路线 A 和路线 C 通常是搭配用的:客户端换JedisCluster解决重定向,服务端用 hash tag 解决跨槽操作。路线 B 只在"线上正在炸、今晚必须止血、改造来不及"的时候用,而且要在事后补上整改任务。

4. 实操:从零改造一个扛得住重定向的 Jedis 客户端

下面这部分是具体动手的内容。我用一个典型的三主三从集群做例子,假设节点地址是10.0.0.11:6379、10.0.0.12:6379、10.0.0.13:6379,带密码认证,应用侧用的是 Jedis 4.x 系列。不同小版本的构造器签名会有增减,我下面给的写法以我本机能编译通过的版本为准,你照抄之前先在 IDE 里看一眼构造器提示,参数含义是一致的,只是顺序或重载不同。

4.1 依赖与版本选择

版本这件事要单独说一句,因为它直接影响到你能用哪些能力。Jedis 3.x 时代的JedisCluster构造器参数用的是裸的int超时和字符串密码,配置项少、灵活性差;4.x 引入了JedisClientConfig,把超时、认证、SSL、客户端名这些统一收进一个 builder,代码干净很多;5.x 又进一步把集群相关的配置单独抽出来,支持周期刷新拓扑这类能力。跨大版本升级的时候,构造函数基本都会变,不要拿旧代码直接套新版本。

依赖本身没什么花头,关键是要确认项目里只有这一个 Redis 客户端依赖,别出现新旧并存。

<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.6</version> </dependency>

版本号你可以按自己项目的依赖管理来,我写这个只是给个参照。加完之后用mvn dependency:tree | grep jedis看一眼,确认没有别的传递依赖把老版本拽进来——这种情况在引入了第三方 SDK 的项目里很常见,最后跑起来的版本可能跟你在pom.xml里写的不一样。

4.2 初始化 JedisCluster 的正确姿势

先看代码,然后我逐段解释为什么这么写。

import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisCluster; import redis.clients.jedis.JedisClientConfig; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.Connection; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import java.time.Duration; import java.util.Arrays; import java.util.HashSet; import java.util.Set; public class RedisClusterFactory { public static JedisCluster build() { // 种子节点不用全配,但建议至少给 2 到 3 个 Set<HostAndPort> seeds = new HashSet<>(Arrays.asList( new HostAndPort("10.0.0.11", 6379), new HostAndPort("10.0.0.12", 6379), new HostAndPort("10.0.0.13", 6379) )); // 连接池配置,每个节点一份 GenericObjectPoolConfig<Connection> poolConfig = new GenericObjectPoolConfig<>(); poolConfig.setMaxTotal(64); poolConfig.setMaxIdle(16); poolConfig.setMinIdle(4); poolConfig.setMaxWait(Duration.ofMillis(1000)); poolConfig.setTestOnBorrow(true); poolConfig.setBlockWhenExhausted(true); // 客户端配置 JedisClientConfig clientConfig = DefaultJedisClientConfig.builder() .password("your-password") .connectionTimeoutMillis(2000) .socketTimeoutMillis(2000) .clientName("order-service") .build(); return new JedisCluster(seeds, clientConfig, poolConfig); } }

种子节点这里有个细节值得说:JedisCluster初始化时只要有一个种子节点能连上,就能拉取到完整的拓扑,所以理论上配一个就够。但我建议至少配两个,原因很实际——如果恰好配置的那个节点在应用启动时正在重启或者处在故障转移过程中,客户端直接启动失败,整个服务起不来。配三个基本能覆盖掉单节点故障的概率。反过来说,也不需要把所有节点都配上,配太多反而拉长初始化时的探测时间。

clientName这个参数很多人不填,其实挺好用的。填上之后,在服务端执行CLIENT LIST就能看到每条连接属于哪个应用,排查"到底是谁在疯狂重定向"的时候特别省事。生产环境上我一般会把服务名和实例编号拼进去。

至于setMaxTotal(64)这个值,别照着抄,得按你的实际情况算,下一节展开。

4.3 连接池与重试参数怎么算

连接池参数拍脑袋定是常态,但算一下其实不费事。基础公式是:需要的并发连接数 ≈ 峰值 QPS × 单次命令平均耗时(秒)。假设某个服务峰值 2000 QPS,单次GET平均 2 毫秒,那么同时处于"在途"状态的请求大约是 2000 × 0.002 = 4 条。乘一个 1.5 到 2 的冗余系数,再留一些给突发流量,maxTotal给到 16 到 32 就相当宽裕了。

很多人的直觉是"往大了配总没错",配到 200、500。这样做的后果是服务端连接数暴涨,Redis 单线程处理命令,连接多了上下文切换和内存开销都上去了,反而拉低整体吞吐。而且连接池太大还有一个隐患:服务端出问题时,大量连接同时超时重试,形成雪崩。我的经验值是按上面公式算出来之后再乘 2,够用就行,不够的话看监控里的numWaiters指标再调。

maxAttempts(重试次数)这个参数更值得细说,因为它直接和超时时间耦合。假设socketTimeout是 2000 毫秒,maxAttempts配成 10,那么最坏情况下一次调用要经历 10 次尝试,耗时可能接近 20 秒。如果你的接口本身超时预算只有 3 秒,那这个配置等于把线程池往死里拖——请求都堵在等 Redis 上,线程耗尽,整个服务不可用。所以我的一般建议是:maxAttempts给 3 到 5,配合 1 到 2 秒的socketTimeout,最坏情况可控在几秒内。真遇到集群大面积故障,快速失败比慢慢重试更有价值。

// 常见的参数组合,按接口的超时预算反推 int connectionTimeout = 1000; // 建连超时,内网可以短一些 int socketTimeout = 1500; // 读超时,按 P99 的 2 到 3 倍给 int maxAttempts = 4; // 3 到 5 之间,别贪大

参数之间存在联动关系,改一个必须回头看另一个。尤其是把maxAttempts调大的时候,一定要重新算一遍最坏耗时,别让 Redis 的重试把上游接口的超时预算吃光。

4.4 Spring Boot 项目的配置落地

如果用 Spring Boot 的自动配置,配置项要先确认版本。2.x 系列是spring.redis.cluster.*,3.x 系列改成了spring.data.redis.cluster.*,写错前缀不会报错,只是不生效,然后你就收获一个单机客户端和满屏MOVED。

spring: data: redis: password: your-password timeout: 1500ms connect-timeout: 1000ms cluster: nodes: - 10.0.0.11:6379 - 10.0.0.12:6379 - 10.0.0.13:6379 max-redirects: 4 lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 max-wait: 1000ms

注意这里我故意写的是lettuce,因为 Spring Boot 2.x 之后默认的 Redis 客户端是 Lettuce 而不是 Jedis。如果你的项目确实要用 Jedis,得把 Lettuce 依赖排掉、显式引入 Jedis,并且确认自动配置真的切过去了。怎么确认?启动之后把RedisConnectionFactory这个 Bean 注入进来打印一下类名,是JedisConnectionFactory还是LettuceConnectionFactory,一目了然。这个动作看着傻,但它能一次性排掉"配置写对了但根本没生效"这类问题。

还有一个高频坑:spring.data.redis.host和spring.data.redis.cluster.nodes同时存在。前者会让自动配置走单机分支,后者被忽略。我见过有人在配置文件里留了一行注释掉的host,结果某次合并分支把它复活了,线上直接出问题。清理配置的时候,不用的一定删掉,别注释。

4.5 复现与验证:先制造问题,再确认修好

改造完之后怎么验证真的修好了?我习惯先造一个能稳定复现的环境,再验证修复。做法很简单:用裸Jedis连集群的某个非 owner 节点,故意访问一个不属于它的 key。

// 这段代码就是为了复现异常,不要用在生产 try (Jedis jedis = new Jedis("10.0.0.11", 6379)) { jedis.auth("your-password"); // 换几个不同的 key,总有一个不在 10.0.0.11 负责的槽上 for (int i = 0; i < 20; i++) { String key = "repro:" + i; try { System.out.println(key + " -> " + jedis.get(key)); } catch (JedisMovedDataException e) { System.out.println(key + " 触发重定向,目标节点:" + e.getTargetNode()); } } }

跑一遍,能看到部分 key 正常返回null(因为不存在),部分 key 抛出JedisMovedDataException并打印出目标地址。这说明复现环境搭好了。然后把同样的 key 用JedisCluster跑一遍,应该全部正常返回,没有任何异常。两步对比做完,改造是否生效就非常确定了。

命令行侧也可以用redis-cli验证,带不带-c的区别很明显:

# 不带 -c,收到重定向错误就直接打印出来 redis-cli -h 10.0.0.11 -p 6379 get repro:7 # 返回:(error) MOVED 866 10.0.0.12:6379 # 带 -c,客户端自动跟随 redis-cli -c -h 10.0.0.11 -p 6379 get repro:7 # 返回:-> Redirected to slot [866] located at 10.0.0.12:6379 # (nil)

这个小实验花不了五分钟,但能让你对"重定向到底长什么样"有非常直观的认识,比看十篇文档都管用。以后日志里再出现这个异常,你脑子里立刻就有画面了。

5. 集群运维侧的配合动作

客户端改完了不等于万事大吉。我见过客户端明明用的是JedisCluster却还是频繁重定向的情况,最后查下来是集群本身一直在抖。所以这一节讲讲服务端该看什么、该防什么。

5.1 上线前先做一次集群体检

任何一次涉及集群变更的上线(扩容、缩容、迁移、升级),我都建议先跑一遍下面这组命令,把结果存下来做基线。

# 集群整体状态,重点看 cluster_state 是不是 ok redis-cli -h 10.0.0.11 -p 6379 -a your-password cluster info # 节点列表,重点看有没有 fail 标记、槽有没有覆盖全 redis-cli -h 10.0.0.11 -p 6379 -a your-password cluster nodes # 槽位分布,确认 16384 个槽全部有主 redis-cli -h 10.0.0.11 -p 6379 -a your-password cluster slots

cluster info里要盯三个值:cluster_state必须是ok;cluster_slots_assigned必须是 16384;cluster_known_nodes应该等于你的节点总数。任何一个不对,说明集群处在不完整状态,这时候上业务就是在给自己找事。cluster nodes的输出要重点看每个节点的 flags 字段,正常情况下主节点是myself,master或者master,从节点是slave,出现fail、fail?、noaddr都是要处理的信号。另外要确认每个主节点后面的槽区间是连续的、合起来正好覆盖 0 到 16383,有缺口就说明有槽没人管,客户端访问到这些槽会直接报CLUSTERDOWN。

5.2 槽迁移期间的客户端动作

迁移这事儿,服务端做的是搬运,客户端做的是跟随,两边得配合。我的经验是把迁移拆成小批次,每次迁一两个槽,观察客户端监控里的异常率和延迟曲线,稳定了再迁下一批。迁之前先确认cluster-require-full-coverage这个参数是什么值——如果设成yes(默认),那么只要有一个槽不可用,整个集群都会拒绝服务;设成no的话,只有落到不可用槽的请求会失败,其他槽不受影响。生产环境上这个值怎么设要看业务能不能接受部分不可用,我个人倾向保守一点,保持默认,然后靠"迁移前充分准备、迁移中密切观察"来保证稳定。

迁移过程中如果发现有槽卡在MIGRATING状态迟迟不结束,先别急着强行收尾。用CLUSTER GETKEYSINSLOT <slot> <count>看看还剩多少 key 没搬,用CLUSTER COUNTKEYSINSLOT <slot>数一下总量。如果剩的 key 很少但搬不动,往往是某个大 key 迁移超时了,可以单独针对它调大MIGRATE命令的超时参数。如果剩的 key 很多,说明迁移任务本身断了,得重新发起。

5.3 值得配置的监控项

客户端侧我一般会监控三个指标:JedisMovedDataException和JedisAskDataException的出现次数、单次命令的 P99 延迟、连接池的numWaiters。前两个直接反映重定向频率,正常情况下应该接近零,突然抬头就说明拓扑在变;延迟和等待数反映客户端是否被拖住,池子里排队说明连接不够或者服务端慢了。

服务端侧重点看:cluster_state的变化、各节点的connected_clients、instantaneous_ops_per_sec、keyspace_hits/misses比例、以及每个节点的内存使用率是否均衡。集群最怕的不是单点慢,是分布不均——某个节点的槽特别多或者大 key 特别多,它会先扛不住,然后引发故障转移,再引发客户端的一轮重定向。

6. 常见问题速查与踩坑记录

最后一节把我这些年记录的典型问题和排查路径整理出来,遇到情况直接查表,比从头捋一遍快得多。

6.1 问题速查表

现象最可能的原因快速验证方式处理动作
小流量正常,放量后偶发 MOVED用了单机客户端连集群搜代码里的JedisPool引用换JedisCluster
启动就报NoReachableClusterNode地址、端口、密码、安全组telnet加redis-cli -a试连修正连接配置
报MOVED但目标地址连不上服务端返回真实地址,客户端走代理在应用机上telnet目标地址解决地址可达性或改直连
重定向持续不断,最后MaxAttempts集群持续抖动或地址错配看cluster nodes的 flags先修集群,再看客户端
某些 key 延迟明显高槽卡在MIGRATING状态cluster nodes找[slot->-id]收尾迁移或重新迁移
报CROSSSLOT而非MOVED多 key 命令跨了槽看 key 有没有统一 hash tag加花括号统一 tag
NOAUTH和MOVED混着出现密码没配或配置未生效打印实际 Bean 类型修正密码与自动配置

6.2 几个我踩过的坑

坑一:只配一个种子节点,赶上它重启就启动不了。有一次发版,应用启动时正好撞上运维在重启那个唯一的种子节点,结果整个服务起不来,回滚了一次。后来把种子节点改成三个,这个问题再没出现过。代价几乎为零,收益很明显。

坑二:以为调大maxAttempts能提高成功率。早期配置改成了 10,想着多试几次总能成功。结果集群一次短时抖动,接口 P99 从 20 毫秒飙到十几秒,线程池打满,上游跟着雪崩。后来老实改回 4,同时在业务层加了降级逻辑,效果反而好得多。Redis 挂了就让它挂,别把整个服务拖下水。

坑三:集群模式下还在用KEYS和跨 key 的MGET。集群模式不支持跨槽的多 key 操作,MGET里如果几个 key 落不到同一个槽,直接报CROSSSLOT。以前单机时代随手写的MGET代码,迁移到集群后全得改。要么用 hash tag 把 key 归到一槽,要么改成 pipeline 分批取。

坑四:SELECT数据库。集群模式只支持 0 号库,SELECT 1直接报错。老代码里如果有切库逻辑,迁移到集群前必须清理掉,不然会以各种奇怪的方式失败。

坑五:依赖版本被传递依赖拉低。pom.xml里写的是新版本,跑起来却是老版本,构造函数对不上,编译报错或者运行时报NoSuchMethodError。上线前跑一次依赖树,把冲突排掉,省事。

坑六:日志里只打异常 message 不打目标节点。JedisMovedDataException的 message 里带了槽号和目标地址,但很多人只把 message 拼进日志,没注意到它其实还能通过getTargetNode()拿到结构化的地址信息。排查的时候把这两个都打出来,尤其是目标节点,能一眼看出是"所有 key 都指向同一个节点"还是"分散指向多个节点",这两种情况的根因完全不同。

6.3 我常备的排查命令清单

把下面这几条存成一个小脚本,出问题时按顺序跑一遍,能覆盖八成的情况。

# 1. 集群整体状态 redis-cli -h <host> -p <port> -a <pwd> cluster info # 2. 节点与槽分布,找 fail 标记和残缺区间 redis-cli -h <host> -p <port> -a <pwd> cluster nodes # 3. 某个 key 到底归哪个槽、哪个节点 redis-cli -h <host> -p <port> -a <pwd> cluster keyslot <key> # 4. 该槽当前的归属,比 keyslot 更直接 redis-cli -h <host> -p <port> -a <pwd> cluster slots # 5. 带重定向跟随的访问测试 redis-cli -c -h <host> -p <port> -a <pwd> get <key> # 6. 看当前连接都是谁,配合 clientName 用 redis-cli -h <host> -p <port> -a <pwd> client list | grep <服务名>

我个人在实际操作中的体会是,遇到JedisMovedDataException这个异常,最快的定位路径不是去翻代码,而是先跑第 2 条和第 4 条命令,确认槽位归属和节点健康状态。如果集群本身是健康的、槽位覆盖完整、没有 fail 节点,那问题百分之百在客户端,直接去看用的哪个客户端类、配置的哪个前缀;反过来如果集群本身就不健康,那客户端怎么改都是治标,先把集群弄稳再说。这个判断顺序能帮你省掉大量来回猜测的时间。

另外一个小技巧:改完客户端之后别急着全量发布,先挑一台机器灰度,把日志级别调到 DEBUG,观察半小时里JedisMovedDataException的出现次数。如果是从"每分钟几百次"变成"零",那就可以放心推了;如果还有零星出现,但总量下降到个位数,那属于拓扑刷新的正常窗口,可以接受;如果只是从几百降到几十,那说明还有别的链路在用单机客户端,继续搜。