导致集群雪崩的200MB大Key:当同步淘汰遇上Gossip心跳超时

早上 10:30,正是一天中业务流量开始拉升的阶段。监控大屏上突然弹出密集的告警,某核心业务的 Redis Cluster 开始出现剧烈的节点状态震荡:Master 和 Replica 不断发生角色切换,客户端大面积报 READONLY 错误和 Command timed out。 介入排查时,第一眼看系统层指标:宿主机 CPU 并没有被打满(由于 Redis 单线程模型,单核跑到 100%,但整体 CPU 负载在一个合理水位),网络带宽也没有出现明显的瓶颈。 但集群就是不稳定。随意连上一个节点执行 cluster nodes,发现 configEpoch 正在疯狂自增,说明 Failover 在频繁发生。

诡异的现场:消失的 Slowlog 与沉默的慢查询

遇到节点响应慢甚至超时,排查的第一直觉通常是查慢查询。我在几个发生过切换的节点上执行 slowlog get 10,结果出乎意料:几乎是空的,或者只有几个毫秒级别的正常操作。 这是一个非常典型的误区:很多开发甚至运维认为,只要客户端超时,就一定能在慢查询日志里找到“元凶”。 实际上,Redis 的慢查询日志统计的仅仅是命令本身的执行时间。在源码中,慢查询的耗时计算严格包裹在 call(c, CMD_CALL_FULL) 函数内部。而一个命令在真正进入 call() 执行之前,还需要经历很多阶段,其中最致命的一个环节就是:内存淘汰(Eviction)

抽丝剥茧:同步淘汰引发的 Reactor 线程阻塞

既然不是命令本身慢,那必然是主线程在其他地方被卡住了。 我立刻查看了节点的实时状态:

redis-cli -p 6379 info stats | grep evicted_keys

发现 evicted_keys 的数值在极其剧烈地飙升。同时,info memory 里的 used_memory 紧紧贴着 maxmemory 的边缘疯狂试探。 结合业务方的发布记录,真相逐渐浮出水面:开发团队在早晨上线了一个所谓的“性能优化”逻辑,为了减少网络 RTT,他们将全量用户的关系图谱作为 Hash 结构缓存进了 Redis。通过 redis-cli --bigkeys 扫描,发现部分 Hash 结构的体积达到了恐怖的 150MB 到 200MB,包含上百万个 Field。 当 Redis 内存达到 maxmemory,且配置了 allkeys-lru 策略时,处理任何写入命令前,都会进入 freeMemoryIfNeeded() 函数尝试释放内存。 灾难的爆发点就在这里:这批集群运行的版本默认并未开启 lazyfree-lazy-eviction。这意味着,当 LRU 算法不幸选中了一个 200MB 的超级大 Key 时,Redis 必须同步地去销毁这个拥有百万元素的 Hash 字典。 在 C 语言层面,这是一个极其沉重的 dictRelease 操作,需要逐一遍历并释放内部的 dictEntry。释放一个 200MB 的 Hash,主线程会被死死卡住 2 到 3 秒。在这 3 秒内,整个 Reactor 事件循环完全停滞。

Gossip 协议的暗礁:极端的 cluster-node-timeout

如果仅仅是主线程阻塞 3 秒,顶多是一次短暂的客户端 P99 延时毛刺,为什么会引发整个集群的连环 Failover? 这就牵扯到了开发人员犯的第二个致命错误。为了追求所谓的“极致高可用”,有人在配置文件中将 cluster-node-timeout 从默认的 15000 毫秒(15秒)暴降到了 3000 毫秒(3秒)。 逻辑看似很丰满:“超时时间越短,故障发现越快,主从切换就越敏捷。” 但这完全违背了 Redis Cluster 的底层通信机制。Redis 的 Gossip 协议虽然使用的是独立端口(通常是业务端口 + 10000,例如 16379),但它的网络 IO 事件(clusterReadHandler / clusterWriteHandler)同样是注册在主线程的 aeMain 事件循环中的。 当主线程在同步释放 200MB 的大 Key 时,事件循环被阻塞。此时,该节点既无法处理客户端的新请求,也无法响应其他集群节点发来的 Gossip PING 包。 时间线是这样推进的:

  1. 0.0s: 内存触顶,触发 LRU 淘汰,选中 200MB 大 Key。

  2. 0.1s – 2.5s: 主线程陷入 dictRelease,疯狂释放内存,对外界毫无响应。

  3. 1.5s: 其他节点发送 PING 给该节点。数据包到达 OS 内核 TCP 缓冲区,但 Redis 主线程没空去 read()

  4. 3.0s: 致命时刻。由于 cluster-node-timeout 被设为 3000ms,其他节点发现超过 3 秒没有收到 PONG,立即将其标记为 PFAIL(疑似下线)。

  5. 3.1s: 通过 Gossip 交换状态,集群半数以上 Master 认为该节点 PFAIL,状态升级为 FAIL

  6. 3.2s: 触发从节点选举,Replica 晋升为新 Master。

  7. 3.5s: 原 Master 终于删完了大 Key,主线程苏醒。它处理完积压在缓冲区的 Gossip 包,愕然发现自己已经被弹劾。它只能乖乖降级为 Replica,并向新 Master 发起全量同步(SYNC)。

  8. 后续: 全量同步触发 BGSAVE,Copy-On-Write 机制在内存高水位下瞬间导致 OOM Killer 介入,节点彻底崩溃。 这是一个教科书级别的雪崩链路。

技术结论与干预

处理这种现场,没有任何优雅退让的余地。我直接采取了三步阻断措施: 第一步:热更配置,开启异步淘汰,把释放内存的脏活丢给后台 BIO 线程。

config set lazyfree-lazy-eviction yes
config set lazyfree-lazy-server-del yes

第二步:回调 Gossip 超时时间到合理水位。3 秒的超时在分布式网络环境中无异于走钢丝。

config set cluster-node-timeout 15000

第三步:拦截写入大 Key 的源头,通知开发紧急回滚。大对象必须拆分(例如按用户 ID Hash 取模落到不同的子 Key 中),没有商量的余地。 不要把单机思维强行套用在分布式架构上。在 Redis 这种极度依赖单线程事件循环的模型中,将超时时间压榨到极致,并不能换来高可用,只会让系统变得像玻璃一样脆弱。任何一个同步阻塞的微小扰动,都会顺着 Gossip 协议的连锁反应,被放大成一场席卷全网的风暴。而懂得在“快速发现故障”和“容忍正常网络抖动与执行毛刺”之间找到平衡点,才是系统架构的成熟体现。