数据库运维12 min read次阅读

缓存三连坑:穿透、击穿、雪崩,以及它们真的发生时你该做什么

早上十点,监控群炸了:订单库 CPU 直接 100%,连接池打满,前端大批超时。Redis 这边呢,命中率从平时的 98% 掉到了 20% 出头。重启应用没用,把流量砍一半也没用,DB 还是在喘。最后定位到根因——前一晚发版时,一批缓存 key 被统一刷新,TTL 都设成了 3600 秒,于是这一天的十一点整,这批 key 几乎同时过期,请求像雪崩一样全砸到数据库上。

这种事我见过不止一次。有意思的是,每次复盘会上大家嘴里的词都不一样:有人说是「雪崩」,有人说是「击穿」,还有人说是「穿透」。这三个概念确实常被混为一谈,但成因和解法差别很大,认错对象就会用错药。

先把它仨分清楚

缓存穿透:查的是一个根本不存在的数据。缓存里没有,数据库里也没有,所以每次请求都穿透缓存直接打到数据库。危害主要来自被恶意刷——比如拿一堆随机的、数据库里压根没有的用户 id 来查,你的缓存形同虚设,DB 全程在扛无意义的查询。

缓存击穿:某一个极热点 key 过期的那一瞬间,成千上万个并发请求同时发现「缓存没了」,然后一窝蜂去查数据库、回写缓存。区别在「击穿」是单点(就那一个热点),「雪崩」是面(一大批)。击穿的杀伤力来自于并发的瞬时集中。

缓存雪崩:两种情况都算。一种是大量 key 在同一时刻过期(就像我们开头那个 3600 秒齐刷刷失效的案例);另一种是 Redis 自己挂了(宕机、主从切换抖动、网络分区),所有请求瞬间失去缓存层。结果是数据库在毫无缓冲的情况下承接全部流量。

一句话记法:穿透是「查不存在的」,击穿是「一个热点没了」,雪崩是「一堆没了或者 Redis 没了」。认准了再动手。

穿透:拦在查库之前

穿透的核心是「不要让它打到数据库」。两个层次的手段:

布隆过滤器(Bloom Filter)是正面硬刚的方案。在缓存前面加一层,所有合法存在的 key 都先 BF.ADD 进去,查的时候先 BF.EXISTS,不存在的直接返回,连 Redis 都不用碰。Redis 自带 RedisBloom 模块:

# 先装好 RedisBloom 模块,然后
BF.ADD user:bf 10086
BF.EXISTS user:bf 10086      # 返回 1
BF.EXISTS user:bf 999999     # 大概率返回 0,直接拦掉

布隆过滤器的代价是「有误判率」——它说「可能存在」时不一定真存在,但它说「肯定不存在」时一定不存在。所以误判率要权衡:设得越低,占的内存越大。容量预估要留余量,快满的时候误判率会涨,很多实现支持 BF.RESERVE 提前指定 error_ratecapacity

另一个更轻量的手段是缓存空值:数据库里查不到,就往 Redis 里写一个带短 TTL 的空对象(或者一个特殊标记),这样同一个不存在的 key 在 TTL 内不会再打到 DB。注意两点:一是 TTL 要短(比如 30~60 秒),避免真的有数据写进了 DB 却还被空值挡着;二是写入时要主动失效这个空值,否则会出现「数据刚插进去,查还是查不到」的诡异现象。

再往前的接口层校验也不能省:id 必须是数字、必须在合理范围、参数做白名单。把明显非法的请求在网关层就挡掉,比什么都便宜。

击穿:让一个请求去扛,其余等着

击穿的解法本质是「别让大家都去查库」。

最常用的是互斥锁 / singleflight 思路:第一个发现缓存失效的线程去查 DB 并回写缓存,其他线程在它搞定之前先等着(或者拿旧值)。Redis 里可以用 SETNX 做一把简单的锁:

# 尝试获取锁,只在 key 不存在时设置,10 秒自动过期(防止锁永远不释放)
SET lock:product:1001 1 NX PX 10000

拿到锁的去查库、写缓存、删锁;没拿到的 sleep 一小会儿重试读缓存。锁的过期时间一定要大于「查 DB + 写缓存」的耗时,不然锁提前释放,又会有一批请求同时冲进去。Go 的 singleflight、Java 的 Redisson 分布式锁都是把这个模式封装好了,比自己手撸 SET NX 稳。

如果要绝对不阻塞读请求,可以用逻辑过期:value 里塞一个过期时间戳,key 本身不设 Redis TTL。读的时候发现「逻辑上过期了」,返回旧值,同时异步起一个线程去刷新。好处是读永远不等待,坏处是刷新完成前会短暂返回旧数据——看业务能不能接受。

极热点的 key(比如首页爆款、配置项)干脆不设过期 + 后台定时刷新,从根上消除「过期瞬间」这个窗口。代价是内存里常驻,得确认它真的值得。

雪崩:别让失效集中,也别让 Redis 裸奔

雪崩最容易被忽视的源头就是开头那个案例——TTL 设成了一模一样的固定值。解法朴素但有效:给 TTL 加随机抖动

# 基础 TTL 3600 秒,再加 0~600 秒的随机,避免齐刷刷失效
import random
ttl = 3600 + random.randint(0, 600)
redis.set(key, value, ex=ttl)

就这么一行随机,能把「同一秒几万 key 失效」变成「接下来十分钟里零散失效」,数据库的压力曲线从尖峰变成长尾,完全扛得住。

剩下的手段是兜底和降级:

  • 多级缓存:本地内存(Caffeine、甚至一个 HashMap)做 Redis 之下的第二层。Redis 挂了,至少还有本地热点兜底,BD 不会瞬间裸奔。代价是本地缓存和 Redis 之间的一致性要自己管。
  • Redis 高可用:哨兵(Sentinel)或 Cluster,主挂了自动切从,别让单点成为雪崩的开关。我们那次事故如果再叠加 Redis 主从切换抖动,恢复期会更长。
  • 熔断降级:DB 压力到阈值时,对非核心请求直接限流、返回默认值或走降级逻辑,优先保住核心链路的 DB 连接。这点靠应用框架的熔断(如 Sentinel、Hystrix 思路)实现。
  • 缓存预热:大促、发版前,把预计的热点 key 提前加载进 Redis,别等流量来了再冷启动。

运维怎么提前看见,而不是等告警炸

这三件事里,运维最容易创造价值的地方其实是观测——在它变成故障之前就发现苗头。

命中率是第一指标。Redis 的 INFO stats 里有 keyspace_hitskeyspace_misses,命中率 = hits / (hits + misses)。正常情况下应该是 95% 以上;如果它短时间掉到 80% 以下,先别怀疑应用 bug,先想是不是有一批 key 在集中失效(雪崩前兆)或者有热点过期(击穿)。

redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|instantaneous_ops_per_sec|evicted_keys"

几个关键字段:

  • instantaneous_ops_per_sec:Redis 当前每秒命令数。配合 DB 的 QPS 一起看,如果 Redis QPS 没怎么变但 DB QPS 突然翻倍,典型的缓存没接住。
  • evicted_keys:被淘汰的 key 总数。如果它在疯涨,说明 maxmemory 不够、LRU/LFU 在拼命清缓存——清掉之后自然就穿透到 DB 了。检查 maxmemory-policy 设得合不合理,别用 noeviction(满了直接报错)去扛读多写少的场景。
  • keyspace_hits/misses:上面说的命中率来源。

大 key 和热点 key 要定期扫。大 key 用 --bigkeys(基于 SCAN 采样,对线上友好):

redis-cli --bigkeys

热点 key 想用 --hotkeys 的话,前提是把 maxmemory-policy 设为 allkeys-lfuvolatile-lfu,否则 OBJECT FREQ 没有计数,扫出来全是 0。我们生产用的是 LFU 策略,配合 --hotkeys 能直接列出最热的那批,对预防「击穿」很有用——提前给它们上「不过期 + 后台刷新」就稳了。

慢日志也别忘:

redis-cli SLOWLOG GET 10
redis-cli --latency -h 127.0.0.1

DB 被打爆的时候,Redis 自身往往也跟着慢,SLOWLOG--latency 能帮你判断「是 Redis 自己慢,还是它把压力转嫁给了 DB」。

把这些指标接进 Prometheus + Grafana(Redis exporter 现成的),画一条命中率曲线、一条 evicted_keys 曲线、一条 DB QPS 曲线,三张图叠一起看,雪崩和击穿在爆发前通常都有几小时的「前戏」。

一点实在的取舍

缓存不是银弹,它本身就是一个「可能随时失效的层」。架构上越依赖它,就越要把「它失效时系统会不会崩」当成默认假设去设计。穿透靠「拦」,击穿靠「合并」,雪崩靠「打散 + 兜底」——三套解法互不重叠,认准了再下药。

还有个容易被忽略的点:TTL 的抖动别只在代码里加,发版脚本批量刷新缓存时也要带抖动。我们那次事故的根因,恰恰就是发版工具里写死了统一的 3600 秒,代码里的随机逻辑根本没被执行到。工具链和代码,两处都得防。

分享:

相关文章

评论区