那次大促的故障来得又快又狠。监控大盘上,数据库的 CPU 几乎是垂直拉到 100%,慢查询堆成山,接口大面积超时,然后是雪崩式的连锁——上游服务因为等不到响应,线程池被占满,跟着也挂了。我们盯着监控,第一反应是"数据库扛不住了,加从库扩容",可扩容根本来不及。等火扑得差不多了复盘,才发现根因小得让人哭笑不得:一个被几万人同时盯着的热点商品详情缓存,恰好在那一刻过期了。
就那么一瞬间,几万个并发请求发现缓存没了,齐刷刷地穿过缓存、全砸到数据库上去重建这一个 key——数据库瞬间被同一条查询打了几万次,直接被打趴下。这就是经典的缓存击穿。这篇就从这个"一个 key 过期打挂整个数据库"的事故讲起,把缓存的三大杀手——穿透、击穿、雪崩——是什么、怎么区分、各自怎么治讲透。它们名字像、症状像,但成因和药方完全不同,认错一个,就会像我当初那样,拿着扩容的水管去浇一场根本不在那儿的火。
先分清:穿透、击穿、雪崩,根本不是一回事
这三个词太像了,很多人混着用,但它们是三种成因完全不同的故障。在动手治之前,必须先分清你遇到的到底是哪一种——因为开错药比不开药更糟。我用一张表把它们彻底拆开:
| 类型 | 说的是什么 | 触发条件 | 对应药方 |
|---|---|---|---|
| 缓存穿透 | 查一个根本不存在的数据,缓存和DB都没有,每次都落到DB | 恶意刷不存在的ID / 业务上大量查空 | 空值缓存 + 布隆过滤器 |
| 缓存击穿 | 单个热点 key 过期瞬间,大量并发同时去重建它 | 热点 key 到期 + 高并发 | 互斥锁重建 / 逻辑过期 |
| 缓存雪崩 | 大量 key 同时 失效,或缓存层整体宕机,请求全压向DB | 批量 key 同一时间过期 / Redis 挂了 | 随机过期 + 多级缓存 + 熔断降级 |
一句话记牢三者的区别:穿透是"查的东西压根不存在",击穿是"一个热点 key 没了",雪崩是"一大片 key 同时没了或缓存整个挂了"。穿透打的是"无效查询",击穿打的是"单点重建风暴",雪崩打的是"大面积失效"。我那次是典型的击穿——单个热点 key 过期引发重建风暴,可我一开始却当成"数据库容量不够"去扩容,自然按不住。
第一件事:遇到故障,先判断是哪一种
所以排查的第一步永远是定性。判断方法其实很直接,看几个特征就能分流:
# 1) 看打到 DB 的查询长什么样
# 如果全是查同一个 key/同一条记录 -> 击穿(单点重建风暴)
# 如果是查各种五花八门、且很多查不到结果的 ID -> 穿透(无效查询)
# 如果是大量不同的 key 在同一时间段集中回源 -> 雪崩(批量失效)
# 2) 看 Redis 状态
redis-cli info stats | grep keyspace # keyspace_misses 暴涨 -> 穿透或雪崩
redis-cli info clients # 连接是否异常、Redis 是否还活着
# 3) 看 key 的过期分布(雪崩高发原因:一批 key 设了相同 TTL)
# 如果大量 key 的 TTL 集中在同一秒到期,基本就是雪崩的隐患
这一步看着简单,却是整套处置的分水岭。穿透、击穿、雪崩的药方互不通用,甚至会互相添乱——你拿治雪崩的"加随机过期"去治穿透,一点用没有,因为穿透的请求压根不进缓存;你拿治击穿的"加互斥锁"去扛雪崩,锁反而会成为新的瓶颈。先定性,再下药,这是被那次事故狠狠教会我的第一课。下面就一种一种拆。
第二件事:缓存击穿——给热点 key 的重建上把锁
先治我踩到的这个。击穿的本质是:一个热点 key 过期的瞬间,成千上万个请求同时发现"缓存没了",于是同时冲去数据库重建它。这些请求做的是同一件事——查同一条数据、写回同一个 key,可它们彼此不知道,于是数据库被同一条查询砸了几万次。先看一下这个重建风暴是怎么形成的,以及加锁之后的正确路径:
核心思路就在那条虚线:缓存没了的时候,只放一个请求去查数据库重建,其余请求等它建好再读缓存,而不是一拥而上。Go 里有个特别合适的工具 singleflight,它能保证"同一个 key 的并发请求,只有一个真正去执行,其余的共享这一个的结果":
// 用 singleflight 防击穿:同一 key 的并发重建,只放一个去查 DB
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func GetProduct(ctx context.Context, id string) (*Product, error) {
// 1) 先查缓存
if p, ok := cache.Get(id); ok {
return p, nil
}
// 2) 缓存没有:同一 id 的并发请求,singleflight 只放一个进 fn,其余共享结果
v, err, _ := g.Do(id, func() (interface{}, error) {
// 这里同一时刻、同一个 id,全局只会有一个 goroutine 真正执行
p, err := db.QueryProduct(ctx, id) // 真正打 DB 的,只有这一次
if err != nil {
return nil, err
}
cache.SetEx(id, p, 10*time.Minute) // 重建并写回缓存
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
这样一来,哪怕一万个请求同时发现 key 过期,真正打到数据库的也只有一次,其余 9999 个都在等这一次的结果。当时如果这个热点 key 上有这么一把"重建锁",那场故障根本不会发生——几万请求会乖乖排队等一次重建,而不是一起把数据库踩死。
互斥锁还有个变体值得知道:逻辑过期。前面那种做法,没抢到锁的请求要短暂阻塞等待;如果你的热点 key 绝对不能让用户等,可以换个思路——key 在 Redis 里永不物理过期,而是在 value 里存一个"逻辑过期时间"。读到 value 后判断逻辑上是否已过期,过期了就异步开个 goroutine 去重建,当前请求先返回旧值。这样用户永远拿得到数据(哪怕短暂是旧的),代价是实现更复杂、且会读到一小段时间的旧数据。两种选择的取舍很清晰:不能容忍读到旧数据,用互斥锁(牺牲一点延迟);不能容忍任何阻塞等待,用逻辑过期(牺牲一点一致性)。
第三件事:缓存穿透——把"不存在"也拦在 DB 之外
穿透是另一种完全不同的攻击面:请求查的是一个根本不存在的数据。缓存里没有(因为压根没这条),数据库里也没有,于是每个这样的请求都会穿过缓存、白白查一次数据库还查不到。最怕的是被恶意利用——有人拿一堆随机的、不存在的 ID 来刷你的接口,每一个都直击数据库,缓存形同虚设。治它有两层防线。
第一层最简单:把"查不到"这件事本身也缓存起来。查数据库发现不存在,就往缓存里写一个空值(标记为"此 ID 不存在"),并设一个较短的过期时间。下次再查这个不存在的 ID,直接命中这个空值缓存,不再打数据库:
// 防穿透第一层:缓存空值,让"查不到"也只查一次 DB
func GetUser(ctx context.Context, id string) (*User, error) {
val, ok := cache.GetRaw(id)
if ok {
if val == NULL_MARKER { // 命中"空值缓存":之前查过,确实不存在
return nil, ErrNotFound
}
return decode(val), nil
}
u, err := db.QueryUser(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
// 关键:不存在也写缓存,但 TTL 要短,避免占用太多内存 & 数据后来真出现时延迟可控
cache.SetEx(id, NULL_MARKER, 60*time.Second)
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
cache.SetEx(id, encode(u), 10*time.Minute)
return u, nil
}
但空值缓存挡不住"每次都用不同的随机不存在 ID"来刷的场景——每个新 ID 都得先查一次 DB 才知道不存在。这就需要第二层防线:布隆过滤器(Bloom Filter)。它用极小的内存就能维护"所有真实存在的 ID 集合",在请求进缓存之前先问它一句:这个 ID 可能存在吗?
// 防穿透第二层:布隆过滤器,在查缓存/DB 之前先挡掉绝不存在的 ID
// 布隆过滤器特性:说"不存在"一定准;说"存在"有极小概率误判(可接受)
func GetUserWithBloom(ctx context.Context, id string) (*User, error) {
if !bloom.MightContain(id) {
// 布隆说"绝对没有",直接拦截,连缓存都不用查 —— 恶意随机ID全卡在这
return nil, ErrNotFound
}
return GetUser(ctx, id) // 可能存在,才走正常的缓存+DB 逻辑
}
// 注意:新增数据时要同步把新 ID 灌进布隆过滤器,否则会把真实数据误拦
布隆过滤器的妙处在于它的不对称性:它说"不存在"是 100% 可信的,说"存在"才有极小误判率。而我们正好用它来"拦截不存在的",所以这个误判方向完全不影响正确性——最坏不过是放个别不存在的 ID 进去走一遍正常流程,而绝大多数恶意随机 ID 都被它用几乎可以忽略的内存挡在了门外。
第四件事:缓存雪崩——别让一大片 key 同时倒下
雪崩是规模最大、最吓人的一种:不是一个 key,而是一大批 key 在几乎同一时刻集体失效,或者干脆是 Redis 整个挂了,海量请求瞬间全压到数据库上。它最常见的成因特别"无辜"——你在某次批量预热缓存时,给一万个 key 设了完全相同的过期时间,于是它们就会在同一秒一起到期、一起回源。治雪崩要从三个层面同时下手。
第一层,也是最便宜的:给过期时间加随机抖动,把"同一秒集体到期"打散成"在一个时间窗里错峰到期":
// 防雪崩第一招:TTL 加随机抖动,避免大批 key 同一时刻集体过期
func cacheWithJitter(key string, val interface{}, base time.Duration) {
// 基础 10 分钟 + 0~3 分钟随机:把集中到期摊平到一个时间窗内
jitter := time.Duration(rand.Int63n(int64(3 * time.Minute)))
cache.SetEx(key, val, base+jitter)
}
第二层,应对"Redis 整个挂掉"这种极端情况:多级缓存 + 熔断降级。本地缓存(进程内,如 Go 的 ristretto 或一个带 TTL 的 map)作为 Redis 之前的一道挡板,Redis 挂了至少热点数据还能从本地兜住一部分;同时给数据库访问加熔断——当回源 DB 的失败率/耗时超过阈值,直接熔断、走降级逻辑(返回兜底数据或友好提示),保住数据库不被彻底冲垮:
// 防雪崩第二招:多级缓存 + 对 DB 的熔断降级
func GetHot(ctx context.Context, id string) (*Item, error) {
if v, ok := localCache.Get(id); ok { // L1: 进程内本地缓存,Redis 挂了也能兜
return v, nil
}
if v, ok := redis.Get(id); ok { // L2: Redis
localCache.Set(id, v, time.Minute)
return v, nil
}
// L3: 回源 DB,但用熔断器包住——失败率过高就直接降级,别让 DB 被压垮
v, err := breaker.Execute(func() (interface{}, error) {
return db.Query(ctx, id)
})
if err != nil {
return fallbackItem(id), nil // 熔断/失败时返回兜底值,保住整体可用
}
item := v.(*Item)
redis.SetEx(id, item, 10*time.Minute)
localCache.Set(id, item, time.Minute)
return item, nil
}
这三层是递进的:随机过期防的是"自己作"(批量同 TTL),多级缓存防的是"Redis 挂了",熔断降级则是最后的兜底——承认极端情况下缓存可能整体失守,那就用熔断把数据库保下来,宁可降级返回兜底数据,也绝不让数据库被打死引发更大范围的连锁雪崩。那次故障最惨的不是缓存击穿本身,而是数据库被打挂后,上游服务跟着雪崩——如果当时 DB 访问外面包着一层熔断,损失会小得多。
第五件事:别忘了一致性——缓存和数据库怎么不打架
把三大杀手都治了之后,还有一个绕不开的老问题:数据更新时,缓存和数据库怎么保持一致?这不算"故障",但处理不好会让用户读到脏数据,排查起来同样要命。最主流、也最该作为默认的策略叫 Cache-Aside(旁路缓存):读的时候缓存优先、未命中再回源并写回(前面几段一直在用的就是它);写的时候,先更新数据库,再删除缓存——注意是"删除"而不是"更新"缓存。
// Cache-Aside 写路径:先更新 DB,再删缓存(不是更新缓存)
func UpdateProduct(ctx context.Context, p *Product) error {
if err := db.UpdateProduct(ctx, p); err != nil { // 1) 先落库,这是唯一可信源
return err
}
cache.Del(p.ID) // 2) 再删缓存,下次读自然回源重建为最新值
return nil
}
这里有两个反直觉但重要的点。第一,为什么是"删除"而不是"更新"缓存?因为更新缓存意味着你要在写路径里也算一遍那个可能很复杂的值,既浪费(写多读少时,算了没人读)又容易在并发下把缓存写成旧值;而"删除"是幂等且简单的——把重建的活儿留给下一次读,让读路径统一负责构建,逻辑只有一处。第二,为什么是"先更库再删缓存"而不是反过来?如果先删缓存再更库,在这中间的空窗期里,有读请求进来会把旧的 DB 值重新加载进缓存,等你的库更新完,缓存里却躺着刚被重建的旧值,脏数据就这么留下了。
即便"先更库再删缓存",在极端并发时序下仍有小概率不一致(比如删缓存的请求和某个慢读请求交错)。对一致性要求高的场景,可以用延迟双删兜底:更库前后各删一次,第二次延迟一小会儿删,把那个空窗期里可能被加载进来的旧值再清掉一遍。但要清楚,这是用复杂度换一致性,大多数业务到"先更库再删缓存"这一步就够了,别过度设计。
把整套处置收成一棵决策树
把前面的判断和药方串起来,下次缓存类故障来袭,照着这棵树走,先定性再下药:
这棵树的根,就是第一件事反复强调的那句话:先看"打到数据库的请求长什么样",据此定性,再对症下药。查同一条 → 击穿;查一堆不存在的 → 穿透;一大片不同 key 同时回源 → 雪崩(再细分是批量过期还是 Redis 宕机)。定性错了,后面的药全是错的。
收口成几条缓存设计的铁律
- 先定性,再下药:穿透、击穿、雪崩成因和解法完全不同,看"打到 DB 的请求模式"先分清是哪一种,别一上来就扩容。
- 热点 key 的重建必须串行化:用 singleflight / 分布式锁,保证过期瞬间只放一个请求回源,其余共享结果,防击穿。
- 不存在的也要缓存,海量随机查上布隆:空值缓存挡重复查空,布隆过滤器挡随机不存在 ID,防穿透。
- TTL 永远加随机抖动:杜绝大批 key 同一秒集体过期,这是最便宜的防雪崩手段。
- 给回源 DB 套上熔断降级:承认缓存可能整体失守,用熔断保住数据库,宁可降级也不让 DB 被打死引发连锁雪崩。
- 写路径用 Cache-Aside:先更库,再删缓存:删而非更新、先库后缓存,顺序反了会留脏数据;强一致场景再考虑延迟双删。
- 缓存是优化不是依赖:系统要能在缓存整体失效时降级活下来,而不是缓存一挂就全盘崩溃。
几个特别容易踩的认知误区
这套经验复盘给团队时,有几个误区几乎人人都有,值得专门点破。
第一个、也是我亲身踩的:"数据库被打高了,就是容量不够,加机器扩容。" 很多缓存类故障的表象都是"DB 扛不住",但根因在缓存层——击穿是重建风暴、穿透是无效查询、雪崩是大面积回源。这时候扩 DB 既慢又治标不治本,水管对着错误的方向浇。看到 DB 异常飙高,先怀疑缓存层,看打过去的请求模式,而不是条件反射地扩容。
第二个误区:"穿透、击穿、雪崩差不多,都是缓存没挡住。" 它们成因天差地别:穿透是"查的东西不存在",击穿是"一个热点 key 没了",雪崩是"一大片 key 同时没了"。对应的药——布隆/空值、互斥锁、随机过期+熔断——互不通用,甚至会互相添乱。混为一谈,就会开错药。
第三个误区:"更新数据时,把缓存也一起更新成新值,最直接。" 更新缓存看着直接,实则埋雷:写多读少时是无用计算,高并发下两个写请求还可能把缓存覆盖成旧值。正确做法是删除缓存、把重建交给下一次读,简单且幂等。而且顺序必须是"先更库再删缓存",反过来会在空窗期被旧值重新填充。
第四个误区:"加了缓存,系统就更稳了。" 缓存用好了是性能利器,用不好反而是新的单点风险——它一挂,所有流量瞬间砸向数据库,可能比没有缓存时更脆弱。真正稳的系统,是把缓存当"优化"而非"依赖":缓存全没了,靠熔断降级也得能活着。把缓存当成必须永远在线的依赖,本身就是一种架构上的脆弱。
一个容易被漏掉的坑:singleflight 只管得住一台机器
前面讲防击穿时我用了 singleflight,但这里藏着一个上线后才会暴露的坑,值得单独拎出来说:singleflight 是进程内的,它只能保证"同一台机器上的并发请求只回源一次"。而生产环境里你的服务往往部署了几十个实例,热点 key 过期那一刻,每台机器各自放一个请求去查 DB——你有 50 个实例,数据库还是会被同时打 50 次。在单机上你以为问题解决了,一上集群,击穿换了个规模又回来了。
要在整个集群层面只放一个请求回源,就得用分布式锁,通常基于 Redis 的 SET key value NX EX 实现:谁先 SETNX 成功谁去重建,其余实例的请求短暂等待后重读缓存:
// 集群级防击穿:Redis 分布式锁,保证全集群同一 key 只有一个实例回源
func rebuildWithLock(ctx context.Context, id string) (*Product, error) {
lockKey := "lock:" + id
token := uuid.NewString() // 唯一标识,释放锁时校验,防止误删别人的锁
// SET NX EX:只有第一个抢到锁的实例返回 true,并自带过期时间防死锁
ok, _ := redis.SetNX(ctx, lockKey, token, 5*time.Second)
if !ok {
time.Sleep(50 * time.Millisecond) // 没抢到:稍等,让赢家把缓存建好
if p, ok := cache.Get(id); ok {
return p, nil // 赢家已重建,直接读到
}
return rebuildWithLock(ctx, id) // 仍没有则重试
}
defer redis.DelIfMatch(ctx, lockKey, token) // 用 token 校验后再删,只删自己的锁
p, err := db.QueryProduct(ctx, id) // 全集群只有这一次真正打 DB
if err != nil {
return nil, err
}
cache.SetEx(id, p, 10*time.Minute)
return p, nil
}
注意两个细节:锁要带过期时间(EX),否则持锁实例万一崩了,锁永远不释放,所有请求都卡死——这叫死锁;释放锁时要用 token 校验是不是自己的锁(DelIfMatch),否则你可能删掉另一个实例刚抢到的锁。"单机 singleflight + 集群分布式锁"两层叠加才是完整的防击穿:singleflight 先把单机内成千上万的请求收敛成一个,分布式锁再把几十台机器收敛成一个,数据库最终只挨这一次。这个"单机有效、集群失效"的差异,是很多人在测试环境一切正常、一上生产就翻车的典型原因。
写在最后
回到开头那场大促故障。事后我们做的事,其实就是把这篇讲的几招老老实实补上:给热点 key 的重建加 singleflight、给 TTL 加随机抖动、给回源数据库套上熔断、写路径统一成"先更库再删缓存"。改动不算大,但下一次大促,同样量级的流量打过来,数据库的 CPU 曲线平稳得像什么都没发生——那个曾经能"一个 key 过期就打挂全站"的脆弱点,被一层层补成了一套有纵深的防御。
这件事给我最深的体会是:缓存从来不只是"加个 Redis 提速"那么简单,它是一套需要认真设计防御纵深的系统。穿透、击穿、雪崩这三个名字相近的杀手,背后是三种完全不同的失效模式;而真正稳的缓存设计,是承认"缓存一定会在某个时刻失守",然后为每一种失守都准备好对应的兜底。下次你给系统加缓存,别只想着它带来的那点速度提升,多想一步:当这个 key 过期的瞬间、当有人拿不存在的 ID 来刷、当 Redis 突然挂掉——我的系统,接得住吗?
—— 别看了 · 2026