早上 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 包。
时间线是这样推进的:
-
0.0s: 内存触顶,触发 LRU 淘汰,选中 200MB 大 Key。
-
0.1s – 2.5s: 主线程陷入
dictRelease,疯狂释放内存,对外界毫无响应。 -
1.5s: 其他节点发送 PING 给该节点。数据包到达 OS 内核 TCP 缓冲区,但 Redis 主线程没空去
read()。 -
3.0s: 致命时刻。由于
cluster-node-timeout被设为 3000ms,其他节点发现超过 3 秒没有收到 PONG,立即将其标记为PFAIL(疑似下线)。 -
3.1s: 通过 Gossip 交换状态,集群半数以上 Master 认为该节点
PFAIL,状态升级为FAIL。 -
3.2s: 触发从节点选举,Replica 晋升为新 Master。
-
3.5s: 原 Master 终于删完了大 Key,主线程苏醒。它处理完积压在缓冲区的 Gossip 包,愕然发现自己已经被弹劾。它只能乖乖降级为 Replica,并向新 Master 发起全量同步(SYNC)。
-
后续: 全量同步触发 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 协议的连锁反应,被放大成一场席卷全网的风暴。而懂得在“快速发现故障”和“容忍正常网络抖动与执行毛刺”之间找到平衡点,才是系统架构的成熟体现。