1. 起因
线上一个查询接口在某天流量陡增时数据库 CPU 直接打满,排查下来是缓存这一层没扛住,暴露出了穿透、击穿、雪崩三个经典问题里的两个。这三个概念平时都知道,真正踩过一遍才记得住区别和对应的解法,记一下。
2. 缓存穿透:查一个根本不存在的 key
1 | def get_user(user_id): |
问题出在如果 user_id 对应的数据在数据库里根本不存在,db.query_user 每次都返回空,这个空结果不会被写进缓存(或者写了也马上被判断为无效),于是每一次请求都会穿透缓存直接打到数据库上。正常情况下这种请求很少见,但一旦被恶意构造大量不存在的 id 来请求,或者爬虫扫了一堆无效 id,数据库瞬间就被压垮。
解法是空结果也要缓存,哪怕值是一个哨兵值:
1 | def get_user(user_id): |
如果担心恶意 id 的种类特别多导致缓存里塞满哨兵值,也可以在前面加一层布隆过滤器,提前判断 id 是否可能存在,根本不存在的直接拦掉,不用走到 Redis 这一层。
3. 缓存击穿:单个热点 key 过期瞬间被并发打爆
这次线上的问题就是这个。一个热点商品详情的缓存 key 在某个时间点过期,恰好那个时间点并发量很高,几百个请求同时发现缓存里没数据,全部一股脑去查数据库、然后各自往 Redis 里写:
1 | def get_product(product_id): |
这跟穿透的区别是:击穿针对的是存在但缓存恰好失效的热点 key,穿透针对的是根本不存在的 key。解法是给查库+回写这一段加锁,保证同一时刻只有一个请求去查数据库,其他请求要么等,要么先返回旧值:
1 | def get_product(product_id): |
对于访问量特别大、绝对不能容忍数据库压力的热点 key,更简单的做法是干脆不设过期时间,改成后台定时任务主动刷新,业务代码只读缓存,永远不会有”缓存失效瞬间”这个状态。
4. 缓存雪崩:大量 key 在同一时间集体过期
雪崩和击穿的机制类似,区别是雪崩是大量不同的 key 集中在同一时间点过期,而不是单个热点 key。最常见的诱因是批量写缓存的时候统一用了同一个固定的过期时间:
1 | for item in items: |
一小时之后这一整批 key 会在几乎同一时刻失效,如果这批 key 数量大、访问频繁,效果就是短时间内大量请求同时穿到数据库,跟单个热点 key 击穿的破坏力不是一个量级。解法很直接,过期时间加一个随机抖动,把集中失效打散开:
1 | import random |
另外 Redis 实例本身挂掉、或者主从切换导致的大规模缓存失效也叫雪崩,这一层的应对更多是架构层面的事情:做好哨兵/集群的高可用、给数据库前面加限流熔断,真扛不住的时候宁可降级返回默认数据也不能让数据库被打死。
5. 小结
三个问题的判断标准可以简化成:查的 key 存不存在(穿透 vs 击穿/雪崩)、失效的是一个 key 还是一批 key(击穿 vs 雪崩)。应对手段本质上都是”别让大量请求在同一时刻绕过缓存直接打数据库”,具体用空值缓存、加锁、随机 TTL 还是布隆过滤器,看场景搭配着用,线上遇到问题优先看监控里数据库 QPS 和缓存命中率的曲线,基本能一眼定位是哪一种。