当前位置: 代码网 > it编程>数据库>Redis > Redis突发缓存穿透的一次完整定位复盘指南

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

2026年07月21日 Redis 我要评论
当命中率从 95% 骤降到 30%,如何在混乱中建立排查节奏,找到真正的根因。周五晚上九点,监控群突然炸开。生产环境 redis 命中率在五分钟之内从 95% 跌到 30%,下游数据库连接数暴涨,cp

当命中率从 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 当成 “缓存不存在”,于是每次都走数据库查询。

// 错误的兜底逻辑
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 usagehotkeys 参数、访问频率统计
解决思路拆分数据结构、压缩、本地缓存本地缓存、读写分离、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 代码层面的防御

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

// 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 告警响起时,少一分慌乱,多一分从容。

以上就是redis突发缓存穿透的一次完整定位复盘指南的详细内容,更多关于redis突发缓存穿透的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com