ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis集群故障恢复与Java客户端实战:从原理到排障

Redis集群故障恢复与Java客户端实战:从原理到排障

Redis 集群学到第 13 章,已经把“主从复制”“哨兵机制”都过了一遍,但说真的,集群真正难的不是搭起来,而是跑起来之后怎么应对故障,以及客户端到底怎么正确接进来。很多人照着教程搭完集群,一测主从切换就懵,一写 Java 代码就报CLUSTERDOWN或者CROSSSLOT,最后放弃了。这篇文章就把集群故障恢复、Java 连接集群的配置方式、以及集群本身的优缺点一次性讲透,希望能帮你少走点弯路。不管你是正在学 Redis 的小白,还是已经上手 Redis 但没深入集群的老手,这部分内容都能直接照着用。

1. 集群故障恢复机制解析

1.1 故障是怎么被发现的

集群不像哨兵那样在外部单独跑一套监控程序,它自己就内置了故障检测能力。集群里的每个节点都会定时向其他节点发送PING消息,如果某个节点在cluster-node-timeout(默认 15000 毫秒)时间内没有收到对方节点的PONG回复,那么这个节点就会被标记为疑似下线,也就是PFAIL。

这里有个容易忽略的点:疑似下线(PFAIL)是局部判断,只有当前节点觉得你挂了;真正让集群把某个节点判定为下线(FAIL)的,需要多数持有槽的主节点都认为这个节点不可达。也就是说,故障判定是“投票”出来的,不是某个节点说了算的。这个设计的核心原因就是避免网络分区时出现误判——比如一个节点只是和另一个节点网络抖动,并不代表它真的挂了,通过多节点投票能大大降低误派的概率。

我经常举一个类比:这就像一个团队里有人经常迟到,你不能因为一次迟到就觉得他离职了,但如果团队里一半以上的人都连续一段时间联系不上他,那基本可以确认这个人出事了。Redis 集群的PFAIL和FAIL就是这种从“疑似”到“确认”的过程。

1.2 主从切换和故障转移流程

一旦一个主节点被多数节点标记为FAIL,并且这个主节点是某个或者多个槽的持有者,集群就会触发故障转移(failover)。整个流程大概是:

  1. 从节点发现自己对应的主节点进入FAIL状态。
  2. 从节点先检查自己是否具备参与选举的条件,比如和主节点的最后通信时间不能太晚,否则数据可能太旧,不适合直接晋升。
  3. 参与选举的从节点会向集群里其他持有槽的主节点发起投票请求,获得超过半数主节点投票的从节点胜出。
  4. 胜出的从节点会执行slaveof no one,把自己提升为主节点,同时把之前主节点负责的槽全部接管过来。
  5. 集群会通过PONG消息广播这一变更,其他节点更新路由信息。

这个流程在数据一致性上其实是有舍取的。因为在PFAIL之前,主节点可能已经收到了部分写命令,但还没来得及复制给从节点,这部分数据就会丢失。Redis 官方提供了一项wait指令可以缓解,但绝大多数场景下,集群模式接受有限的数据丢失,来换取较高的可用性。如果你做的是金融级强一致需求的业务,Redis 集群天然就不适合。

实际操作中,你可能会遇到主节点恢复了,但它已经不是主了,它重新加入集群后只能作为之前从节点的从节点。这个过程中集群会自动修改它的角色,不需要手动干预。但是有一点要特别注意:如果你之前手动改过某些节点的槽分配,或者有人手滑执行了cluster failover之类命令,恢复后的分片结构可能会和你预期不一致。所以每次做故障演练或者真实故障恢复之后,一定要用redis-cli --cluster check再检查一遍集群状态。

1.3 脑裂场景和数据丢失

这里得单独提一下脑裂问题。所谓脑裂,就是原来的主节点并没有真的掛,只是它和集群其他节点之间的网络中断了,同时它仍然在接收客户端的写请求。此时新选出来的主节点已经在服务了,老主节点手里的数据在分区期间不断增长,但网络恢复后老主节点发现自己的角色已经变成从节点,它会尝试同步新主节点的数据,这时老主节点上在分区期间写入的增量数据会被丢弃。

这种情况下数据丢失不是 Redis 的 bug,而是分布式系统在 CP 和 AP 之间做了选择的必然结果。Redis 集群选择的是可用性优先,为了保证整体服务不中断,必然牺牲强一致性。理解了这一点,你就不会在故障恢复后纠结为什么有些数据突然消失了,而是应该去判断业务是否能接受这种概率极小但确实存在的丢失。如果完全不接受,那就别用集群,或者上层做补偿机制。

2. Java 开启集群模式:客户端配置与实操

2.1 Jedis 连接 Redis 集群的典型写法

Java 生态里用得最多的客户端,早期绝对是 Jedis。Jedis 连接集群的代码思路很直接:创建一个JedisCluster对象,传入一组初始节点的地址即可。

import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisCluster; import java.util.HashSet; import java.util.Set; public class RedisClusterDemo { public static void main(String[] args) { Set<HostAndPort> nodes = new HashSet<>(); // 不需要把所有节点都写上,能连到一个节点就能自动发现整个集群拓扑 nodes.add(new HostAndPort("192.168.1.10", 7001)); nodes.add(new HostAndPort("192.168.1.11", 7002)); nodes.add(new HostAndPort("192.168.1.12", 7003)); JedisCluster jedisCluster = new JedisCluster(nodes, 2000, 3000, 3); // 第一个参数是连接超时,第二个是读写超时,第三个是最大重定向次数 String result = jedisCluster.set("hello:1", "world"); System.out.println(result); System.out.println(jedisCluster.get("hello:1")); // 用完之后别忘了关闭连接池 jedisCluster.close(); } }

这段代码里最核心的是最后一个参数“最大重定向次数”。因为集群中的每个 key 根据哈希计算结果落在不同的槽上,客户端第一次请求一个 key 时,如果它连接的节点并不负责这个槽,集群会返回一个MOVED重定向错误。Jedis 收到之后会重新发起请求到正确的节点,这个重定向次数如果设置得太小(比如 0),那跨槽的请求就会直接报错。

实际上在配置JedisCluster时,官方建议用连接池,而不是每次 new 一个实例。因为JedisCluster内部维护了每个节点的连接池,如果你每次都创建新实例,不仅会频繁进行 TCP 握手,还会导致大量连接堆积,压垮 Redis 服务器。

2.2 基于 Lettuce 的集群配置方式

现在新一代项目里,Spring Boot 默认用的已经是 Lettuce 了。Lettuce 是异步和响应式驱动的,底层是基于 Netty,同一个连接可以在不同线程间复用,所以它比 Jedis 更节省连接资源。

如果你在用 Spring Boot,最简单的方式是直接在配置类里建立一个RedisClusterConfiguration:

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisClusterConfiguration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; @Configuration public class RedisClusterConfig { @Bean public RedisConnectionFactory redisConnectionFactory() { RedisClusterConfiguration configuration = new RedisClusterConfiguration(); configuration.clusterNode("192.168.1.10", 7001); configuration.clusterNode("192.168.1.11", 7002); configuration.clusterNode("192.168.1.12", 7003); configuration.setMaxRedirects(3); configuration.setPassword("yourpassword"); return new LettuceConnectionFactory(configuration); } }

靠这一点配置,Spring 的RedisTemplate就可以直接操作集群了。这里要注意,Lettuce 的拓扑刷新机制值得说一说:Lettuce 默认会定期从集群里获取最新的分片信息,如果集群发生了主从切换或者槽迁移,它能自动感知,而不是像旧版本那样傻等重定向。但是拓扑刷新的频率并不是越高越好,太频繁会产生额外的CLUSTER SLOTS命令压力。一般生产环境保持默认的 5 分钟即可,如果需要更快感知故障,可以适当调短。

之前我遇到过一个问题:集群里某个主节点挂了,从节点晋升为一主,但是 Java 程序还是持续往旧节点上写请求,报错倒是没有,但数据写不进去。其实这就是因为客户端拿到了旧拓扑,没有及时刷新。处理办法就是在客户端配置里显式开启自适应拓扑刷新,同时保证maxRedirects不要太小。对于 Lettuce 来说,可以设置setTopologyRefreshOptions,让它在连接断开时主动刷新拓扑。

2.3 Java 客户端连接集群的常见报错和排查

讲几个我真正踩过的坑,每个都能让人卡半天。

第一个,CLUSTERDOWN The cluster is down。这个错误说明集群本身处于故障状态,通常是集群里至少一个槽上没有可用节点。比如你给节点配置了cluster-enabled yes,但地址写错了导致集群并没有完整创建,或者主节点挂掉之后从节点没有顺利晋升。出现这个报错,先别看 Java 代码,直接用redis-cli -c进任意一个节点执行cluster info,看cluster_state是不是ok。不是ok的话,检查是不是有槽没被分配。

第二个,Moved或者Ask重定向错误。如果你用的是普通Jedis(不是JedisCluster)直接去访问集群,就会得到这种错误。这没什么好说的,Java 代码里不要用new Jedis(),要使用JedisCluster或 Lettuce 的集群客户端。如果用了JedisCluster仍然报Moved,多半是最大重定向次数设置成了 0。

第三个,CROSSSLOT Keys in request don't hash to the same slot。这是集群模式下经常犯的错,因为集群把数据分散到了 16384 个槽里,一个 key 只能落在一个槽中。对于mgetmset这类多 key 命令,如果这些 key 不在同一个槽,集群就无法支持。这种情况不能靠客户端解决,要么在上层业务里按 key 分片后并发请求,要么用 hash tag 强制让一组相关的 key 映射到同一个槽。比如把格式写成{user}.profile和{user}.order,Redis 只对大括号里的内容做哈希,这样两个 key 就能落到同一个槽,mget就不会报CROSSSLOT了。

3. Redis 集群的优缺点:到底值不值得上

3.1 集群解决了什么问题

Redis 集群最核心的价值就是“横向扩容”。单机 Redis 即使再快,也有内存上限和单线程瓶颈,一旦数据量超过单机内存,或者并发读写超过单机处理能力,单机再怎么优化都很难撑住。集群通过把数据分片到多台机器上,让每台机器只负责一部分数据,这样内存和数据吞吐都可以随节点增加而线性扩展。

举个具体例子:单台 Redis 8G 内存,写入 QPS 大约 10 万,当你数据量涨到 50G 时单机根本放不下。如果你搭建一个 10 节点集群,每个节点只存 5G,理论上总容量到 50G 甚至更高。更重要的是,集群的读写压力被分散到了多个节点,整个集群的吞吐上限会比单机高一个量级。

另外,集群模式天然支持主从复制和故障转移,只要你的配置合理,某个节点宕机后,从节点可以自动顶上,不需要像“主从 + 哨兵”那样额外维护一套哨兵组件。这也是为什么很多人跳过哨兵,直接上集群。但要注意,这不代表集群比哨兵更轻松,相反,集群的运维复杂度要高得多。

3.2 集群的硬伤在哪里

集群的缺点,第一就是多 key 操作受限。前面提到的CROSSSLOT问题,就是分布式数据分片带来的天然代价。哪些命令受影响?mget、mset、del多 key 版本、sunion、sdiff、lpop多 key 推送、事务中操作多个不同槽的 key,这些在集群模式下全都不可用。如果你业务里大量依赖这种跨键操作,要么改代码,要么提前设计好 key 的 hash tag。

第二个缺点是数据迁移比较繁琐。当你要增加一个节点,Redis 集群会将已有节点上的部分槽和对应的数据迁移到新节点。这个过程会占用网络 IO 和 CPU,如果数据量很大,迁移期间可能影响业务。槽迁移过程实际上是逐个槽迁移,每个槽里可能有大量 key,redis-cli --cluster reshard会触发键迁移,期间对这批 key 的访问可能性能下降,甚至出现短暂超时。

第三个缺点就是运维复杂度。你不能只监控一台机器的 CPU 和内存了,要监控每个节点,同时还要关注集群层面的槽分布、上下线状态、网络延迟。特别是节点之间交换PING/PONG消息,如果节点数特别多,网络风暴也是需要留意的。官方建议单集群节点数不要超过 1000 个,这也是从网络开销角度出发的。

3.3 什么场景适合用集群

基于上面的权衡,我觉得可以放心用集群的场景是这样的:

  • 单机内存不足。比如你需要存 20G 以上缓存数据,但单机内存加不到那么高,或者加高成本太高。
  • 要求更高的写入吞吐。单机 Redis 写 QPS 已经接近瓶颈。
  • 能够接受极端情况下的小量数据丢失,同时业务能在上层做补偿。

反之,如果业务对数据一致性要求极高,或者大量使用多 key 操作、复杂事务,或者其实单机完全扛得住,那么使用集群反而是给自己找麻烦。Redis 本身单机性能已经够强,没必要为了“用集群”而上集群。

这里说一个我的实际感受:生产上很多团队把缓存数据做了冷热分离后,单机的热数据其实不大,真正用上集群的往往是有几十 G 甚至上百 G 缓存,且访问量比较均匀的场景。如果你只是小项目,还是先把单机和持久化做好,别一上来就整个集群,否则光是日常维护就耗掉不少精力。

4. 集群运维与故障恢复的实战排障

4.1 典型的故障场景:主节点宕机

先模拟一个最常见的故障:集群中的主节点node1:7001突然宕机,它拥有一部分槽和一个从节点。正常情况下,从节点会在cluster-node-timeout之后开始发起选举,大约几秒到十几秒内完成切换。

这个过程中你会看到:

  1. 业务侧出现超时或CLUSTERDOWN错误,持续时间通常在秒级。
  2. 从节点晋升为主节点后,业务请求恢复。
  3. 老的node1:7001重启后,会自动变为集群中的从节点。

这里我遇到过一种情况:跟业务方说故障恢复很快,业务侧报的错却持续了几分钟。后来排查发现是客户端没有做拓扑刷新,一直往旧节点的地址发送请求。服务端确实已经恢复了,但客户端回路没有更新。这类问题不取决于 Redis 本身,而是取决于你怎么配置客户端。所以集群运维不能只看 Redis 侧,还要确保客户端的拓扑感知能力开启,并且要在业务代码里设置合理的重试机制。

4.2 用命令手工检查集群健康状态

在故障排查中,常用命令一定要记熟。我最推荐的是:

redis-cli --cluster check <任意节点IP>:<端口>

比如:

redis-cli --cluster check 192.168.1.10:7001

这个命令会检查集群每个槽的分配情况,打印每个节点的角色,以及是否有主节点没有从节点。等输出完后,最关心的其实是两个地方:

  • M:开头的是主节点,S:开头的是从节点。要确认每个主节点都有对应的从节点,不然这台主节点再挂就没得自动切换了。
  • 开头的[OK] All nodes agree about slots configuration,说明槽分配一致。如果出现[WARNING] Node ... has slots in migrating state之类的提示,说明有槽正在迁移。

除了check,还可以用redis-cli --cluster info查看整体数据分布情况。

进入节点后,用cluster nodes可以看到节点间的连接状态,重点关注带disconnected字样的节点。

4.3 手动触发故障切换:演练比纸上谈兵重要

我强烈建议你在测试环境做至少一次手动故障转移演练,因为很多团队集群上线半年都没出过故障,一旦真的出事了,操作都不熟练。手动切换的命令在主节点上执行:

redis-cli -p 7001 cluster reset

不对,这个可不是用来切换的。要让某个从节点主动接管它的主节点,在从节点上执行:

redis-cli -p 7002 cluster failover

这里有两种模式:不带参数的是 graceful 切换,它会尽量同步主节点上的增量数据,等数据同步差不多后再发起切换;如果主节点已经完全不可用,从节点会通过常规选举自动晋升,不需要手动执行命令。

手动演练不只是为了看切换是否成功,更重要的是感受整个切换过程中业务日志的报错情况。如果切换期间报错超过可接受范围,就要调整cluster-node-timeout或者客户端超时配置。我建议你在演练的时候把 Redis 慢日志打开,观察切换前后每条命令的耗时变化,这能帮你判断是业务侧不够健壮导致的报错,还是集群切换本身的锅。

4.4 故障恢复后的数据校验

故障恢复不等于一切结束。尤其是主节点宕机后,老主节点如果重新上线,它会作为从节点同步新主节点多出来的数据。为了确认数据确实没有出现大面积的分裂,可以用:

redis-cli --cluster rebalance <任意节点IP>:<端口>

这个命令会触发槽重新均衡,把槽和数据均匀分布到各个节点。不过要提醒一下,虽然叫 rebalance,它并不会在你没加节点的时候随意乱移数据,只是会检查并尽量调整到均衡状态。如果集群状态本身正常,它会提示无需操作。

更好的校验方式是直接在业务里做一层对账:比如缓存系统里每天定时从数据库取统计值,和 Redis 里的缓存命中结果做比较。如果发现异常,再针对性排查。

5. 一些关于集群参数和客户端调优的经验

5.1 cluster-node-timeout 不是越大越好

很多人对cluster-node-timeout的理解是“故障超时时间,设大一点稳妥一点”,其实不然。它直接决定了节点判定故障的速度,也就是从 PFAIL 到 FAIL 的时间基准。如果设得太大,比如 60 秒,那么主节点挂了之后,业务会中断很长时间,等从节点完成切换都已经超过一分钟了。如果设得太小,比如 3 秒,只要网络出现轻微抖动,集群就可能频繁触发主从切换,反而更不稳定。

我生产环境一般设置在 10 到 15 秒之间,同时把客户端侧的 socket 超时控制在 3 到 5 秒,这样即使切换发生,在单次请求上用户也基本不会感知到超时,只知道偶发变慢。

5.2 集群的从节点数量配置

每个主节点至少配一个从节点,这是底线。如果资源紧张,最多保持每个主节点配一个从节点,不要试图通过增加从节点数量来提高读性能。集群模式下读扩展一般是靠增加分片数,而不是给某个主节点挂一堆从节点。因为 Redis 集群的读写路由都是基于槽的,从节点只负责故障转移和高可用,虽然也可以读从库,但那样很容易读到过期数据,而且会破坏一致性预期。

5.3 监控指标不能只看 Redis

一个健康的 Redis 集群,光看 Redis 自身状态还不够,节点机器的 CPU、内存、磁盘、网络流量都要纳入监控。磁盘其实是最容易被忽略的:主从切换用了大量磁盘 IO 做 RDB 同步,如果磁盘性能差,同步就会慢,主从切换期间数据延迟大,极端情况下会触发从节点超时,导致从节点重打全量同步。所以给集群节点挂 SSD 至少不是坏事,条件允许的话别用机械盘顶着。

个人的体会是:Redis 集群虽然能解决容量和并发问题,但它把更多运维责任转移给了使用者。如果你以前只用过单机 Redis,刚开始上集群的那段时间,最值得花的钱就是做一套全面的监控和告警,不然故障发生时你会发现自己像个无头苍蝇。

这篇文章基本上把我学习 Redis 集群过程中最重要的几个环节都过了一遍:故障恢复的原理、Java 客户端的接入方式,以及集群的优缺点和运维经验。最后送你一个小技巧:每次修改集群配置或者做完故障演练,记得把cluster nodes的输出和检查日志保存下来,这样下次出问题时,你能快速对比出集群状态到底变化了什么。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进