缓存不是加一层就完事

上一篇给 ACGHub 的帖子列表加了索引,单条查询是快下来了。可我有点得寸进尺:热门列表被反复请求,每次都老老实实查一遍 DB,还是浪费——DB 再快,也不如不查。于是我在前面加了一层 Redis 缓存。
加的时候我特地先把缓存的几个经典坑搞清楚了:穿透、击穿、雪崩。
先说句实话:这三个坑,我目前一个都还没真撞上——ACGHub 现在用户不多,真出这些问题,我未必感觉得到。但缓存这东西有意思的地方在于,有些防护得在上缓存的第一天就埋进去(比如随机 TTL、空值缓存),等真撞上了再补,往往要把缓存逻辑翻出来重改。所以这篇不是血泪史,是「出事之前先把雷标好」。
先把「正常情况」说清楚
这三个坑都是从同一套读法里裂出来的,叫 ,流程很朴素:
flowchart LR
R[请求读数据] --> C{缓存命中?}
C -->|命中| H[直接返回]
C -->|miss| DB[查数据库]
DB --> W[写回缓存]
W --> Ret[返回] 绝大多数时候它工作得很好:第一次 miss 查一次 DB,之后都走缓存。穿透、击穿、雪崩,本质上都是这套流程的某个隐含假设被打破了。一个个来。
穿透:缓存压根建不起来
cache-aside 有个隐含假设:「miss 了去查 DB,就能把缓存填上」。
可如果这条数据在 DB 里也不存在呢?查了个空,没东西可回填,下次请求还是 miss,再查 DB,还是空……缓存这一层根本建立不起来,每个这样的请求都直接打到 DB 上。这就是 。
最典型的就是有人拿一个根本不存在的 article_id 反复刷详情接口——缓存对它永远无效,等于形同虚设。
它和后面「击穿」的根本区别就在这儿:穿透的数据压根不存在,所以缓存建不起来;击穿的数据是存在的,只是恰好过期了。
修法两种:
- 缓存空值:查出来是空,也往缓存里写一个「空」标记、给个短 TTL(比如
SET article:99999 "<空>" EX 60),下次直接命中这个空值、不再打 DB; - 布隆过滤器:在缓存前面加一道,先判断这个 id「可能存在 / 一定不存在」,一定不存在的直接挡掉。
击穿:一个热点 key 过期的瞬间
cache-aside 另一个假设:「key 在缓存里」。
绝大多数时候是的。但单个热点 key 过期的那一瞬,问题来了:这一刻涌进来的大量并发请求同时 miss,于是同时去查同一条 DB、同时回填——一个 key,瞬间就能把 DB 打爆。这就是 。
注意它的两个特征:数据是存在的(只是恰好过期了),而且是一个热 key 引起的局部风暴。
修法的核心是「别让一群请求同时去重建同一个 key」:让抢到锁的那一个请求去查 DB、重建缓存,其余请求先等一下(或先返回旧值顶着),等缓存好了大家再走缓存。这就是互斥锁 / single-flight:同一个 key 的重建,同一时刻只允许一个请求去做。再狠一点,干脆给热点 key 逻辑过期(不真过期,后台定时刷新),让它根本不会在请求高峰里突然消失。
雪崩:很多击穿一起来
你要是把击穿理解成「一个热点 key 过期引发的风暴」,那 就好懂了——它就是很多击穿同时发生。
最常见的成因:一大批 key 在差不多同一时间被写进缓存,又设了相同的 TTL,于是它们也在差不多同一时间集中过期。某个整点一到,成片缓存同时失效,请求洪水般压到 DB。另一种是更直接的——Redis 整个宕机了,所有请求一瞬间全部回落到 DB。
修法分两层:
- 打散过期时间:TTL 别用固定值,加一点随机抖动(比如
30 分钟 ± 随机几分钟),别让一批 key 约好了一起过期。这一条最便宜,也最该在第一天就做; - 别让缓存层本身成为单点:Redis 上高可用(主从 / 集群),再配上限流、降级兜底——万一缓存真没了,也别让 DB 被瞬间冲垮。
三个坑,一张表
| 触发条件 | 数据存在吗 | 范围 | 主要解法 | |
|---|---|---|---|---|
| 穿透 | 查不存在的数据,缓存建不起来 | 不存在 | 取决于被刷的量 | 缓存空值 / 布隆过滤器 |
| 击穿 | 单个热点 key 过期那一瞬 | 存在 | 一个 key | 互斥锁 / 逻辑过期 |
| 雪崩 | 大批 key 同时失效 / Redis 宕机 | 存在 | 一大批 key | TTL 随机抖动 / 高可用 / 降级 |
记忆的话:穿透看「数据在不在」,击穿看「一个热 key 的过期瞬间」,雪崩看「一大批一起没」。
那我现在还没流量,为什么要管这些
因为它们的「修」分两类,性质完全不同。
有些是事后能补的:流量真上来、某个 key 被打爆了,再加互斥锁也不迟。但有些是得提前埋的——
- 随机 TTL:要是一开始全用了固定过期,等你意识到雪崩风险时,缓存早就一批批同步过期了,回头改还得清理存量;
- 缓存空值:穿透往往是被人故意用不存在的 id 刷出来的,等接口被刷烂了才加,DB 已经先扛了一波。
所以我把随机 TTL 和空值缓存在上缓存的第一天就写进去了,互斥锁那类留到真有热点再说。这不是过度设计,是分清了「哪些便宜的防护顺手就做、哪些等信号再上」。
回头看
这篇标题想说的就是那句话:缓存不是「加一层就完事」。
加缓存,本质是拿一个新问题去换一个旧问题——你免掉了「DB 次次被查」的压力,换来的是「缓存和 DB 的一致性」,以及穿透、击穿、雪崩这三个新的失效模式。它确实让系统快了,但也让系统多了一层要照顾的东西。
ACGHub 这条性能线,到这儿是第二段了:索引让单条查询变快,缓存让重复查询不必落到 DB。等之后流量真起来,还会撞上新的——那时候单条快、缓存也有了,慢却可能出在「查的次数」上。不过那是后话了。
至于这三个坑,我现在算是把它们认全了,雷也标在了图上。剩下的,就等 ACGHub 哪天真有了那么多人,再来验证我标得对不对。
// related