JedisMovedDataException排查与Redis集群迁移改造

发布时间:2026/10/27 14:34:36
JedisMovedDataException排查与Redis集群迁移改造

Redis 集群这玩意儿,最让人头疼的不是搭建,而是业务代码从单机切过来之后,控制台突然刷出一片红:redis.clients.jedis.exceptions.JedisMovedDataException。第一次遇到的时候我也懵,明明配置文件里 host 和 port 都填对了,redis-cli手动连上去set/get也一切正常,怎么一到应用里就报这个错,还带着一长串MOVED 866 ...的尾巴。后来把客户端的源码翻了一遍才明白,这条异常根本不是"连不上",而是"连上了,但你要的数据不归这台机器管"。也就是说,Redis 服务端很明确地告诉你:这个 key 在 866 号槽,那台机器才是它的主人,你走错门了。它是一张搬家通知单,不是一张故障单——很多人在这一步就走偏了,跑去查网络、查防火墙、查密码,方向从一开始就错了。这篇内容就是想把这件事从头捋一遍:MOVED 重定向在集群里到底是怎么工作的、Jedis 为什么把它包装成异常而不是自动跟随、三种最常见的成因分别怎么快速判定、以及从单机客户端迁到集群客户端时那些文档里不写但一定会踩的坑。不管你是刚接手一个从单机升级到集群的项目,还是正在纠结要不要换客户端,下面这些内容都能直接拿去对照排查。

1. 这条异常到底在喊什么:从 MOVED 协议说起

1.1 16384 个槽位与"搬家通知"的语义

Redis 集群把所有 key 空间切成 16384 个槽(slot),每个主节点负责其中一段连续的或不连续的槽。当你对一个 key 发起命令时,接收命令的节点会先算出CRC16(key) mod 16384,得到一个 0 到 16383 之间的整数,然后查自己维护的槽位映射表:这个槽归我管,就正常执行;不归我管,就返回一个重定向响应,格式大概长这样:

-MOVED 866 10.0.0.12:7001

前半段是被移动的槽号,后半段是当前负责这个槽的节点地址。注意,这个响应是正常协议的一部分,不是错误。Redis 服务端认为自己在正确履职,只是告诉你"去那边办"。问题出在客户端这一侧:不同客户端对这个响应的处理策略不一样,有的会自动跟随并把结果拿回来,有的则直接把它当成异常抛给上层。Jedis 在单机模式(Jedis、JedisPool)下就是后者——它没有集群拓扑的概念,不知道 16384 个槽该怎么分布,也无从判断这个MOVED是应该跟随还是应该报错,所以干脆包装成JedisMovedDataException抛出去,让调用方自己决定。

这就解释了一个很多人困惑的现象:同一份代码,用redis-cli连集群手动执行没问题,放到应用里就报错。因为redis-cli加上-c参数之后会自己处理重定向,而 Jedis 单机客户端不会。你以为你在验证应用的行为,其实你在验证的是另一个客户端的行为,两者根本不是一回事。

理解这一点之后,后面所有的排查动作才有意义:这条异常的出现,本质上说明"客户端与服务端对槽位归属的认知不一致",或者"客户端根本不具备认知槽位归属的能力"。前者是拓扑问题,后者是客户端选型问题,两条路线的修法完全不同。

1.2 JedisMovedDataException 在 Jedis 异常树里的位置

这个细节值得单独拎出来说,因为大部分人拿到异常只看类名,不看字段,白白浪费了服务端主动送过来的信息。JedisMovedDataException并不是直接继承JedisDataException,它的父类是JedisRedirectionException,而JedisRedirectionException才继承JedisDataException。这个中间层不是摆设,它带了两个非常有用的字段:

方法返回类型含义
getSlot()int触发重定向的槽号,0 到 16383
getTargetNode()HostAndPort服务端认为应该由哪个节点处理这个槽

也就是说,异常对象本身就携带了完整的排查线索。你完全可以在日志里把这两个值打出来,直接看到"哪个 key 落在了哪个槽、这个槽现在归谁"。我在实际项目里就在全局异常处理器里加了一段兜底日志:

try { jedis.set(key, value); } catch (JedisRedirectionException e) { log.warn("槽位重定向 slot={}, target={}, key={}", e.getSlot(), e.getTargetNode(), key); throw e; }

这段代码的价值在于,它把一次"莫名其妙的报错"变成了"一条带槽号和目标地址的结构化日志"。日志里出现slot=866, target=10.0.0.12:7001,你立刻就能去集群上核对 866 号槽到底是不是在这个节点,核对成本从半小时降到半分钟。还有一个容易被忽略的点:JedisAskDataException和JedisMovedDataException是兄弟类,都继承自JedisRedirectionException。如果你只 catch 了JedisMovedDataException,那在槽迁移过程中出现的ASK重定向就会漏掉,继续往上冒泡。稳妥的写法是 catch 父类JedisRedirectionException,一次性覆盖两种重定向。

1.3 MOVED 与 ASK 的差别:一个永久,一个临时

这两个东西经常被混为一谈,但它们的处理方式在协议层面就不一样,搞混了会写出看起来很对实际有 bug 的代码。

MOVED表示槽的归属已经永久变更。比如你做了扩容,把原来 7000 节点上的部分槽迁到了新加的 7003 节点,迁完之后集群的槽位映射表就更新了,之后再访问这些槽,所有节点都会告诉你"去 7003"。客户端收到MOVED之后,正确做法是更新本地缓存的槽位映射,然后直接向目标节点重发命令,后续同一个槽的请求就不用再绕一圈了。

ASK表示槽正在迁移中,属于临时状态。此时源节点上还有这个 key,目标节点上也可能已经有这个 key 了,所以客户端不能更新本地缓存(因为迁完之后这个槽最终可能还留在原节点,也可能过去,还没定),而是必须先在目标节点上发一条ASKING命令,然后再发真正的业务命令。ASKING的作用是告诉目标节点:"我知道这个槽还没正式归你,但这次请你特殊处理一下,别给我返回 TRYAGAIN。"

区别用一句话概括:MOVED是"以后都去那边",ASK是"这一次去那边,但别改你脑子里的地图"。

这个差别在实际运维中很关键。如果你的集群正在做槽迁移(比如扩容、缩容、节点替换),这段时间里客户端会同时看到MOVED和ASK两种重定向,如果客户端只处理了MOVED,那ASK就会变成异常抛出来,表现为"迁移期间零星报错,迁完就恢复了"。这种间歇性故障最难查,因为等你去看的时候已经恢复了。所以选客户端的时候,要确认它对两种重定向都有处理,而不只是处理最常见的那个。

2. 三类高频成因:先判断你踩的是哪一种

2.1 用单机连接方式去连了集群端口

这是压倒性的第一成因,我见过的案例里至少七成是它。典型场景是这样:项目早期用的是单机 Redis,配置里写着spring.redis.host=10.0.0.11、spring.redis.port=7000。后来数据量上来了,运维把后端换成了三主三从的集群,端口还是那些端口,地址还是那些地址,配置文件一个字没改,应用重启,然后就开始报JedisMovedDataException。

判断方法极其简单,一条命令就够:

redis-cli -h 10.0.0.11 -p 7000 cluster info

如果返回里能看到cluster_state:ok、cluster_slots_assigned:16384、cluster_known_nodes:6这类字段,说明你连的就是一个集群节点,那么任何单机客户端连上来都必然会在命中非本节点槽位时报错。反过来,如果这条命令返回ERR This instance has cluster support disabled,那说明你连的是单机实例,异常另有原因。

这里有个隐蔽的变体:有些团队的集群前面挂了一层代理(比如某些云服务商提供的集群代理),对外暴露一个统一的地址和端口,代理内部再做转发。这种情况下,如果代理对MOVED的处理不完整,或者代理本身不支持多 key 跨槽操作,也会冒出类似的异常。判断方法是看异常里的getTargetNode()返回的地址——如果返回的是内网真实节点地址而不是你配置的代理地址,说明代理没有屏蔽掉重定向,客户端直接看到了集群内部拓扑,这条链路后面的行为就很难预料了。

还有一种情况是配置写了一半:spring.redis.cluster.nodes配了,但代码里手动new JedisPool(...)建了另一个连接在用,两套并存。这种"配置是集群、代码是单机"的半吊子状态,报错是必然的,而且因为配置看起来是对的,排查时特别容易绕远路。

2.2 拓扑变了但客户端还拿着旧地图

第二类成因出现在已经用了JedisCluster的项目里。JedisCluster启动时会从种子节点拉一份完整的槽位映射缓存在本地,之后每次请求都查这份缓存直达目标节点,这是它性能好的原因。但缓存终究是缓存,集群发生了槽迁移、主从切换、节点上下线之后,本地缓存就和真实拓扑对不上了。

表现是什么样的?改造上线之后平稳跑了几周,某天运维做了一次扩容,然后应用开始零星报错,重启一下就好了,过一阵又不行。这种"重启能好、过段时间复发"的模式,基本可以锁定是拓扑缓存过期。

JedisCluster内部其实有自愈机制:它捕获到JedisMovedDataException或JedisAskDataException之后,会调用内部方法刷新槽位缓存,然后按maxAttempts重试。所以理论上单次重定向不该抛到业务层。如果抛出来了,通常意味着两种情况:一是重试次数耗尽了(默认 5 次,如果集群正在大规模迁移,5 次可能不够);二是刷新的种子节点本身也拿不到最新拓扑,比如种子节点刚好在故障中。

比较新的 Jedis 版本提供了集群拓扑定期刷新的能力,可以在客户端配置里设定一个刷新周期,让它主动去拉最新拓扑,而不是被动等到报错才刷。具体 API 在不同大版本之间有差异,升级前务必对照你所用版本的文档确认,不要照抄网上不同版本的示例代码——这是我吃过亏的地方,Jedis 3.x 和 4.x 的JedisCluster构造函数签名完全不一样,抄错了编译都过不了。

2.3 客户端与服务端看到的节点地址对不上

这一类最少见但最难查,通常发生在容器化环境或者做了地址映射的网络里。原理是这样的:集群节点之间互相通信时,会把自己认为的"我的地址"广播给其他节点,这个地址记录在集群的元数据里。如果节点启动时配置的地址是容器内网 IP(比如172.17.0.5),而客户端在容器外,那服务端返回的MOVED里就会带着这个客户端根本访问不到的172.17.0.5,客户端照着去连,连接超时,或者干脆连到一个不存在的地址上。

排查手法是直接看集群元数据里的地址长什么样:

redis-cli -h 10.0.0.11 -p 7000 cluster nodes

输出每行的格式是节点ID IP:端口@集群总线端口 角色 主节点ID 心跳 配置纪元 连接状态 槽位。重点看 IP 部分,如果里面出现的是172.17.x.x、127.0.0.1或者某个明显不是客户端可达网段的地址,那问题就找到了。修复方式是在节点配置里显式指定对外广播的地址,让集群元数据记录客户端真正能访问到的那个 IP。

这类问题的另一个变种是 NAT 场景:客户端在 A 网段,集群在 B 网段,中间有地址转换设备。如果转换只做了单向映射(客户端能访问服务端,但服务端返回的地址客户端访问不到),就会表现为"第一条命令能通,重定向之后就超时"。特征是异常堆栈里除了JedisMovedDataException还会伴随连接超时或者JedisConnectionException,看到这种组合,优先怀疑地址可达性而不是客户端逻辑。

3. 从堆栈信息里把槽位和目标节点挖出来

3.1 异常对象自带的两个字段

前面提过JedisRedirectionException带getSlot()和getTargetNode(),这里展开说怎么用。很多人拿到异常第一反应是看 message,message 里确实有MOVED 866 10.0.0.12:7001这样的文本,但它是给人看的,解析它需要字符串处理,容易出错。用字段就干净得多:

catch (JedisRedirectionException e) { int slot = e.getSlot(); HostAndPort node = e.getTargetNode(); // 直接拿去核对 }

拿到槽号之后,可以做两件事。第一,确认这个槽是不是真的归目标节点管:

redis-cli -h 10.0.0.11 -p 7000 cluster slots

这条命令返回的是槽区间和对应节点的完整映射,输出的每一组是起始槽 结束槽 [主节点IP 端口 节点ID] [从节点IP 端口 节点ID] ...。拿 866 去对,看它落在哪个区间,区间对应的主节点是不是10.0.0.12:7001。如果对得上,说明服务端的认知是一致的,问题在客户端;如果对不上,说明集群元数据本身处于不一致状态,那要先去修集群,而不是修客户端。

第二,确认这个 key 到底该落在哪个槽:

redis-cli -h 10.0.0.11 -p 7000 cluster keyslot user:1001:name

返回的数字就是CRC16("user:1001:name") mod 16384的结果。如果这个数字和异常里的槽号一致,说明你理解的 key 和服务端理解的 key 是同一个,排除 key 编码、序列化方式导致的偏差;如果不一致,那就要怀疑是不是序列化器把 key 加了前后缀(比如某些框架会给 key 加命名空间前缀),导致实际写入的 key 和你以为的不一样。

3.2 用 redis-cli -c 做一次交叉验证

这一步是整个排查里性价比最高的动作,因为它在客户端和服务端之间划了一条清晰的分界线。

redis-cli -c -h 10.0.0.11 -p 7000 10.0.0.11:7000> set user:1001:name test -> Redirected to slot [7241] located at 10.0.0.12:7001 OK

注意那行-> Redirected to slot,这是redis-cli -c处理重定向时打印的提示。看到这行,就等于服务端亲口告诉你"我这边要重定向",问题被锁定在客户端侧,和网络、认证、端口都没关系。

如果redis-cli -c执行同样的命令没有打印重定向,直接返回 OK,那说明这个 key 的槽正好就在你连的那个节点上,这是一次"幸运命中",不代表问题不存在。这时候要换几个 key 试,或者直接用cluster keyslot算一个不在当前节点上的 key 来测。我一般会连续测 5 个不同的 key,因为 3 主节点的集群里,命中本节点的概率是三分之一左右,只测一个很容易误判。

再补一个反向验证:不加-c直接连,执行同样的命令,如果返回(error) MOVED 866 10.0.0.12:7001而不是 OK,那就完全复现了 Jedis 单机客户端的处境,后面的改造方向也就毫无悬念了。

3.3 cluster nodes 与 cluster slots 的核对顺序

这两个命令输出的信息有重叠但侧重点不同,核对的时候有个推荐顺序,能少走弯路。

cluster nodes看的是节点视角:有哪些节点、各自什么角色、谁是谁的从、连接状态怎么样、每个节点声称自己负责哪些槽。这个命令的优势是能看到节点的健康状态,比如某个节点标了fail?或者fail,说明它自己或者多数节点认为它挂了,这时候无论槽位映射对不对,客户端连上去都会有问题。

cluster slots看的是槽位视角:每个槽区间的归属,以及对应的主从节点列表。这个命令的输出更适合直接和异常里的槽号做匹配,格式规整,好解析。

我的核对顺序是:先cluster info看整体健康(cluster_state是不是 ok),再cluster nodes看有没有节点异常,最后cluster slots精确匹配槽号。如果第一步就看到cluster_state:fail,那后面两步基本不用做了,先去修集群——集群处于 fail 状态时,所有节点都会拒绝写入,报的错也五花八门,JedisMovedDataException只是其中之一,很容易误导方向。

顺便提一个实操细节:cluster nodes的输出里,节点 ID 是一串 40 位十六进制,肉眼比对极易出错。我一般会把输出贴到编辑器里,用awk过滤一下:

redis-cli -h 10.0.0.11 -p 7000 cluster nodes | awk '{print $2, $3, $9}'

只保留地址、角色和槽位三列,一眼就能看完。别小看这种小动作,排查的时候注意力是稀缺资源,能省一点是一点。

4. 改造方案:JedisCluster 接入的完整过程

4.1 依赖与版本对齐

在动手改代码之前,先把版本这件事定下来,因为 Jedis 在 3.x 到 4.x 之间做了一次比较大的 API 重构,网上搜到的示例代码经常是跨版本混着的,照抄会浪费大量时间在编译错误上。

JedisCluster的核心构造参数在两代之间的差异大致是这样:

关注点Jedis 3.xJedis 4.x 及以上
连接配置分散的 timeout、password 参数统一的JedisClientConfig
超时设置构造函数传 int 毫秒builder 里分连接超时和读超时
重试次数构造函数传 maxAttempts构造函数传 maxAttempts
连接池JedisPoolConfigGenericObjectPoolConfig<Connection>

如果你项目里已经有其他依赖间接引入了 Jedis,务必先mvn dependency:tree看一眼实际生效的版本,避免出现"我明明升到 4.x 了怎么还是找不到DefaultJedisClientConfig"这种诡异现象。我就遇到过一次,某个中间件依赖锁死了 Jedis 3.6,我在 pom 里声明的 4.x 被依赖调解机制降级了,排查了快一小时才发现。

确定版本之后,改造的思路是:把原来的JedisPool或Jedis换成JedisCluster,把单节点地址换成一组成员地址,其余业务代码尽量不动。JedisCluster的接口设计和Jedis高度相似,绝大多数命令方法名和签名一致,这也是为什么这个改造通常能在半天内完成。

Set<HostAndPort> nodes = new HashSet<>(); nodes.add(new HostAndPort("10.0.0.11", 7000)); nodes.add(new HostAndPort("10.0.0.12", 7001)); nodes.add(new HostAndPort("10.0.0.13", 7002)); JedisClientConfig clientConfig = DefaultJedisClientConfig.builder() .password("your-password") .connectionTimeoutMillis(2000) .socketTimeoutMillis(2000) .build(); GenericObjectPoolConfig<Connection> poolConfig = new GenericObjectPoolConfig<>(); poolConfig.setMaxTotal(64); poolConfig.setMaxIdle(16); poolConfig.setMinIdle(8); JedisCluster cluster = new JedisCluster(nodes, clientConfig, 5, poolConfig);

种子节点不需要写全,写全了也没坏处。JedisCluster启动时会依次尝试这些地址,只要有一个能连上并返回拓扑,就能正常工作。我习惯把全部主节点都写上,这样单个节点故障时启动成功率更高。

4.2 连接参数与超时、重试的配置逻辑

参数这块值得单独讲讲,因为默认值往往不适合生产环境,而报错信息又不会告诉你"是你的超时设太小了"。

maxAttempts是重定向重试次数,默认 5。它的作用范围是:客户端遇到重定向后刷新拓扑、重发命令,最多重试这么多次。集群规模小、拓扑稳定的时候,5 次绰绰有余。但如果集群正在做大规模迁移,或者节点数量多(比如几十个节点),一次操作可能连续遇到多次重定向,5 次就可能不够,表现为迁移期间持续报错。我的经验值是:节点数在 10 个以内保持 5,超过 10 个可以调到 8 到 10。调太高也不好,真出问题时重试会拖长响应时间,把故障放大。

连接超时和读超时的区分也要搞清楚。连接超时管的是 TCP 握手阶段,读超时管的是命令发出去之后等服务端响应的时间。集群模式下,一次业务请求可能涉及多次内部重定向,每次都是一次独立的连接往返,所以读超时不能设得和单机一样紧。单机模式下我一般设 1000 毫秒,集群模式下至少 2000 毫秒,跨机房部署的话还要往上加。

连接池的maxTotal也有讲究。单机模式下maxTotal就是全部并发连接数,集群模式下是每个节点的连接数上限。也就是说maxTotal=64在 3 主节点的集群里,理论上最坏情况会建 192 个连接(每个主节点 64 个)。如果不注意这一点,从单机迁过来的时候很容易把连接数放大三倍,撞上服务端的maxclients限制。我一般的做法是先按单机时的maxTotal除以主节点数,再稍微留点余量。

4.3 Spring Boot 场景下的配置迁移

如果项目用的是 Spring Boot 的自动配置,改造量会小很多,基本就是改配置。需要注意 Spring Boot 2.x 和 3.x 的配置前缀不一样,2.x 是spring.redis.*,3.x 改成了spring.data.redis.*,这个变化坑过不少人——配置写错前缀不会报错,只会静默使用默认值连 localhost,然后你就对着Connection refused发呆。

spring: redis: cluster: nodes: - 10.0.0.11:7000 - 10.0.0.12:7001 - 10.0.0.13:7002 max-redirects: 5 password: your-password timeout: 2000

关键点:只要cluster.nodes配了,Spring Boot 就会自动装配JedisCluster或LettuceConnectionFactory的集群版本,不会再创建单机的JedisConnectionFactory。max-redirects就是前面说的maxAttempts。

迁移时有个必须检查的地方:代码里有没有手动new JedisPool(...)或者注入JedisPool的地方。自动配置只管它自己创建的那套 Bean,手写的连接池不会自动变成集群模式。我在一个老项目里就发现过三个地方手动建了JedisPool,分别在不同的工具类里,改完配置应用照样报错,最后是全局搜JedisPool才揪出来的。建议改配置的同时执行一次全文搜索,把这几个关键字都过一遍:JedisPool、new Jedis(、JedisConnectionFactory。

4.4 保留单机客户端的场景与代价

有没有可能不改客户端,只处理异常?理论上可以,但我不推荐,理由得说清楚。

手动处理的做法是:catchJedisRedirectionException,取出目标节点,用Jedis连过去重发命令。代码大概长这样:

try { return jedis.get(key); } catch (JedisRedirectionException e) { try (Jedis target = new Jedis(e.getTargetNode())) { return target.get(key); } }

这段代码能跑,但它有几个致命问题。第一,每次重定向都要新建一个连接,没有池化,高频场景下连接创建开销会直接压垮性能。第二,没有本地拓扑缓存,意味着每一次访问非本节点槽位的 key 都要走一遍"报错-重连-重发",等于把集群当单机用,完全丧失了集群的直连优势。第三,ASK重定向需要先发ASKING命令,上面这段代码没处理,迁移期间会出错。第四,多 key 命令、事务、Lua 脚本根本没法这么玩。

所以我的态度很明确:如果确认后端是集群,就老老实实换JedisCluster。手动处理重定向只适合两类边缘场景——一是临时的过渡方案,比如线上出故障需要十分钟内止血,先打补丁撑着,然后排期正经改造;二是代码里只有极少数几个 key 需要访问集群,为它引入整个集群客户端不划算,那可以考虑用这种方式隔离在一个小工具类里。除此以外,没有保留单机客户端的理由。

5. 换成集群客户端之后才会暴露的问题

5.1 多 key 命令与 hash tag

这是改造后最常见的第二波报错来源,而且报的错和MOVED完全不一样,是CROSSSLOT Keys in request don't hash to the same slot。很多人改完客户端发现MOVED没了,冒出新错误,以为是改造没做对,其实是暴露了一个原本就存在的设计问题。

原因很简单:MGET、MSET、SUNION、SINTER这类多 key 命令,集群要求所有 key 必须落在同一个槽里,否则没法在单个节点上完成计算。单机模式下这个问题不存在,因为所有 key 都在一台机器上,所以代码里写mget("user:1:name", "user:2:name")完全没问题,迁到集群就炸了。

解决办法是hash tag:用花括号把 key 中参与哈希计算的部分括起来,Redis 只对花括号里的内容做 CRC16,花括号外的部分不参与。这样就能把逻辑上相关的一组 key 强行绑到同一个槽。

// 会报 CROSSSLOT,因为两个 key 的哈希值大概率不同 jedis.mget("user:1001:name", "user:1002:name"); // 用 hash tag 绑定到同一个槽 jedis.mget("{user:1001}:name", "{user:1001}:age");

注意,花括号里的内容才是决定槽位的部分。{user:1001}:name和{user:1001}:age的花括号内容都是user:1001,算出来的槽必然相同。验证方法:

redis-cli -h 10.0.0.11 -p 7000 cluster keyslot "{user:1001}:name" redis-cli -h 10.0.0.11 -p 7000 cluster keyslot "{user:1001}:age"

两条命令返回的数字应该完全一致。这个验证动作建议在改 key 设计的时候就做,别等上线才发现。

hash tag 不是免费的。它把原本可以分散到多个节点的数据强行聚到一个节点上,用多了会造成数据倾斜——某个节点扛了远超平均的流量和内存。所以设计原则是:只在确实需要多 key 原子操作或批量操作的地方用,且 tag 的粒度要合适。按用户 ID 做 tag 通常没问题,按业务类型做 tag(比如{order}:1、{order}:2)就会把所有订单压到一个节点,那是灾难。

5.2 事务、Lua 脚本、Pipeline 的边界

这三样东西在集群模式下的限制,比多 key 命令更容易被忽略,因为它们报错的方式更隐蔽。

事务(MULTI/EXEC)要求事务里涉及的所有 key 在同一个节点。跨节点的事务在集群里无法保证原子性,客户端会直接拒绝。Lua 脚本同理,脚本里访问的所有 key 必须同槽,而且 Redis 还会校验脚本声明的 key 列表和实际访问的是否一致,不一致会报错。这两个限制意味着,单机时代写的"一个事务里更新三张表对应的缓存"这种代码,迁到集群必须重新设计——要么用 hash tag 把它们绑到一起,要么改成非事务的最终一致方案。

Pipeline 的情况更复杂一些。早期版本的JedisCluster是不支持 pipeline 的,因为 pipeline 的本质是把多条命令攒着一次性发出去,而集群模式下不同 key 可能要去不同节点,没法攒在一起发。后来的版本引入了ClusterPipeline这类支持,但它的实现方式是按节点分组、分别发 pipeline,语义上和单机 pipeline 不完全等价,比如跨节点的原子性就没有了。如果你的代码里有大量 pipeline 批量写,改造成本要提前评估,别等到改完客户端才发现 pipeline 全废了。

还有一个常被忽略的命令:KEYS。集群模式下KEYS只会在你连的那一个节点上执行,返回的只是这个节点的 key,不是全集群的。想要全量扫描必须遍历所有主节点,各自执行SCAN。这个行为不会报错,只会静默返回不完整的结果,比报错还危险。如果代码里有依赖KEYS做统计或者清理的逻辑,迁移前必须改成遍历节点的写法。

5.3 连接池膨胀与拓扑刷新的坑

前面提过maxTotal在集群模式下是每节点的上限,这里补充几个相关的实操坑。

第一个坑是连接数总量失控。一个 6 节点(3 主 3 从)的集群,如果maxTotal设成 100,最坏情况下会建 600 个连接。很多团队从单机迁过来的时候直接沿用了原来的配置,上线后发现服务端maxclients被打满,新连接全部被拒,报的错是ERR max number of clients reached。这个错误和MOVED完全是两回事,但同时出现的时候很容易误判。

第二个坑是拓扑刷新带来的连接抖动。客户端刷新拓扑时,如果发现某个节点的地址变了,会关掉旧连接、建新连接。这个过程如果很频繁(比如集群不稳定,反复触发刷新),会造成连接数剧烈波动,表现为监控图上连接数锯齿状起伏,同时伴随大量短生命周期的连接。看到这种图形,说明集群拓扑在反复变化,得先去查集群为什么不稳定,而不是调客户端参数。

第三个坑是空闲连接被中间设备掐断。有些网络设备会清理长时间空闲的 TCP 连接,而连接池里的空闲连接自己不知道已经被掐了,下次取出来用的时候才发现是死连接,报JedisConnectionException: Unexpected end of stream。解决办法是开启连接池的存活检测,定期发个PING探活,同时在业务层做好重试。JedisCluster内部对连接异常有一定的重试逻辑,但不能完全依赖它,关键业务路径上还是要自己兜一层。

6. 上线前的验证清单与回滚预案

6.1 六个必须验证的动作

改造完成后不要直接全量上线,按下面这个清单过一遍,能提前发现绝大部分问题。

验证项具体动作期望结果
连通性用集群地址构造客户端,执行一次set/get正常返回,无重定向异常
跨节点写入连续写 20 个不同前缀的 key全部成功,无异常
多 key 操作执行mget操作两个同 hash tag 的 key正常返回列表
跨槽多 key执行mget操作两个不同 hash tag 的 key预期报CROSSSLOT,确认业务代码没有这种写法
拓扑变更在测试集群上做一次槽迁移,观察应用日志最多出现几条 warn 级重定向日志,无 error
故障切换手动下线一个从节点,观察应用读写正常,或按预期降级,无异常堆积

第五项和第六项是最容易被跳过、也最能暴露问题的。特别是拓扑变更测试,很多人嫌麻烦不做,结果生产上第一次扩容就出问题。测试方法很简单,在两个节点之间迁移几个槽就行:

redis-cli --cluster reshard 10.0.0.11:7000 \ --cluster-from <源节点ID> \ --cluster-to <目标节点ID> \ --cluster-slots 100 \ --cluster-yes

迁完之后立刻跑一轮业务请求,看日志里有没有异常的JedisMovedDataException冒到业务层。正常的客户端应该能自己消化掉,最多在 debug 级别留下几条重定向记录。

6.2 观察哪些指标能证明改造生效

上线之后,光看"没报错"是不够的,还得确认改造真的带来了预期的收益,以及没有引入新的问题。我一般盯三个指标。

第一个是重定向次数。如果客户端或框架能暴露这个指标,观察它的绝对值。稳态下应该接近于零——因为拓扑缓存生效之后,请求直接打到正确节点,不会触发重定向。如果这个数字持续不为零,说明拓扑缓存一直在失效,要么是集群在频繁变化,要么是客户端刷新策略有问题。

第二个是各节点的请求分布。集群的核心价值是把负载分散开,如果改造后发现 90% 的请求还是打在某一个节点上,那基本可以断定是 hash tag 用过头了,数据严重倾斜。用redis-cli --cluster check或者各节点的INFO commandstats可以看分布情况。

第三个是连接数。对比改造前后的连接总数,确认没有出现前面说的"每节点上限叠加"导致的膨胀。如果连接数涨了三倍以上,回头检查maxTotal的配置。

回滚预案这块,说实话,客户端改造的回滚成本不算低,因为涉及代码和配置的变更。我的做法是保留旧配置项但注释掉,同时确保发布流程支持快速回滚到上一个版本。另外,如果集群是新搭的、数据是刚迁过来的,最好保留一份迁移前的快照,万一改造过程中发现集群本身有问题,能快速回到单机状态先保证业务可用,再慢慢排查。这种"先保命再优化"的取舍,在真实的生产环境里比任何技术方案都重要。

最后分享一个我个人踩过的坑:改造完成后本地测试全过,上线当天下午开始零星报错,日志里同时出现JedisMovedDataException和CROSSSLOT。排查了半天才发现,是某个定时任务里有一段批量写缓存的代码,用了mset拼了 500 个不同用户的 key。集群模式下这 500 个 key 几乎不可能同槽,必然报错。这段代码在单机时代跑了两年从没出过问题,所以谁也没想起来它。从那以后我养成了一个习惯:改客户端之前,先全文搜索mget、mset、pipeline、multi、eval这几个关键字,把项目里所有跨 key 的操作列个清单,逐个评估迁移影响。这个动作花不了半小时,但能省掉上线后一整晚的排查。