
预计阅读: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 + 预热 + 监控 |

空值缓存有一个经典坑:攻击者用随机 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 查询结果。不需要分布式锁,不需要考虑超时和续期。但它有两个陷阱:
- error broadcasting:如果回源的那个请求返回 error,所有等待的请求都会拿到同一个 error。1000 个并发请求,全部拿到同一个 error。在高并发下,一次偶发的 DB 超时可能引发雪崩式报错。缓解方法:用
DoChan替代Do,它返回 channel 而非阻塞等待,配合select+ctx.Done()可以给每个等待者设独立超时。 - 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