缓存三连击实战:穿透、击穿、雪崩,一个热点 key 如何打挂数据库

那次大促,数据库 CPU 垂直拉到 100%、接口大面积超时、上游服务跟着雪崩,我们第一反应是扩容却根本来不及。复盘才发现根因小得哭笑不得:一个被几万人盯着的热点商品缓存恰好在那一刻过期了——几万并发瞬间穿过缓存把同一条查询砸了几万次,这就是缓存击穿。这篇从这个一个 key 打挂数据库的事故讲起,把缓存三大杀手穿透、击穿、雪崩怎么区分、各自怎么治讲透:singleflight 与分布式锁防击穿、空值缓存加布隆过滤器防穿透、随机 TTL 加多级缓存熔断降级防雪崩,再到 Cache-Aside 先更库再删缓存的一致性。

那次大促的故障来得又快又狠。监控大盘上,数据库的 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 宕机)。定性错了,后面的药全是错的。

收口成几条缓存设计的铁律

  1. 先定性,再下药:穿透、击穿、雪崩成因和解法完全不同,看"打到 DB 的请求模式"先分清是哪一种,别一上来就扩容。
  2. 热点 key 的重建必须串行化:用 singleflight / 分布式锁,保证过期瞬间只放一个请求回源,其余共享结果,防击穿。
  3. 不存在的也要缓存,海量随机查上布隆:空值缓存挡重复查空,布隆过滤器挡随机不存在 ID,防穿透。
  4. TTL 永远加随机抖动:杜绝大批 key 同一秒集体过期,这是最便宜的防雪崩手段。
  5. 给回源 DB 套上熔断降级:承认缓存可能整体失守,用熔断保住数据库,宁可降级也不让 DB 被打死引发连锁雪崩。
  6. 写路径用 Cache-Aside:先更库,再删缓存:删而非更新、先库后缓存,顺序反了会留脏数据;强一致场景再考虑延迟双删。
  7. 缓存是优化不是依赖:系统要能在缓存整体失效时降级活下来,而不是缓存一挂就全盘崩溃。

几个特别容易踩的认知误区

这套经验复盘给团队时,有几个误区几乎人人都有,值得专门点破。

第一个、也是我亲身踩的:"数据库被打高了,就是容量不够,加机器扩容。" 很多缓存类故障的表象都是"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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RAG 实战:知识库问答总在胡编?根因往往不是模型,而是检索

2026-5-29 21:17:58

技术教程

C# async/await 死锁实战:一个 .Result 如何让接口集体卡死

2026-5-29 21:30:15

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索