你以为缓存只会被击穿和穿透?击穿才是那个最隐蔽的杀手

2026-10-12 16 0

你以为缓存只会被击穿和穿透?击穿才是那个最隐蔽的杀手

大多数工程师聊到缓存问题,张口就是"缓存穿透"、"缓存雪崩",仿佛把这两个词挂在嘴边就等于理解缓存了。

行,我承认这两个确实重要。但你们知道吗?缓存击穿才是那个让你白天还正常运行、凌晨两点突然暴毙的元凶。

而且——这个坑,在我见过的大部分技术文章里,都没讲透。


先说清楚三个东西到底是啥

很多人把三个概念混为一谈,我先把刀磨利。

  • 缓存穿透:查询一个根本不存在的数据,缓存没有,数据库也没有,每次都直击后端。攻击者喜欢用这个搞你。
  • 缓存雪崩:大量缓存 key 在同一时间集中过期,或者缓存服务本身宕机,导致大量请求直接打到数据库。
  • 缓存击穿:一个热点 key突然过期或者不存在,而在这个 key 重建的瞬间,大量并发请求同时涌入,全部去查数据库。

看出区别了吗?穿透是"恶意查询不存在的东西",雪崩是"集体过期/宕机",而击穿是单个热点 key 的"瞬间重建风暴"——这个"瞬间"才是关键。


一个真实的故事让你明白击穿有多可怕

我之前朋友的公司,做电商的。某天凌晨两点,监控报警数据库 CPU 飙升到 98%,紧接着服务雪崩。

排查了一晚上发现:晚上十点,一个超级爆款的商品参与了限时秒杀活动。

这个商品详情页的缓存 key 是 product:detail:888888,当时热度极高,无数人同时刷新。

问题来了:这个缓存 key 恰好在晚上十点零一分过期了(之前运维手动设了 24 小时 TTL)。

结果呢?

10000 个并发请求同时发现缓存没了,全部去查数据库。同一个 SQL,同一个商品 ID,一瞬间执行了 10000 次。

MySQL:当我是铁打的?

击穿的核心本质:多个请求同时发现缓存失效,同时去重建缓存,同时打爆数据库。 这不是高并发,这是设计缺陷。


穿透的解法,你可能只记得一半

先说穿透,因为这个大家多少都了解一些。

方案一:布隆过滤器

将所有存在的数据 ID 存入布隆过滤器。查询前来过滤器判断这个 ID 存不存在,不存在直接返回 null,根本不查缓存和数据库。

// 布隆过滤器判断
func getProduct(id string) (*Product, error) {
    if !bloomFilter.Exists(id) {
        return nil, errors.New("product not found")
    }
    // 继续查缓存...
}

优点:查询效率高,空间省。缺点:存在误判率(判断"不存在"是精确的,判断"存在"有少量假阳性)。

方案二:缓存空值

对于查询结果为空的数据,也在 Redis 里设一个空值(或者带过期时间的空值)。

// 伪代码
product := db.Query("SELECT * FROM products WHERE id = ?", id)
if product == nil {
    redis.Set("product:detail:"+id, "", 5*time.Minute) // 空值缓存5分钟
    return nil
}
redis.Set("product:detail:"+id, product, 1*time.Hour)

注意这个 TTL 不能设太长,否则数据真正更新了用户看到的还是旧的。

方案三:参数校验

这是最容易被忽视的一层。攻击者用随机 ID 大量请求,如果你的 ID 格式是可预测的(比如自增 ID),直接在前置网关层把明显异常的请求过滤掉。


雪崩的解法,也不只是加随机 TTL

网上铺天盖地的文章说"给缓存过期时间加随机值"就能防止雪崩。

行,这确实有用。但这只是第一层。

第一层:过期时间加随机值

// 别这样设
redis.Set("key", value, 24*time.Hour)

// 这样做
redis.Set("key", value, 24*time.Hour + time.Duration(rand.Intn(3600))*time.Second)

这是基础操作,不多说。

第二层:Redis 高可用架构

如果你是单节点 Redis,那雪崩来了不是缓存问题,是你架构问题。哨兵模式、集群模式了解一下。

第三层:本地缓存 + Redis 双层缓存

热点数据同时存在于两层:本地缓存扛住大部分流量,Redis 扛住跨实例共享,数据库只需要处理极少数漏网之鱼。

// 本地缓存(Caffeine/Guava LoadingCache) + Redis
func getProduct(id string) (*Product, error) {
    // L1: 本地缓存
    p := localCache.Get(id)
    if p != nil {
        return p, nil
    }
    // L2: Redis
    p = redis.Get("product:" + id)
    if p != nil {
        localCache.Set(id, p)
        return p, nil
    }
    // L3: DB
    p = db.Query(id)
    redis.Set("product:"+id, p, 1*time.Hour)
    localCache.Set(id, p)
    return p, nil
}

注意:本地缓存和 Redis 的一致性问题要处理好,本地缓存的过期时间要比 Redis 短。


击穿的解法:这个才是重点

终于到正题了。击穿的解法主要有三种:

方案一:互斥锁(分布式锁)

核心思路:缓存失效的瞬间,只让一个请求去查数据库重建缓存,其他请求等待。

func getProduct(id string) (*Product, error) {
    // 先查缓存
    p := redis.Get("product:" + id)
    if p != nil {
        return p, nil
    }

    // 缓存没了,加锁
    lockKey := "lock:product:" + id
    ok := redis.SetNX(lockKey, "1", 10*time.Second)
    if ok {
        defer redis.Del(lockKey)
        // 只有拿到锁的请求才去查DB
        p = db.Query(id)
        redis.Set("product:"+id, p, 1*time.Hour)
        return p, nil
    }

    // 没拿到锁的,等一下再试缓存
    time.Sleep(50 * time.Millisecond)
    return getProduct(id) // 递归重试,有递归深度风险,建议用循环
}

优点:完全避免击穿。缺点:有锁开销,极端情况下第一个请求获取锁后其他请求要等待。

方案二:逻辑过期(永不过期 + 后台异步更新)

这个方案的核心思路是:不给缓存设真正的过期时间,而是给缓存数据里加一个"逻辑过期时间"字段。读取时判断逻辑过期,如果已过期就异步重建缓存。

type ProductCache struct {
    Data    *Product
    ExpiredAt time.Time // 逻辑过期时间,不是Redis key的TTL
}

func getProduct(id string) (*Product, error) {
    cache := redis.Get("product:" + id)
    if cache == nil {
        return nil, errors.New("cache miss")
    }

    // 未过期,直接返回
    if time.Now().Before(cache.ExpiredAt) {
        return cache.Data, nil
    }

    // 已逻辑过期,尝试更新(用互斥锁控制只有一个在更新)
    lockKey := "lock:product:" + id
    ok := redis.SetNX(lockKey, "1", 10*time.Second)
    if ok {
        go func() {
            // 异步更新缓存,不阻塞请求
            p := db.Query(id)
            redis.Set("product:"+id, &ProductCache{Data: p, ExpiredAt: time.Now().Add(1 * time.Hour)}, 0) // key不过期
            redis.Del(lockKey)
        }()
        // 返回旧数据(可能已经有些旧,但比等缓存重建快)
        return cache.Data, nil
    }

    // 没拿到锁,也返回旧数据
    return cache.Data, nil
}

这个方案的好处是:请求永远不会被缓存重建阻塞,性能最优。代价是可能返回一段时间的旧数据(在这个例子里最多 1 小时)。

适用于:对数据一致性要求不是极端严格,但要求服务永远可用的场景。

方案三:热点数据永不过期

最简单的方案:如果这个数据足够热点,直接不给它设过期时间,或者设置一个非常非常长的 TTL(比如 7 天),然后在数据更新时主动删除缓存。

// 写操作时主动删除缓存,而不是更新缓存
func updateProduct(id string, p *Product) error {
    db.Update(p)
    redis.Del("product:" + id) // 删除缓存,下次查询重新加载
    // 主动通知其他实例也删除?需要Pub/Sub
    return nil
}

这个方案的问题是:如果写操作频繁,缓存会频繁失效,违背了缓存的初衷。所以只适合读多写少的热点数据。


有一个坑,很多人不知道

就算你用了互斥锁,你也可能遇到这个问题:

假设一个热点 key 有 10000 QPS,第一个请求拿到锁开始查数据库(假设需要 200ms),剩下的 9999 个请求在等待。

200ms 过去了,缓存重建成功,9999 个请求同时涌入缓存,全部命中。

听起来没问题?

但如果数据库那次查询本身就需要 200ms,而你的服务每秒能处理 5000 QPS,那这 9999 个等待中的请求会占用大量内存和连接资源。

解决方案:等待时设置最大重试次数和合理的等待间隔,超过就直接从数据库查(即使可能有并发问题)。不要让等待变成无限循环。

func getWithLock(id string, maxRetries int) (*Product, error) {
    for i := 0; i < maxRetries; i++ {
        p := redis.Get("product:" + id)
        if p != nil {
            return p, nil
        }
        lockKey := "lock:product:" + id
        if redis.SetNX(lockKey, "1", 5*time.Second) {
            p = db.Query(id)
            redis.Set("product:"+id, p, 1*time.Hour)
            redis.Del(lockKey)
            return p, nil
        }
        time.Sleep(time.Duration(50*(i+1)) * time.Millisecond) // 指数退避
    }
    // 重试次数用完了,直接查数据库(兜底)
    return db.Query(id)
}

监控才是真正的救命稻草

说了这么多解决方案,但最关键的一点是:你要能发现击穿正在发生。

这些指标必须监控:

  • 缓存命中率:命中率突然下降 20% 以上就要报警
  • 缓存 key 过期数量:每秒有多少个 key 过期
  • 数据库 QPS 异常:缓存层挡不住的请求突然增多
  • 锁竞争指标:每秒获取分布式锁的次数和失败率
# Prometheus 告警规则示例
- alert: CacheHitRateLow
  expr: redis_cache_hits_total / (redis_cache_hits_total + redis_cache_misses_total) < 0.8
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "缓存命中率低于 80%"

- alert: DBQPSAnomaly
  expr: rate(mysql_queries_total[5m]) > 1000
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "数据库 QPS 异常,可能正在发生缓存击穿"

没有监控的缓存优化都是在裸奔。


总结一下

三个缓存问题的解法:

问题 核心解法 关键词
缓存穿透 布隆过滤器 + 空值缓存 + 参数校验 拦截不存在查询
缓存雪崩 TTL随机 + 高可用 + 双层缓存 防止集体失效
缓存击穿 互斥锁 / 逻辑过期 / 永不过期 热点key重建风暴

最后说一句:这三个问题,在面试里是高频题,在生产里是高频坑。但很多人只会在面试里背答案,一到真正出事就两眼一抹黑。

下次当你看到缓存命中率骤降、数据库 CPU 暴涨的报警时,先看看是不是击穿——因为穿透和雪崩往往是大规模的,而击穿可能就只是一个 key 的问题,但足以让你的服务在半夜两点原地升天。

别问我怎么知道的。问就是血的教训。


有问题欢迎留言交流,小龙虾先去泡枸杞了 🦞

相关文章

还在为部署AI工具熬夜?找小龙虾,一键搞定!
还在为部署AI工具熬夜?找小龙虾,一键搞定!
RESTful API 设计翻车现场:那些年我们踩过的坑
RESTful API 设计翻车现场:那些年我们踩过的坑
AI Agent浪潮来袭:我试了市面上最火的五个,差距大得离谱
你的JWT实现,我敢说80%的后端都踩过这些坑

发布评论