Redis 突发缓存穿透:一次完整的定位复盘

当命中率从 95% 骤降到 30%,如何在混乱中建立排查节奏,找到真正的根因。

周五晚上九点,监控群突然炸开。生产环境 Redis 命中率在五分钟之内从 95% 跌到 30%,下游数据库连接数暴涨,CPU 飙到 90%,服务响应时间从 20ms 爬到 2s。值班同学的第一反应是扩容,但加完 Redis 节点后,命中率纹丝不动。

这不是我第一次遇到 Redis 大规模 miss,但每一次都像是在迷雾里找出口。经过多年踩坑,我总结出一套分层排查的方法论。这篇文章不会罗列命令手册,而是分享一种在混乱中建立排查节奏的思路,帮助你从现象出发,逐层逼近根因。


一、先建立排查框架,别急着动手

很多人看到命中率暴跌,第一反应是 redis-cli --bigkeys 或者重启实例。这种散弹枪式的排查效率极低。更合理的方式是从数据流的全链路视角切入,把问题定位拆成三个层次:

复制代码
Redis 命中率暴跌
├── 第一层:应用层
│   ├── 连接池耗尽
│   ├── 超时配置不合理
│   ├── 代码逻辑变更
│   └── 缓存 key 设计缺陷
│
├── 第二层:Redis 服务端
│   ├── 内存逐出
│   ├── 大 key / 热 key
│   ├── 持久化阻塞
│   └── 主从同步延迟
│
└── 第三层:网络与基础设施
    ├── 网络抖动/丢包
    ├── 容器资源限制
    ├── DNS 解析异常
    └── 中间件代理故障

这个框架的核心是自上而下、由近及远。应用层离你最近,最容易验证,应该优先排查;只有当应用层被排除后,才需要深入 Redis 服务端和基础设施。很多新手反其道而行,一开始就抓 Redis 的慢日志,结果折腾两小时发现是应用发布时把超时时间配错了。

关键原则:在拿到任何监控数据之前,先问三个问题:最近有没有发布?有没有配置变更?流量有没有突增?这三个问题的答案能帮你排除 50% 以上的场景。


二、第一层:应用层排查

应用层是 Redis 的调用方,也是问题最高发的环节。这里我总结出四个高频根因。

2.1 连接池耗尽与等待队列堆积

当并发突增时,如果连接池大小设置偏小,新的请求会在连接池队列里等待。等待时间超过超时阈值后,客户端会直接抛异常或降级走数据库,表现为 "Redis miss"。但实际上 Redis 服务端根本没有收到这些请求。

排查方式很简单。以 Lettuce 为例,观察连接池监控:

复制代码
# 通过 Micrometer 暴露的指标
redis_connection_pool_active{pool="userCache"} 48
redis_connection_pool_idle{pool="userCache"} 2
redis_connection_pool_pending{pool="userCache"} 127

如果 pending 持续大于 0,说明连接池已经成为瓶颈。但注意,扩连接池不是万能药。每个连接在 Redis 服务端都会占用内存和文件描述符,盲目扩大会把压力转嫁到服务端。更合理的做法是:先确认连接池配置是否合理(通常公式是:核心线程数 × 单节点连接数),再考虑增加 Redis 分片。

2.2 超时配置的隐性陷阱

我见过最隐蔽的一次事故,是某次发布把 Lettuce 的 timeout 从 3s 改成了 200ms。发布初期一切正常,直到晚高峰流量上来,网络延迟波动到 50ms 以上,大量请求在 200ms 内拿不到响应就被判定为失败,缓存逻辑直接跳过,请求击穿到数据库。

更危险的是某些客户端的默认行为。比如 Jedis 在连接超时后,部分版本不会把异常抛给业务层,而是静默返回 null,业务代码把这个 null 当成 "缓存不存在",于是每次都走数据库查询。

java 复制代码
// 错误的兜底逻辑
try {
    value = redis.get(key);
} catch (Exception e) {
    // 超时后也返回 null,调用方无法区分
    value = null;
}
if (value == null) {
    value = db.query(key);  // 缓存穿透
}

正确的做法是把异常和空值分开处理:超时异常应该记录日志并触发告警,而不是当成 miss 处理。

2.3 缓存 key 的设计缺陷

某次业务迭代后,缓存 key 从 user:12345 变成了 user:v2:12345。但清理逻辑里还沿用旧前缀匹配,导致大量旧 key 堆积,新 key 又没预热。结果是:Redis 内存满了,逐出策略开始工作,新写入的 key 很快被踢掉,命中率雪崩。

这类问题的排查要关注两个指标:

  • keyspace_hits / keyspace_misses 的比率变化
  • evicted_keys 的增长速率

如果 evicted_keys 在命中率下降的同时明显上升,说明内存已经吃紧,需要检查 key 的生命周期管理是否合理。

2.4 批量操作引发的连锁反应

有些业务为了降低网络往返,会用 MGET 一次性拉取上百个 key。这本身没问题,但如果其中某个 key 对应的数据结构很大(比如一个 Hash 里有几十万个 field),整个 MGET 的响应时间会急剧拉长,拖慢同批次里的其他 key,导致这些 key 被客户端判定为超时 miss。

定位这类问题需要把监控粒度拆到命令级别。Redis 的 SLOWLOGlatency-monitor 能帮你找到耗时异常的命令:

复制代码
127.0.0.1:6379> SLOWLOG GET 10
1) 1) (integer) 12345
   2) (integer) 1721123400
   3) (integer) 5200000
   4) 1) "MGET"
      2) "user:10001"
      3) "user:10002"
      ...

上面的 5200000 表示这个命令耗时 5.2 秒。当你看到 MGET 的耗时不正常地高,就要怀疑里面混入了大 key。


三、第二层:Redis 服务端排查

应用层排查无果后,把视线转向 Redis 服务端。服务端的问题通常更隐蔽,但对全链路的破坏力也更大。

3.1 内存逐出:最经典的雪崩起点

Redis 的内存上限由 maxmemory 控制,超过后根据 maxmemory-policy 决定如何释放空间。如果策略配置为 allkeys-lruallkeys-random,当内存满了之后,每次新写入都会触发逐出,而逐出操作是同步阻塞的。

在极端情况下,逐出会演变成一场灾难。比如内存使用率长期维持在 99%,此时突然有一批大 key 写入,Redis 需要在短时间内逐出大量旧 key 来腾出空间。这个逐出过程会阻塞主线程,导致正常的读写请求被延迟,客户端超时后判定为 miss。

排查内存问题的标准动作:

  1. INFO memory 确认 used_memorymaxmemory 的关系
  2. INFO stats 观察 evicted_keys 是否在持续增长
  3. redis-cli --bigkeysMEMORY USAGE <key> 定位内存大户

注意 :很多人看到内存满了就直接扩容,但如果根本原因是 key 没有设置过期时间,扩容只是延缓问题。建议配合 TTL 扫描,找出那些 "永久存活" 的 key,让业务方确认是否可以清理。

3.2 大 key 与热 key:两个截然相反的问题

大 key 和热 key 经常被混为一谈,但它们的成因和解决方案完全不同。

维度 大 key 热 key
定义 单个 key 的 value 体积过大 单个 key 被高频访问
危害 阻塞主线程、占用带宽、序列化耗时 单节点 CPU 飙高、连接打满
发现方式 --bigkeysMEMORY USAGE hotkeys 参数、访问频率统计
解决思路 拆分数据结构、压缩、本地缓存 本地缓存、读写分离、key 拆分

对于热 key,一个实用的 trick 是在 key 后面加随机后缀做副本拆分。比如把 config:global 拆成 config:global:0config:global:9,客户端按哈希取模分散读取。这样能把单点 QPS 降到原来的 1/10。

3.3 持久化与主从同步的阻塞效应

如果 Redis 开启了 AOF 的 always 策略,或者正在执行 BGSAVE / BGREWRITEAOF,主线程会被 fork 出的子进程影响。在内存数据量较大的实例上,fork 操作本身就可能耗时数百毫秒,期间主线程无法处理新请求。

排查这类问题需要关注 INFO persistence 里的几个关键指标:

  • rdb_last_bgsave_status:上次持久化是否成功
  • aof_last_rewrite_duration_sec:AOF 重写耗时
  • master_last_io_seconds_ago:主从同步延迟

一个经验值是:如果 master_last_io_seconds_ago 持续大于 10 秒,说明主从同步已经出现明显滞后,从节点的数据已经不新鲜,读请求落到从节点上等同于 miss。


四、第三层:网络与基础设施

当应用层和服务端都被排除后,问题往往藏在网络层或基础设施里。这一层的问题最难定位,因为指标分散在不同的监控系统中。

4.1 网络抖动与丢包

Redis 基于 TCP,对网络延迟非常敏感。一次简单的 GET 请求,客户端到服务端的 RTT 通常在 0.5ms 以内。但如果网络出现抖动,RTT 突然跳到 50ms 以上,那些超时阈值设置偏低的客户端就会大面积抛异常。

排查网络问题不要只看应用层的超时日志,要抓更低层的指标:

  • 容器/虚机层面的 tcp_retransmits 重传率
  • 网卡层面的 rx_errorstx_dropped
  • 中间网络设备的流量突增告警

如果 Redis 部署在 Kubernetes 里,还要检查 CNI 插件的 iptables 规则是否过于复杂。某次事故中,我们发现一个节点上的 conntrack 表满了,导致新连接被随机丢弃,表现就是 Redis 间歇性不可达。

4.2 容器资源限制

容器化环境中的资源限制(cgroups)是另一个隐形杀手。Redis 的 CPU 使用率看起来只有 50%,但如果容器被限制了 2 核,而 Redis 是单线程模型,实际上它已经把一个核心跑满了。剩余的 "空闲" CPU 只是其他进程没有用完的配额。

更隐蔽的是内存限制。如果容器设置了 memory.limit_in_bytes,而 Redis 的 maxmemory 配置得比容器限制还大,Redis 以为还有空间,但操作系统已经开始 OOM kill 或者 swap,性能断崖式下跌。

复制代码
# 检查容器实际的 cgroup 限制
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us

4.3 DNS 与中间件代理

如果使用 Twemproxy、Codis 或者 Envoy 作为 Redis 代理,代理层本身也可能成为瓶颈。代理的转发逻辑、连接池管理、健康检查机制,任何一环出问题都会导致 "Redis 不可用" 的假象。

一个典型的场景是:代理的健康检查间隔为 5 秒,但某台 Redis 实例因为持久化阻塞了 8 秒,代理把它标记为不可用,把所有流量切到了剩余节点上,引发连锁反应。等 Redis 恢复后,代理又需要一段时间才能重新把它加回池子。


五、实战:一个完整的定位时间线

回到文章开头的那个周五晚上。以下是真实的排查时间线,展示了如何结合上述框架逐步缩小范围。

复制代码
21:05  监控告警:命中率 95% → 30%
21:07  确认:无发布,无配置变更,流量正常
21:10  应用层检查:连接池 pending = 0,超时配置正常
21:15  INFO memory → used_memory = 7.8GB, maxmemory = 8GB
21:18  INFO stats → evicted_keys 每分钟增长 12k
21:22  --bigkeys 发现 hash:user:tags 占用 1.2GB
21:25  业务确认:该 key 为全量用户标签聚合,本不该进缓存
21:30  紧急清理大 key,命中率回升至 88%
21:45  补充本地缓存+熔断,命中率恢复到 95%+

这次事故的根本原因是:某次需求上线时,开发同学把 "全量用户标签" 这个巨大的 Hash 写入了 Redis,而 key 没有设置过期时间。Redis 内存被撑到临界值后,LRU 逐出开始高频工作,新业务写入的 key 把老业务的 key 大量踢出,引发命中率雪崩。

这里的教训是:监控 evicted_keys 比监控命中率更重要。命中率下降是结果,evicted_keys 上升才是原因。如果告警里加了 evicted_keys 的阈值,这个问题可能在 21:05 之前就被发现了。


六、监控与预防体系

事后救火不如事前防火。一个健壮的 Redis 监控体系,应该覆盖以下维度。

6.1 必加的告警项

指标 告警阈值 意义
命中率 < 80% 持续 2min 缓存失效或穿透
evicted_keys 增长 > 1000/min 内存不足,逐出频繁
used_memory / maxmemory > 85% 内存即将耗尽
连接数 > maxclients × 80% 连接泄漏或攻击
慢查询数量 > 10/min 大 key 或复杂命令
主从延迟 > 10s 同步阻塞或网络问题
CPU 使用率 > 80% 持续 2min 热 key 或复杂计算

6.2 代码层面的防御

除了监控,代码里也要埋好防御性逻辑:

java 复制代码
// 1. 缓存空值,防止缓存穿透
String value = redis.get(key);
if (value == null) {
    value = db.query(key);
    if (value == null) {
        // 空值也缓存,TTL 设短一些
        redis.setex(key, 60, "__NULL__");
    } else {
        redis.setex(key, 3600, value);
    }
}

// 2. 加随机抖动,防止缓存雪崩
int ttl = 3600 + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, ttl, value);

// 3. 异步预热 + 熔断降级
if (circuitBreaker.isOpen()) {
    return fallback.get(key);
}

6.3 架构层面的兜底

  • 本地缓存(Caffeine/Guava):作为 Redis 的 L1 缓存,即使 Redis 短暂不可用,也能扛住一部分流量。
  • 熔断降级(Sentinel/Hystrix):当 Redis 超时率超过阈值时,自动切断对 Redis 的访问,直接走降级逻辑,防止把数据库也打挂。
  • 读写分离:热 key 的读操作分散到多个从节点,减轻主节点压力。

七、总结

Redis 命中率突降从来不是单一原因造成的。它可能是应用层的配置失误,可能是服务端内存管理的漏洞,也可能是网络层的一次短暂抖动。建立分层排查的框架,比记住十个命令更有价值。

最后,分享三个我反复验证过的经验:

  1. 先问变更,再问指标。 70% 的故障与最近的发布或配置变更有关,确认这一点能帮你省去大量无意义的排查。
  2. evicted_keys 是比命中率更前置的指标。 命中率下降是结果,内存逐出才是根因。把 evicted_keys 加入核心告警,能让你提前发现问题。
  3. 客户端超时不等于服务端慢。 网络抖动、连接池耗尽、代理故障都会表现为 "Redis 慢",但根因可能在离 Redis 很远的地方。

缓存是高性能系统的基石,但也是最容易被忽视的灰色地带。希望这篇文章能帮你在下一次 Redis 告警响起时,少一分慌乱,多一分从容。

相关推荐
Xzaveir16 小时前
不要用一个状态表示“号码已认证”:企业号码身份的四域模型
android·人工智能
To_OC16 小时前
搭 RAG 的第一个坎:网页检索答非所问?聊聊文档加载与分块的那些坑
人工智能·llm·agent
科技林总16 小时前
图像处理领域的技术发展
人工智能·深度学习·计算机视觉
lauo16 小时前
掌心核爆:iQOO首款小平板搭载2nm骁龙8E6,开启AI原生计算的移动终端新纪元
前端·人工智能·智能手机·重构·电脑·ai-native
wenb1n16 小时前
MySQL诊断系列(3/6):索引分析——5个SQL揪出“僵尸索引”
数据库·人工智能·编程语言
Ethan010716 小时前
redis 热key问题如何解决+场景
redis
阿里云大数据AI技术16 小时前
从向量存储到 Agentic 数据基础设施:Paimon × Milvus 如何构建 AI 原生多模态数据湖
人工智能·agent
CoovallyAIHub16 小时前
当能源行业遇上 AI 智能体:Coco 为什么选择留在本地
前端·agent
字节跳动视频云技术团队16 小时前
一句话上线 AI Agent 应用:火山 Supabase + IGA Pages 全栈部署实践
人工智能·agent