从一次深夜告警说起:缓存穿透、击穿与雪崩
现在假设这样一个场景:凌晨两点,监控群里突然炸了。数据库 CPU 飙到 100%,接口响应时间从几十毫秒涨到十几秒,大量请求超时。排查一圈下来,罪魁祸首既不是新上线的代码,也不是突发的流量高峰,而是缓存层出了问题:一批热点数据的缓存在同一时刻集中过期,海量请求瞬间直接打到了数据库上。
这类事故在使用缓存的系统里非常常见。业界习惯把缓存失效引发的问题归纳为三类:穿透、击穿、雪崩。它们名字相近,成因和对策却各不相同。这篇文章就把这三个概念掰开揉碎讲清楚,再聊聊实际工程中的取舍。
先回顾一下缓存的基本模式
绝大多数业务系统采用的是"旁路缓存"(Cache-Aside)模式,流程大致如下:
- 读请求先查缓存,命中则直接返回;
- 未命中则查数据库,并把结果写回缓存,设置一个过期时间;
- 写请求先更新数据库,再删除缓存中对应的键。
这个模式简单直观,缓存命中率高时,数据库只需承担很小一部分读压力。但它隐含了一个前提:大部分请求都能在缓存里找到答案。一旦这个前提被打破,数据库就要直面原本被缓存挡住的全部流量,而数据库的承载能力往往比缓存低一到两个数量级。三类问题,本质上都是这个前提在不同情况下失效。
缓存穿透:查一个根本不存在的东西
现象:请求查询的数据在缓存和数据库里都不存在。由于数据库返回空结果,缓存不会写入任何内容,于是下一次同样的请求依然会穿过缓存直达数据库。
正常用户偶尔查询不存在的数据并不可怕,可怕的是被恶意利用。攻击者只要构造大量随机的、不存在的 ID 发起请求,就能让缓存形同虚设,把压力全部转嫁给数据库。
常见对策:
缓存空值。 数据库查不到时,也在缓存里写入一个特殊的空标记,并设置较短的过期时间(比如一两分钟)。这样同一个不存在的键在短时间内不会重复打到数据库。缺点是如果攻击者每次都用不同的键,缓存里会堆积大量无用的空值,占用内存。
布隆过滤器。 把数据库中所有合法的键预先加载到布隆过滤器中,请求进来时先判断键是否"可能存在"。布隆过滤器的特点是:判断为不存在时一定不存在,判断为存在时有小概率误判。因此它能以极小的内存代价拦截掉绝大多数非法请求。需要注意的是,布隆过滤器不支持删除元素(标准实现下),数据频繁删除的场景需要定期重建,或者改用计数布隆过滤器之类的变种。
入口校验。 这一点常被忽视,却最省事。ID 格式是否合法、数值范围是否合理、用户是否有权限访问,这些检查放在网关或接口层就能挡掉一大批明显异常的请求。
缓存击穿:一个热点键突然失效
现象:某个访问量极高的热点键在缓存中过期了。在它过期后、被重新写入之前的这一小段时间窗口里,成千上万的并发请求同时发现缓存未命中,于是同时去查数据库,同时重建缓存。
打个比方,这就像一座桥上的交通管制突然撤掉,所有车同时涌上桥面。数据本身是存在的,查询也不复杂,但并发量瞬间把数据库压垮。
常见对策:
互斥锁重建。 缓存未命中时,只允许一个请求去数据库加载数据并写回缓存,其他请求要么短暂等待后重试读缓存,要么直接返回旧值或降级结果。在分布式环境下,这个锁一般借助缓存系统自身的原子操作实现,例如"键不存在时才设置"的命令,并配合一个过期时间防止持锁进程崩溃导致死锁。
逻辑过期。 缓存中的数据不设置物理过期时间,而是在值里附带一个逻辑过期时间戳。读取时如果发现逻辑上已过期,就返回当前的旧数据,同时异步触发一个后台任务去刷新。这种方式牺牲了一点点数据新鲜度,换来了请求路径上完全没有阻塞。
热点数据永不过期。 对于极少数可以明确识别的核心热点数据,干脆不设过期时间,由数据变更事件主动更新缓存。这种方式最稳,但需要有可靠的变更通知机制,否则容易出现长期不一致。
互斥锁方案保证了强一致性但会让部分请求等待;逻辑过期方案保证了可用性但会短暂返回旧数据。选择哪个,取决于业务能容忍的是"慢一点"还是"旧一点"。
缓存雪崩:大面积同时失效
现象:大量缓存键在同一时间段内集体失效,或者缓存服务本身宕机,导致海量请求同时涌向数据库。
开头那次事故就是典型的雪崩。事后复盘发现,原因是某个定时任务在每天凌晨批量预热数据,所有键被设置了完全相同的过期时间,于是它们也在完全相同的时刻一起过期。
击穿是"一个点"的失效,雪崩是"一大片"的失效,后者的破坏力要大得多。
常见对策:
过期时间加随机抖动。 这是最简单也最有效的一招。在基础过期时间上加一个随机偏移量,比如基础 30 分钟,再随机加 0 到 5 分钟,让键的过期时刻均匀分散开,避免集中失效。开头那次事故,修复时改的就是这一行代码。
缓存高可用。 如果雪崩的原因是缓存服务宕机,那么对策就是让缓存本身不容易宕机:主从复制、哨兵自动故障转移、集群分片,都是标准做法。
多级缓存。 在分布式缓存之前再加一层进程内的本地缓存。即便分布式缓存整体不可用,本地缓存仍能挡住一部分请求。本地缓存容量小、各节点之间不一致,但作为兜底已经足够。
限流与熔断。 这是最后一道防线。当检测到数据库压力异常时,主动拒绝一部分请求或返回降级内容,宁可让部分用户看到"服务繁忙",也不能让数据库彻底被压垮导致所有用户都不可用。一个活着的、部分可用的系统,远比一个完全崩溃的系统好。
把三者放在一起看
| 问题 | 触发条件 | 影响范围 | 核心思路 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 取决于请求量 | 在缓存前拦截非法请求 |
| 击穿 | 单个热点键过期 | 单个键,高并发 | 控制重建的并发度 |
| 雪崩 | 大量键同时过期或缓存宕机 | 大面积 | 分散过期时间、提高缓存可用性、兜底保护 |
可以看到,三者的共同点是数据库暴露在了本不该承受的流量之下,区别在于"缺口"从哪里打开。穿透是缓存从来没挡住过这些请求,击穿是一个关键位置临时失守,雪崩则是整条防线同时崩溃。
工程实践中的几点体会
不要等出事再加防护。 随机过期时间、空值缓存、入口参数校验,这些手段成本极低,几乎没有理由不在设计初期就加上。
监控比方案更重要。 缓存命中率、数据库 QPS、慢查询数量,这几个指标应该有明确的告警阈值。很多雪崩在真正爆发前都有征兆,比如命中率在几分钟内持续下降。
压测要覆盖缓存失效场景。 常规压测往往在缓存预热完毕后进行,看到的数据非常漂亮。建议专门做一轮"冷缓存"压测和"缓存宕机"演练,看看数据库在最坏情况下能撑多久、降级逻辑是否真的生效。
方案之间可以组合。 实际系统中很少只用一种手段。布隆过滤器加空值缓存防穿透,互斥锁加逻辑过期防击穿,随机过期加多级缓存加限流防雪崩,层层设防才能睡个安稳觉。
结语
缓存是提升系统性能最立竿见影的手段之一,但它也把系统的稳定性和一个前提绑在了一起:大部分请求都能命中。穿透、击穿、雪崩,本质上都是在提醒我们,这个前提随时可能失效,而设计系统时必须回答一个问题:当缓存不在的时候,我的系统会怎样?
那天凌晨的事故最终在四十分钟后恢复,修复本身只改了一行代码。但在复盘会上,大家花了整整两个小时讨论另一件事:为什么我们在设计时没有想到这个问题。
- 点赞
- 收藏
- 关注作者
评论(0)