缓存穿透、击穿、雪崩详解:问题如何产生,又该如何治理

缓存穿透、击穿、雪崩详解:问题如何产生,又该如何治理

前言

缓存是后端系统里最常见的性能优化手段。

接口慢了,加缓存;数据库扛不住了,加缓存;热点数据查询太频繁,加缓存。很多时候缓存确实能立刻降低数据库压力,让系统吞吐量明显提升。

但缓存不是银弹。缓存层一旦设计不好,反而会把问题放大。最典型的三类问题就是:

  • 缓存穿透
  • 缓存击穿
  • 缓存雪崩

这三个词经常一起出现,也经常被混淆。它们都表现为“请求打到数据库”,但产生原因、影响范围和解决方案并不一样。

本文按下面思路展开:

  1. 先讲缓存的基本访问模型。
  2. 分别解释穿透、击穿、雪崩是怎么产生的。
  3. 给出常见治理方案和适用边界。
  4. 最后用一张对比表和实践清单收尾。

一、先看最常见的缓存访问模型

大多数业务系统使用缓存时,都会采用 Cache Aside Pattern,也叫旁路缓存模式。

典型流程如下:

请求进来
   ↓
先查 Redis
   ↓
缓存命中 -> 直接返回
   ↓
缓存未命中
   ↓
查询数据库
   ↓
数据库有数据 -> 写入 Redis -> 返回
数据库无数据 -> 返回空

伪代码:

public Product getProduct(Long productId) {
    String cacheKey = "product:" + productId;

    Product product = redis.get(cacheKey);
    if (product != null) {
        return product;
    }

    product = productMapper.selectById(productId);
    if (product != null) {
        redis.set(cacheKey, product, 30, TimeUnit.MINUTES);
    }

    return product;
}

这段逻辑看起来很自然,但它隐藏了三个问题:

  1. 如果请求的 key 在数据库里根本不存在,会怎样?
  2. 如果某个超级热点 key 过期了,会怎样?
  3. 如果大量 key 同一时间过期,或者 Redis 整体不可用,会怎样?

这三个问题分别对应缓存穿透、缓存击穿、缓存雪崩。

二、缓存穿透:查一个根本不存在的数据

2.1 什么是缓存穿透

缓存穿透指的是:

请求查询的数据既不在缓存中,也不在数据库中,导致每次请求都会绕过缓存,直接打到数据库。

例如:

/product/detail?id=-1
/product/detail?id=999999999999

如果这些 ID 在数据库中不存在,第一次请求查缓存未命中,查数据库也没有数据。由于没有数据可写回缓存,下一次同样的请求仍然会再次查数据库。

如果有人恶意构造大量不存在的 ID,请求会变成:

大量非法请求 -> Redis 查不到 -> 数据库查不到 -> 数据库压力升高

这就是缓存穿透。

2.2 穿透是怎么产生的

常见原因有三类。

第一,业务正常访问了不存在的数据。

例如用户打开一个已经删除的商品、文章、订单。

第二,参数校验不足。

例如 ID 小于 0、字符串格式不合法、分页参数异常,这类请求根本不应该进入缓存和数据库查询流程。

第三,恶意攻击。

攻击者不断构造不存在的 key,让请求持续穿透缓存层,直接打数据库。

2.3 穿透的核心特征

缓存穿透的关键点是:

缓存没有,数据库也没有

它不是因为缓存失效,而是因为请求目标本身不存在。

三、缓存穿透怎么解决

3.1 参数校验:第一道防线

最便宜、最直接的方案是参数校验。

例如:

public Product getProduct(Long productId) {
    if (productId == null || productId <= 0) {
        throw new IllegalArgumentException("非法商品 ID");
    }

    // 后续再查缓存和数据库
}

如果业务 ID 有明确规则,比如雪花 ID、UUID、固定长度编码,都应该先校验格式。

原则是:

明显非法的请求,不要进入缓存层,更不要进入数据库。

3.2 缓存空值:防止重复查库

如果请求参数合法,但数据库确实没有数据,可以缓存一个空值。

示例:

public Product getProduct(Long productId) {
    String cacheKey = "product:" + productId;

    String json = redis.get(cacheKey);
    if (json != null) {
        if ("NULL".equals(json)) {
            return null;
        }
        return JsonUtils.fromJson(json, Product.class);
    }

    Product product = productMapper.selectById(productId);
    if (product == null) {
        redis.set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
        return null;
    }

    redis.set(cacheKey, JsonUtils.toJson(product), 30, TimeUnit.MINUTES);
    return product;
}

缓存空值后,同样的不存在 key 再次访问时,会直接从 Redis 返回空,不再打数据库。

但空值缓存有两个注意点。

第一,过期时间要短。

如果空值缓存时间太长,可能影响后续新数据创建。例如商品 ID 100 原来不存在,被缓存为空;后来商品 100 创建成功,但空值缓存还没过期,就会读到错误结果。

第二,要控制空值数量。

如果攻击者构造大量不同 key,Redis 里可能堆积大量空值缓存。所以空值缓存不能替代参数校验和限流。

3.3 布隆过滤器:提前判断数据是否可能存在

布隆过滤器适合大规模判断“某个 key 是否可能存在”。

它的特点是:

  • 判断不存在:一定不存在
  • 判断存在:可能存在

访问流程:

请求 key
   ↓
先查布隆过滤器
   ↓
判断不存在 -> 直接返回
   ↓
判断可能存在 -> 查 Redis
   ↓
Redis 未命中 -> 查数据库

适合场景:

  • 商品详情
  • 文章详情
  • 用户主页
  • 活动页
  • ID 空间很大,非法 ID 很多

伪代码:

public Product getProduct(Long productId) {
    if (!bloomFilter.mightContain(productId)) {
        return null;
    }

    // 再走 Redis + DB 查询
}

布隆过滤器的问题是维护成本更高:

  • 新增数据时要写入布隆过滤器。
  • 删除数据后,普通布隆过滤器不方便删除元素。
  • 存在误判,不能完全替代数据库。

因此它更适合高并发、大数据量、穿透风险明显的核心接口。

3.4 限流和风控

如果穿透来自恶意攻击,仅靠缓存策略不够,还要做限流。

常见维度:

  • IP 限流
  • 用户限流
  • 接口限流
  • 参数模式识别
  • 网关层拦截

例如同一个 IP 每秒请求几百个不存在商品 ID,这已经不是正常业务访问,应在网关或风控层拦截。

四、缓存击穿:热点 key 突然失效

4.1 什么是缓存击穿

缓存击穿指的是:

某个热点 key 在缓存中过期的一瞬间,大量并发请求同时打到数据库。

例如秒杀商品详情:

product:10086 是超级热点 key
平时所有请求都命中 Redis
某一刻 key 正好过期
大量请求同时发现缓存未命中
于是一起查询数据库

数据库原本只需要承接少量缓存未命中请求,突然被一个热点 key 的大量并发压住,可能瞬间打满连接池。

4.2 击穿是怎么产生的

击穿通常同时满足两个条件:

  1. 这个 key 非常热。
  2. 这个 key 在某一刻失效。

比如:

  • 热门商品
  • 热点新闻
  • 首页配置
  • 明星直播间
  • 秒杀活动信息
  • 大促活动库存展示

只要热点 key 有过期时间,就存在过期瞬间被高并发击穿的风险。

4.3 击穿和穿透的区别

击穿的 key 在数据库中是存在的。

缓存穿透:缓存没有,数据库也没有
缓存击穿:缓存没有,数据库有,但热点 key 过期了

击穿的核心不是“数据不存在”,而是“热点数据失效瞬间并发过高”。

五、缓存击穿怎么解决

5.1 互斥锁:只允许一个线程回源

最常见方案是加互斥锁。

流程:

查缓存未命中
   ↓
尝试获取分布式锁
   ↓
获取成功 -> 查数据库 -> 写缓存 -> 释放锁
获取失败 -> 等待一小段时间 -> 重试查缓存

伪代码:

public Product getProduct(Long productId) {
    String cacheKey = "product:" + productId;
    String lockKey = "lock:product:" + productId;

    Product product = getFromCache(cacheKey);
    if (product != null) {
        return product;
    }

    boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (!locked) {
        sleep(50);
        return getProduct(productId);
    }

    try {
        // 双重检查,避免拿到锁后重复查库
        product = getFromCache(cacheKey);
        if (product != null) {
            return product;
        }

        product = productMapper.selectById(productId);
        if (product != null) {
            setCache(cacheKey, product, 30, TimeUnit.MINUTES);
        }
        return product;
    } finally {
        redis.delete(lockKey);
    }
}

注意点:

  • 锁必须有过期时间,避免死锁。
  • 拿到锁后要再次查缓存,即双重检查。
  • 等待线程不要疯狂自旋,要短暂 sleep 或快速失败。
  • 高并发场景要注意锁过期时间是否覆盖查库和写缓存耗时。

互斥锁的优点是强一致性更好,能明显降低回源并发。

缺点是请求会等待,极端情况下可能增加响应时间。

5.2 逻辑过期:热点数据不过期,异步刷新

另一种常见方案是逻辑过期。

做法是 Redis key 本身不设置短 TTL,而是在缓存 value 里放一个逻辑过期时间。

示例结构:

{
  "expireTime": "2026-06-23T10:00:00",
  "data": {
    "id": 10086,
    "name": "热门商品"
  }
}

读取流程:

查 Redis
   ↓
数据不存在 -> 查库或返回空
数据存在且未逻辑过期 -> 直接返回
数据存在但已逻辑过期 -> 先返回旧数据,再异步刷新缓存

伪代码:

public Product getHotProduct(Long productId) {
    CacheData<Product> cacheData = getCacheData(productId);
    if (cacheData == null) {
        return null;
    }

    if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
        return cacheData.getData();
    }

    boolean locked = tryLock("lock:product:" + productId);
    if (locked) {
        executor.submit(() -> {
            try {
                Product fresh = productMapper.selectById(productId);
                setCacheWithLogicalExpire(productId, fresh, 30, TimeUnit.MINUTES);
            } finally {
                unlock("lock:product:" + productId);
            }
        });
    }

    return cacheData.getData();
}

逻辑过期适合热点数据,因为它优先保证系统可用性:

宁愿短时间返回旧数据,也不要让数据库被打垮。

缺点是可能读到短暂旧数据,不适合强一致性场景。

5.3 热点 key 永不过期

对极少数核心热点数据,可以不设置过期时间,而是通过后台任务或消息机制主动刷新。

例如:

  • 首页配置
  • 秒杀活动信息
  • 热门榜单
  • 系统字典

更新方式可以是:

  • 管理后台修改后主动删除或刷新缓存
  • 定时任务刷新
  • 数据变更消息驱动刷新

这种方案简单有效,但要避免缓存永远不更新,必须有明确的数据刷新机制。

六、缓存雪崩:大量缓存同时失效或缓存层整体故障

6.1 什么是缓存雪崩

缓存雪崩指的是:

大量缓存 key 在同一时间失效,或者缓存服务整体不可用,导致大量请求同时打到数据库,引发系统级故障。

例如:

大量 key 设置了相同 TTL:30 分钟
系统 10:00 批量预热缓存
10:30 大量 key 同时过期
请求同时回源数据库
数据库压力暴增
接口超时
线程堆积
连接池耗尽
系统雪崩

还有一种更严重的情况:

Redis 集群故障
所有缓存请求失败
全部流量直接压到数据库

6.2 雪崩是怎么产生的

常见原因:

  1. 大量 key 设置了相同过期时间。
  2. 缓存预热时批量写入,TTL 完全一致。
  3. Redis 集群故障或网络抖动。
  4. 缓存容量不足,大量 key 被淘汰。
  5. 重启发布后缓存为空,大量请求同时回源。

6.3 雪崩和击穿的区别

击穿通常是单个热点 key。

雪崩是大面积缓存失效或缓存服务整体不可用。

缓存击穿:一个热点 key 失效,局部高压
缓存雪崩:大量 key 失效,系统级高压

七、缓存雪崩怎么解决

7.1 TTL 加随机值,避免同时过期

不要给大量 key 设置完全相同的过期时间。

错误示例:

redis.set(key, value, 30, TimeUnit.MINUTES);

更好的做法:

int baseTtl = 30 * 60;
int randomTtl = ThreadLocalRandom.current().nextInt(0, 300);
redis.set(key, value, baseTtl + randomTtl, TimeUnit.SECONDS);

这样 key 的过期时间会分散在 30 到 35 分钟之间,避免同一时刻大面积回源。

7.2 缓存预热

对于核心数据,不要等用户请求来了才加载缓存。

可以在系统启动、活动开始前或发布完成后主动预热:

读取热点商品列表
   ↓
查询数据库
   ↓
写入 Redis
   ↓
设置随机 TTL

适合预热的数据:

  • 首页配置
  • 热门商品
  • 活动页面
  • 类目树
  • 字典配置
  • 排行榜

缓存预热不能解决所有问题,但能避免冷启动时所有请求都打数据库。

7.3 多级缓存

多级缓存可以降低 Redis 故障时对数据库的冲击。

常见结构:

本地缓存 Caffeine
        ↓
分布式缓存 Redis
        ↓
数据库 MySQL

读取流程:

先查本地缓存
本地没有查 Redis
Redis 没有查 DB
DB 有数据后回填 Redis 和本地缓存

本地缓存适合:

  • 字典数据
  • 配置数据
  • 热点只读数据
  • 变更频率低的数据

注意,本地缓存会带来一致性问题。如果数据频繁变化,需要考虑消息通知、版本号、短 TTL 或主动失效机制。

7.4 限流、降级和熔断

缓存雪崩是系统级风险,不能只靠缓存策略解决。

必须配合限流和降级:

  • 限制进入核心接口的请求数。
  • Redis 故障时直接返回兜底数据。
  • 数据库压力过高时拒绝非核心请求。
  • 对低优先级接口降级。
  • 对核心链路保留资源。

示例:

Redis 超时
   ↓
读取本地兜底缓存
   ↓
兜底缓存也没有
   ↓
返回默认结果或友好提示

对于电商首页,宁愿短时间展示旧配置,也不要所有请求都去打数据库。

7.5 提高 Redis 可用性

如果 Redis 本身不可用,业务层再多技巧也只能缓解。

生产环境应考虑:

  • Redis 主从复制
  • Redis Sentinel
  • Redis Cluster
  • 合理的内存淘汰策略
  • 慢查询监控
  • 连接池隔离
  • 超时时间配置
  • 命令大 key 治理

关键点是:缓存层也要按生产系统治理,而不是当成一个随便部署的辅助组件。

八、三类问题对比

问题 核心原因 数据库是否有数据 影响范围 典型解决方案
缓存穿透 查询不存在的数据 没有 单 key 或大量非法 key 参数校验、缓存空值、布隆过滤器、限流
缓存击穿 热点 key 过期 单个热点 key 互斥锁、逻辑过期、热点 key 永不过期
缓存雪崩 大量 key 同时过期或缓存故障 通常有 大面积系统风险 TTL 随机化、预热、多级缓存、限流降级、高可用

一句话记忆:

穿透:查不存在的数据。
击穿:一个热点 key 失效。
雪崩:大量 key 或整个缓存层失效。

九、真实项目中的组合治理方案

一个成熟系统通常不会只用一种方案,而是组合治理。

9.1 普通详情页

适合方案:

  • 参数校验
  • 缓存空值
  • TTL 随机值
  • 必要时加布隆过滤器

流程:

校验 ID
   ↓
布隆过滤器判断是否可能存在
   ↓
查 Redis
   ↓
Redis 未命中查 DB
   ↓
DB 空则缓存短 TTL 空值
   ↓
DB 有则缓存正常数据 + 随机 TTL

9.2 热点商品详情

适合方案:

  • 热点 key 预热
  • 逻辑过期
  • 异步刷新
  • 本地缓存
  • 限流保护

流程:

查本地缓存
   ↓
查 Redis
   ↓
逻辑过期则异步刷新
   ↓
先返回旧数据保证可用性

9.3 秒杀活动

秒杀活动的特点是请求集中、热点明显、数据库极易被打垮。

适合方案:

  • 活动开始前缓存预热
  • 热点数据不设置普通短 TTL
  • 使用逻辑过期或主动刷新
  • 库存扣减尽量走 Redis 或消息队列削峰
  • 接口限流
  • 用户维度防刷
  • 降级兜底

秒杀场景下,不要让每个请求都实时查数据库库存。数据库应该承担最终落库和对账,不应该直接承接高频读写冲击。

十、代码实践注意点

1. 缓存 key 要规范

推荐格式:

业务名:对象名:ID

示例:

mall:product:10086
mall:user:9527
mall:category:tree

不要使用含义不清的 key:

data:1
cache:user
tmp:abc

key 规范有利于排查、统计和批量治理。

2. TTL 要按业务分层

不是所有数据都适合一个 TTL。

数据类型 建议策略
字典配置 长 TTL + 主动刷新
商品详情 中等 TTL + 随机值
用户信息 短 TTL 或按变更主动删除
热点数据 逻辑过期或主动刷新
空值缓存 短 TTL

3. 回源数据库要有限制

缓存未命中后查数据库,这一步必须谨慎。

可以加:

  • 分布式锁
  • 单飞机制
  • 限流
  • 超时控制
  • 降级策略

不要让所有缓存未命中请求无脑打数据库。

4. 删除缓存和更新数据库要注意一致性

常见做法是:

先更新数据库
再删除缓存

不要简单地先删缓存再更新数据库,否则并发场景可能读到旧数据并重新写入缓存。

如果一致性要求更高,可以结合:

  • 延迟双删
  • Binlog 订阅
  • 消息队列
  • 版本号
  • 分布式锁

缓存问题不是只有穿透、击穿、雪崩,一致性同样是核心问题。

5. 监控必须到位

缓存治理不能只靠代码,还要靠监控。

建议关注:

  • Redis QPS
  • Redis 命中率
  • Redis 延迟
  • Redis 连接数
  • 慢查询
  • 大 key
  • 热 key
  • 缓存空值数量
  • 数据库 QPS
  • 数据库连接池使用率
  • 接口超时率

如果没有监控,缓存问题通常会在数据库被打满之后才暴露。

十一、面试回答模板

如果面试中被问到缓存穿透、击穿、雪崩,可以这样回答:

缓存穿透是查询不存在的数据,缓存和数据库都没有,导致请求每次都打到数据库。
解决方案包括参数校验、缓存空值、布隆过滤器和限流。

缓存击穿是某个热点 key 过期,大量并发同时回源数据库。
解决方案包括互斥锁、逻辑过期、热点 key 永不过期和异步刷新。

缓存雪崩是大量 key 同时失效,或者 Redis 整体不可用,导致大量请求同时打到数据库。
解决方案包括 TTL 加随机值、缓存预热、多级缓存、限流降级和 Redis 高可用。

如果想回答得更深入,可以补一句:

三者的本质区别在于影响范围和数据是否存在。穿透是数据不存在,击穿是单个热点 key 失效,雪崩是大量 key 或缓存层整体失效。

十二、总结

缓存穿透、击穿、雪崩虽然都可能导致数据库压力升高,但治理思路完全不同。

缓存穿透关注的是:

不要让不存在的数据反复查数据库

核心方案是参数校验、缓存空值、布隆过滤器和限流。

缓存击穿关注的是:

不要让热点 key 过期瞬间被并发打爆

核心方案是互斥锁、逻辑过期、热点 key 永不过期和异步刷新。

缓存雪崩关注的是:

不要让大量 key 或整个缓存层同时失效

核心方案是 TTL 随机化、缓存预热、多级缓存、限流降级和 Redis 高可用。

实际项目里,缓存设计要从一开始就考虑异常场景:

  • key 是否可能不存在?
  • key 是否是热点?
  • TTL 是否会集中失效?
  • Redis 故障时系统能否降级?
  • 数据库能承受多少回源请求?
  • 缓存命中率和热 key 是否有监控?

缓存的价值是保护数据库、提升性能;缓存设计不当时,它也可能成为故障放大器。

真正可靠的缓存系统,不是“加一层 Redis”这么简单,而是要围绕数据特征、访问热度、失效策略、降级能力和监控体系做整体设计。

posted @ 2026-06-23 14:50  松鼠航  阅读(20)  评论(0)    收藏  举报