cd ../articles

后端/性能

缓存不是加一层就完事

4 min read
数据库前面挡着一层缓存,墙上有三道裂缝,分别标着穿透、击穿、雪崩。
数据库前面挡着一层缓存,墙上有三道裂缝,分别标着穿透、击穿、雪崩。

上一篇给 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 宕机存在一大批 keyTTL 随机抖动 / 高可用 / 降级

记忆的话:穿透看「数据在不在」,击穿看「一个热 key 的过期瞬间」,雪崩看「一大批一起没」。

那我现在还没流量,为什么要管这些

因为它们的「修」分两类,性质完全不同。

有些是事后能补的:流量真上来、某个 key 被打爆了,再加互斥锁也不迟。但有些是得提前埋的——

  • 随机 TTL:要是一开始全用了固定过期,等你意识到雪崩风险时,缓存早就一批批同步过期了,回头改还得清理存量;
  • 缓存空值:穿透往往是被人故意用不存在的 id 刷出来的,等接口被刷烂了才加,DB 已经先扛了一波。

所以我把随机 TTL 和空值缓存在上缓存的第一天就写进去了,互斥锁那类留到真有热点再说。这不是过度设计,是分清了「哪些便宜的防护顺手就做、哪些等信号再上」。

回头看

这篇标题想说的就是那句话:缓存不是「加一层就完事」。

加缓存,本质是拿一个新问题去换一个旧问题——你免掉了「DB 次次被查」的压力,换来的是「缓存和 DB 的一致性」,以及穿透、击穿、雪崩这三个新的失效模式。它确实让系统快了,但也让系统多了一层要照顾的东西

ACGHub 这条性能线,到这儿是第二段了:索引让单条查询变快,缓存让重复查询不必落到 DB。等之后流量真起来,还会撞上新的——那时候单条快、缓存也有了,慢却可能出在「查的次数」上。不过那是后话了。

至于这三个坑,我现在算是把它们认全了,雷也标在了图上。剩下的,就等 ACGHub 哪天真有了那么多人,再来验证我标得对不对。

// related

作者头像
yen@harvey:~$ exit 0