缓存穿透/击穿/雪崩:面试能背,上线能用吗

缓存穿透/击穿/雪崩的教科书方案都有隐藏工程账单——布隆过滤器的内存成本、互斥锁的延迟税、预热脚本的维护债。用 Go 实测数据逐笔拆账,按 QPS 量级给出分层选型判断。

封面

预计阅读:12 分钟

面试问缓存穿透怎么解决,你说"加布隆过滤器"。面试官点头。

面试问缓存击穿怎么解决,你说"加互斥锁,只让一个请求回源"。面试官继续点头。

缓存雪崩?“过期时间加随机值 + 预热。” 三道题,三个标准答案,评分表上三个勾。

然后你拿到 offer,上线第一周就发现事情没那么简单。

布隆过滤器要多少内存?你翻遍了面试笔记,没找到答案。你的 Redis 节点才 512MB,1 亿 key 的布隆过滤器就要吃掉 114MB,超过总内存的 22%。

锁加在哪一层?本地 Mutex 还是 Redis SET ... NX EX?超时设多少?持锁的那个请求挂了,其他请求等多久才放弃?

预热脚本谁写?写完谁维护?上个写预热脚本的人两年前离职了,你现在是唯一知道这个脚本存在的人。

教科书方案没有错。布隆过滤器确实能拦住穿透,互斥锁确实能防止击穿,预热确实能缓解雪崩。

但教科书没告诉你的是:每个方案都有一张隐藏的工程账单。布隆过滤器有内存成本,互斥锁有延迟税,预热脚本有维护债。面试官不考你这些,因为这些属于工程判断,而工程判断只能在生产环境里学。

这篇文章帮你算清楚三张账:布隆过滤器的内存账,互斥锁的延迟账,预热脚本的维护账

第一章先校准前提——你的系统 QPS 到了需要这些方案的门槛吗?我按并发量级分了三档,不同档位需要的防护强度完全不同。后面三章逐笔拆账:布隆过滤器在不同数据规模下吃多少内存,三种锁方案各自的延迟代价和适用边界,预热脚本从 120 行 demo 膨胀到生产版的全过程。赶时间的话,直接跳到你关心的那章。

但在翻账本之前,先问一个更基本的问题。

一、你的 QPS 到了这个门槛吗?

很多人一听到"缓存穿透"就条件反射地想加布隆过滤器。但冷静想一下:你的系统真的需要吗?

这取决于一个核心参数:你的 QPS 有多高

以常见 4C8G 云实例、InnoDB、主键查找为基准,MySQL 单机约 3000-5000 QPS。复杂查询(JOIN、子查询、全表扫描)更低,大约 500-1500 QPS。这是 DBA 圈子的常见经验值,实际数字受硬件、表设计、索引影响可能有数倍差异。

如果你的系统总 QPS 只有几百,就算缓存全部失效、所有请求穿透到 DB,数据库也扛得住。缓存穿透不会打爆一个本来就没多少流量的系统。

但很多人不做这个判断。他们看了面试题就开始加布隆过滤器,看了技术博客就开始搞分布式锁——在一个 QPS 300 的系统上搞了一套 QPS 30000 才需要的基础设施。

我按 QPS 量级做了一个分层判断:

QPS 量级 风险 建议
< 1000 空值缓存 + 短 TTL,不需要重装备
1000-5000 singleflight + 随机过期
> 5000 全套:布隆 + 锁/singleflight + 预热 + 监控

QPS量级分层决策

空值缓存有一个经典坑:攻击者用随机 key 大量请求时,缓存会被海量空值条目填满。务必给空值设短 TTL(30-60 秒),流量异常时配合限流。

推演逻辑很简单:MySQL 单机 QPS 上限约 3000-5000(简单查询),总 QPS 远低于这个数字,就算全部穿透,DB 也扛得住。

做个算术。用 95% 作保守基线(设计良好的系统可达 99%),看穿透量的放大效应:

  • 总 QPS 5000,命中率 99% → 穿透 50 → MySQL 毫无压力
  • 总 QPS 5000,命中率 95% → 穿透 250 → 开始有感觉
  • 总 QPS 50000,命中率 95% → 穿透 2500 → DB 直接告急

命中率从 99% 掉到 95%,穿透量翻了 5 倍。如果因为大量 key 同时过期(雪崩)或恶意请求(穿透),命中率继续掉到 90% 甚至 80%,穿透量就是灾难级的。

穿透放大效应

判断你需不需要搞这些方案,看两个指标:总 QPS缓存命中率的波动幅度。前者决定天花板,后者决定风险窗口。

如果你的系统确实在 5000+ QPS 的量级,或者缓存命中率经常掉到 95% 以下——接着往下看,我们来算第一笔账。

但如果你的系统 QPS 在 1000 以下,认真地说:与其在这个量级上搞布隆过滤器和分布式锁,不如花两天做好空值缓存和过期时间随机化。前者是武装到牙齿,后者是够用就好。把工程资源花在 ROI 更高的地方(性能优化、监控告警、核心业务逻辑),这才是更务实的判断。

二、布隆过滤器的内存账

面试标准答案:用布隆过滤器拦截不存在的 key,防止穿透到数据库。

一句话说完了。但布隆过滤器有物理体积,这个体积可能比你想的大得多。

布隆过滤器的内存占用由两个参数决定:数据规模(你要存多少 key)和误判率(你能容忍多少"假阳性")。公式是 m = -n × ln(p) / (ln2)²,我用 Go 跑了一组精确计算:

func bloomFilterSize(
    n int, p float64,
) (bits int, mb float64) {
    ln2sq := math.Log(2) *
        math.Log(2)
    m := -float64(n) *
        math.Log(p) / ln2sq
    bits = int(math.Ceil(m))
    mb = float64((bits+7)/8) /
        (1024 * 1024)
    return
}

以下数据基于 Go 1.26.2 (linux/amd64) 精确计算:

数据规模 误判率 5% 误判率 1% 误判率 0.1%
100 万 key 0.7 MB 1.1 MB 1.7 MB
1000 万 key 7.4 MB 11.4 MB 17.1 MB
1 亿 key 74.3 MB 114.3 MB 171.4 MB

看中间那列——1% 误判率是最常用的配置。1000 万 key 要 11.4 MB,对一个 Redis 节点来说可以接受。但如果你的系统有 1 亿 key(用户 ID、商品 ID、订单 ID 加起来很容易到这个量级),布隆过滤器就要吃掉 114.3 MB。

这里有个常见的认知偏差:面试时我们谈论布隆过滤器,脑子里想的是"100 万 key"的量级,内存只有 1 MB,几乎免费。但生产环境的数据规模往往是百倍甚至千倍于此。从 100 万到 1 亿,内存增长了 100 倍。严格来说是线性增长(误判率不变时,每个元素需要的比特数固定,1% 误判率约 9.6 bits/元素),但绝对值已经大到不能忽略。

布隆过滤器内存增长

114.3 MB 是什么概念?如果你的 Redis 节点配置是 512MB(中小公司很常见的配置),布隆过滤器一个人就占了 22.3%。加上业务数据和其他缓存,内存很可能不够用。

就算你用的是 2GB 实例,1 亿 key 也要占掉 5.6%。这只是一个过滤器。如果有多个业务场景各自需要布隆过滤器(用户 ID 一个、商品 ID 一个、订单 ID 一个),内存占用就是线性叠加的。

还有一个容易忽略的数字:哈希函数数量。1% 误判率需要 7 个哈希函数,0.1% 需要 10 个。每次查询都要算这么多次哈希,在 CPU 敏感的场景下(比如高 QPS + 低延迟要求),这也是一笔开销。

而且布隆过滤器有一个结构性限制:不支持删除。你往里加了一个 key,就永远在里面了。如果你的 key 集合会变化(商品下架、用户注销、订单作废),布隆过滤器里的数据会越来越"脏",误判率会逐渐上升,最终超过你设定的阈值。

怎么办?定期重建——每天凌晨全量重建一次。但你需要扫全表获取有效 key、重新计算、原子替换旧过滤器、处理替换期间的查询请求。也可以换成支持删除的 Cuckoo Filter,但 Go 生态的成熟实现有限。两条路都不是免费的。

部署上还有一个前置问题:Redis 原生不支持布隆过滤器,你需要 Redis Stack 或加载 RedisBloom 模块(部分云实例不支持)。另一条路是应用内嵌,每个实例各持一份,但要处理实例间同步。选 Redis 模块还是应用内嵌,取决于你的运维能力和实例拓扑。

这些决策在面试中不会被问到,但在第一次线上故障复盘会上一定会被提起。

内存算清楚了,延迟呢?

三、互斥锁的延迟账

面试标准答案:缓存击穿时加互斥锁,只让一个请求回源,其他请求等待。

方案原理没问题。但"加锁"两个字背后有三个必须回答的问题:锁加在哪一层?用什么锁?其他请求等多久?

这三个问题在面试中从来不会被追问——面试考查的是"你知道这个方案",但落地考查的是"你能驾驭这个方案"。两者之间隔着一堆工程细节。

我模拟了一个典型的缓存击穿场景:所有请求同时到达同一个 hot key,DB 查询延迟 50ms。

以下数据仅展示方案模式差异(阻塞 vs 非阻塞),绝对值基于 time.Sleep 模拟,不可作为真实环境性能参考。

以下代码基于 Go 1.26.2 (linux/amd64),用 time.Sleep 模拟 DB 查询延迟,对比三种方案:

// 方案 1:互斥锁(sync.Mutex)
mu.Lock()
defer mu.Unlock()
if v, ok := cache[key]; ok {
    return v
}
v := fetchFromDB(key) // 50ms
cache[key] = v
return v

// 方案 2:singleflight
v, _, _ := g.Do(key,
    func() (any, error) {
        return fetchFromDB(key)
    })

// 方案 3:逻辑过期
v, ok := cache.Get(key)
if !ok {
    // 冷启动:cache 无旧值,
    // 必须同步回源
    return fetchFromDB(key)
}
if expired(key) {
    go asyncRefresh(key)
}
return v // 返回旧值
方案 并发100 并发1000 一致性
互斥锁 ~50ms ~51ms
singleflight ~50ms ~51ms
逻辑过期 ~1μs ~1μs 最终

锁方案权衡对比

互斥锁和 singleflight 在延迟上差不多,都约等于一次 DB 查询时间。但细节差异很大。重点看选型矩阵(下方),那才是真正有决策价值的部分。

**互斥锁(sync.Mutex 或 Redis SET ... NX EX)**的问题在于锁粒度。用本地 Mutex?只能保护单个进程,多实例部署下每个实例都会有一个请求回源,击穿防护效果打折。用 Redis SET ... NX EX 做分布式锁?锁本身就多了一次 Redis 往返。而且你必须回答三个子问题:

  • 锁超时设多少? 太短,DB 查询还没回来锁就过期了,其他请求冲进来;太长,持锁的请求挂了,所有人都在等一把永远不会释放的锁。
  • 要不要做锁续期? 续期(类似 Redisson 的看门狗机制)能解决超时问题,但代码复杂度翻倍,还引入了对续期线程的依赖。
  • 网络分区怎么办? Redis 主从切换时锁可能丢失。Martin Kleppmann 和 antirez 关于 Redlock 的争议至今未定论。这意味着 Redis 分布式锁在主从切换时没有强一致保证,如果你的场景不能容忍锁丢失,别只靠 Redis 做分布式锁。

singleflight 在 Go 生态是更优雅的选择——golang.org/x/sync/singleflight 直接用,所有相同 key 的并发请求共享同一个 DB 查询结果。不需要分布式锁,不需要考虑超时和续期。但它有两个陷阱:

  1. error broadcasting:如果回源的那个请求返回 error,所有等待的请求都会拿到同一个 error。1000 个并发请求,全部拿到同一个 error。在高并发下,一次偶发的 DB 超时可能引发雪崩式报错。缓解方法:用 DoChan 替代 Do,它返回 channel 而非阻塞等待,配合 select + ctx.Done() 可以给每个等待者设独立超时。
  2. panic broadcasting:更危险——如果回源请求 panic 了,singleflight 会把 panic 传播给所有等待的 goroutine。如果你的 recover 没有做好,整个服务可能挂掉。

注意:singleflight 是进程级的。如果有 N 个 Pod,每个 Pod 各放一个请求回源,DB 仍然收到 N 次查询。100 个 Pod = 100 QPS 打到 DB,超高并发场景下可能仍不够。

逻辑过期是延迟最低的方案(微秒级),代价是返回旧数据——缓存永不真正过期,读到逻辑过期后返回旧值、后台异步刷新。商品详情、文章内容、用户 Profile 这类场景很合适。但金融交易、库存扣减、秒杀计数,你不可能给用户返回"5 分钟前的库存数"。使用逻辑过期时,建议仍设较长的物理 TTL(逻辑过期时间的 2-3 倍)作兜底,否则不设 TTL 的 key 在 Redis 内存满时无法被淘汰。

所以"加锁"不是一个动作,是一组选型决策。 面试答"加互斥锁"只回答了 1/3 的问题。你还需要决定锁的粒度、超时、续期、分布式一致性——每一个子决策都有工程成本。

给你一个快速判断参考:

场景 推荐方案 理由
单实例 + Go singleflight 零配置,性能好
多实例 + 强一致 Redis 分布式锁 跨实例防击穿,要处理超时和续期
多实例 + 可接受旧数据 逻辑过期 延迟最低,不需要锁
多实例 + 高并发 + 强一致 singleflight + 短TTL 实例内合并请求,跨实例靠短 TTL 收敛

锁方案选型决策

实际工程中,第四种方案是 Go 社区讨论中出现频率最高的选择——既避免了分布式锁的复杂度,又能在可接受的时间窗口内保持数据一致性。

锁选好了,数据怎么预热?

四、预热脚本的维护账

“预热"两个字说出来只要 0.5 秒。写一个能跑的预热脚本要多久?

预热逻辑本身不复杂,复杂的是一个"能跑"的脚本和一个"能上线"的脚本之间,隔着至少 8 个工程决策。

我写了一个最小版本——从 DB 扫描 key → 分批写入缓存 → 并发限流 → 错误重试。核心逻辑如下:

type CacheWarmer struct {
    batchSize    int
    concurrency  int
    retryMax     int
    retryBackoff time.Duration
}

func (w *CacheWarmer) Run(
    ctx context.Context,
) error {
    keys, err := w.scanKeys(ctx)
    if err != nil {
        return err
    }
    sem := make(chan struct{},
        w.concurrency)
    var wg sync.WaitGroup
    for i := 0; i < len(keys);
        i += w.batchSize {
        end := min(
            i+w.batchSize, len(keys))
        for _, k := range keys[i:end] {
            sem <- struct{}{}
            wg.Add(1)
            go func(key string) {
                defer func() {
                    <-sem
                    wg.Done()
                }()
                w.warmKeyWithRetry(
                    ctx, key)
            }(k)
        }
    }
    wg.Wait()
    return nil
}

实测结果(Go 1.26.2, linux/amd64, DB 延迟用 time.Sleep 模拟):1 万 key,并发 10,吞吐约 842 keys/sec,耗时约 12 秒。

完整实现约 120 行(含 scanKeys、warmKeyWithRetry 等辅助函数)。功能只覆盖了最基本的预热流程。但你把这个脚本交给 SRE 审查,他会问你至少 8 个问题:

# 维护点 面试会考吗
1 数据源遍历
2 批大小调优
3 并发限流
4 错误处理
5 幂等性
6 监控告警
7 触发时机
8 资源隔离

展开看每一项:

1. 数据源遍历:游标分页还是全量扫描?5000 万行的表全扫一次要多久?分页用自增 ID 还是时间戳做游标?

2. 批大小调优:batch=500 合适吗?太大可能触发 Redis 慢查询日志,太小预热 1 亿 key 要跑几个小时。实际写入 Redis 时,用 Pipeline 批量发送命令而非逐条 SET,吞吐可提升 5-10 倍。

3. 并发限流:channel 限流只能控制并发数,不能控制 QPS。如果每个 key 查 DB 只要 1ms,并发 10 = 10000 QPS,可能比线上流量还大。

4. 错误处理:单 key 失败跳过还是中止?DB 连接池满了导致大面积失败时,跳过意味着大量 key 没预热,中止意味着完成率可能只有 30%。

以上四点是"写脚本"要解决的。下面四点是"上线运维"要解决的——往往更隐蔽,出问题也更晚。

5. 幂等性:预热到 60% 中断了,重跑时会覆盖这期间用户写入的新数据吗?覆盖了刚改的商品价格,后果很严重。

6. 监控告警:怎么知道预热完成?成功率 95% 算合格吗?失败了谁收到告警?

7. 触发时机:部署时自动跑?定时任务每天凌晨跑?Redis 重启后手动触发?每种方式实现和维护成本都不同。

8. 资源隔离:预热流量和线上流量走同一个 DB 连接池吗?预热占了 80% 连接,线上请求会被影响。

预热脚本复杂度增长

8 个维护点,每个单独看都不算复杂。但加在一起,Demo 版的 120 行代码会膨胀到 300-500 行——加上配置管理、日志、监控打点、优雅停止(收到 SIGTERM 时等当前批次处理完再退出)。而且这 300-500 行代码没有一行有业务价值,纯粹是基础设施投入。

我上一个项目的预热脚本,从 Demo 到生产版花了 3 人天。半年后数据表加了字段,排查预热数据不完整又花了 1 人天。

更麻烦的是预热脚本的维护半衰期。上线的第一个月,大家都记得有这个脚本。半年后它从日常维护列表上消失了。两年后,数据表结构变了,新加的字段没有被预热脚本覆盖,但没人知道,因为脚本跑成功了(成功率 100%),只是预热了一份不完整的数据。直到某天凌晨三点缓存雪崩,你才发现预热脚本的最后一次代码更新是两年前离职的那个人做的。

预热脚本的真正成本不是写它,是养它。

账算完了,然后呢?

三笔账总结

回到开头的场景。面试的时候你可以说"布隆过滤器、互斥锁、预热”——这些确实是正确答案。

但上线之后你需要算三笔账:

  • 内存账:1 亿 key 的布隆过滤器要 114MB,你的 Redis 节点够大吗?不支持删除,key 集合变化时你准备定期重建还是换 Cuckoo Filter?
  • 延迟账:加锁方案的延迟约等于一次 DB 查询。用本地锁还是分布式锁?singleflight 的 error broadcasting 你处理了吗?
  • 维护账:预热脚本 120 行起步,生产版 300-500 行,写完之后谁负责维护?

下次技术方案评审时有人提出"加个布隆过滤器",试着追问:

  • 数据规模多少?Redis 节点内存够吗?key 集合会变化吗?
  • 锁加在哪一层?超时设多少?
  • 预热脚本谁写?写完谁维护?半夜跑失败了谁收到告警?

把账算清楚再上方案,比出了事再补方案,便宜得多。

附录:实验数据与代码

本文涉及的所有实测数据(布隆过滤器内存计算、锁方案延迟对比、预热脚本 Demo)均有可运行代码和完整输出,已公开在 GitHub 仓库:

👉 zhiyulab-evidence/cache-breakdown-myths

论据 代码 输出
布隆过滤器内存占用 code/bloom-memory/main.go output/bloom-memory.txt
锁方案延迟对比 code/lock-benchmark/main.go output/lock-benchmark.txt
预热脚本 Demo code/warmup-demo/main.go output/warmup-demo.txt

实验环境:Go 1.26.2 (linux/amd64),延迟数据使用 time.Sleep 模拟 DB 查询,非真实 Redis/MySQL 环境。

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →