缓存三大坑:穿透 + 雪崩 + 击穿实战复盘——每个都能让数据库瞬间崩掉

引言

线上告警炸了:

[P1] MySQL CPU 100%
[P1] 应用接口 P99 5s
[P1] 数据库连接池打满

打开 Grafana一看:

Redis 命中率从 95% 掉到 30%
MySQL QPS 从 2 千飙到 8 万
应用线程池全满

典型的缓存失效问题。但具体是哪种?

缓存失效有三种典型形态——穿透、雪崩、击穿,每个都能让数据库瞬间崩溃,但原因和解决方案完全不同。

这篇文章把三个真实事故合并在一起复盘,每个坑附事故场景、排查过程、代码方案。看完这篇,下次再遇到你能 5 分钟定位是哪一种。


一、先搞清三个坑的区别

很多人把这三个概念搞混。一张表说清:

概念现象根因典型场景
穿透查的数据在缓存和 DB 都没有数据不存在恶意攻击、爬虫乱查 ID
雪崩大量缓存同时失效过期时间相同批量预热后统一过期
击穿单个热点 Key 失效瞬间热点 + 过期秒杀商品、首页推荐

记忆口诀:

穿透:查没有的 → DB 被"打穿"
雪崩:集体失效 → DB 被"压塌"
击穿:热点失效 → DB 被"击穿"

二、事故一:缓存穿透——压垮 DB 的隐形杀手

2.1 事故复盘

凌晨 3 点告警:

[告警] MySQL CPU 95%
[告警] 商品查询接口 P99 8s
[告警] 数据库连接池满

排查 Grafana:

Redis QPS: 20 万/s(正常 5 万)
Redis 命中率: 12%(正常 95%)
MySQL QPS: 18 万/s(正常 5 千)

查询日志:
  GET product:99999999 → nil
  GET product:99999998 → nil
  GET product:99999997 → nil
  ...(全是查不存在的 ID)

2.2 事故场景

某营销活动被黑客盯上,对方写脚本批量请求不存在的商品 ID:

# 攻击脚本
for id in {99999999..99999999-1000000}
do
    curl "https://api.example.com/products/$id"
done

这些 ID 在数据库根本不存在,每次请求都打到 DB:

应用 → Redis(miss)→ MySQL(select,无结果)
应用 → Redis(miss)→ MySQL(select,无结果)
... 18 万次/秒

2.3 排查过程

第 1 步:看 Redis 命中率

Grafana → Redis 面板 → 命中率掉到 12%
→ 说明大量 Key 没在缓存

第 2 步:看慢查询

-- MySQL 慢日志
SELECT * FROM products WHERE id = 99999999;
SELECT * FROM products WHERE id = 99999998;
...

这些 SQL 都是"查不到数据",但每次都消耗 DB 资源。

第 3 步:看应用日志

2026-08-08 03:00:15 [http-nio-8080-exec-1] GET /products/99999999
2026-08-08 03:00:15 [http-nio-8080-exec-2] GET /products/99999998
...

同一个 UA、同一秒大量请求不存在的 ID → 攻击特征。

2.4 解决方案

方案 1:空值缓存(最简单)

查不到的数据也缓存起来,下次直接返回 null:

public Product getProduct(Long id) {
    String key = "product:" + id;
    Object cached = redis.get(key);

    // 缓存命中
    if (cached != null) {
        if (cached instanceof NullObject) {
            return null;   // 空值缓存命中
        }
        return (Product) cached;
    }

    // 缓存未命中,查 DB
    Product product = productMapper.selectById(id);

    if (product == null) {
        // 关键:空值也缓存,但过期时间短(5 分钟)
        redis.set(key, NULL_OBJECT, 5, TimeUnit.MINUTES);
        return null;
    }

    // 正常数据缓存 1 小时
    redis.set(key, product, 1, TimeUnit.HOURS);
    return product;
}

优点:实现简单。

缺点

  • 大量不存在的 ID 会占内存(几百万空值)
  • 攻击者改个 ID 就绕过

方案 2:布隆过滤器(更彻底)

预先把"所有存在的 ID"放到布隆过滤器:

启动时:
  遍历所有商品 ID → 加入布隆过滤器

查询时:
  ID 在布隆过滤器? → 是 → 查 Redis/DB
                   → 否 → 直接返回 null(不存在)

布隆过滤器特性:

  • 说"存在" → 可能存在(误判率可控)
  • 说"不存在" → 一定不存在
  • 内存占用极小(1 亿 ID 约 100MB)
@Service
public class ProductService {

    @Autowired
    private BloomFilter<Long> productIdBloomFilter;

    public Product getProduct(Long id) {
        // 1. 先过布隆过滤器
        if (!productIdBloomFilter.mightContain(id)) {
            return null;  // 一定不存在
        }

        // 2. 查 Redis
        String key = "product:" + id;
        Object cached = redis.get(key);
        if (cached != null) {
            return (Product) cached;
        }

        // 3. 查 DB
        Product product = productMapper.selectById(id);
        if (product != null) {
            redis.set(key, product, 1, TimeUnit.HOURS);
        }
        return product;
    }
}

布隆过滤器初始化:

@Configuration
public class BloomFilterConfig {

    @Bean
    public BloomFilter<Long> productIdBloomFilter(ProductMapper mapper) {
        // 预期 1 亿 ID,误判率 0.01%
        BloomFilter<Long> filter = BloomFilter.create(
            Funnels.longFunnel(), 100_000_000L, 0.0001);

        // 启动时加载所有 ID
        try (Cursor<Long> cursor = mapper.selectAllIds()) {
            cursor.forEachRemaining(filter::put);
        }
        return filter;
    }
}

方案 3:两者结合(生产推荐)

public Product getProduct(Long id) {
    // 1. 布隆过滤器:拦截大部分不存在的 ID
    if (!productIdBloomFilter.mightContain(id)) {
        return null;
    }

    // 2. Redis
    String key = "product:" + id;
    Object cached = redis.get(key);
    if (cached != null) {
        return cached instanceof NullObject ? null : (Product) cached;
    }

    // 3. DB(布隆过滤器有误判,可能仍不存在)
    Product product = productMapper.selectById(id);
    if (product == null) {
        redis.set(key, NULL_OBJECT, 5, TimeUnit.MINUTES);
        return null;
    }
    redis.set(key, product, 1, TimeUnit.HOURS);
    return product;
}

2.5 复盘结论

措施效果
空值缓存解决已知不存在的 ID 反复查
布隆过滤器拦截 99.99% 的非法 ID
WAF 限流治标:限制单 IP 请求频率
接口鉴权治本:不让未授权的请求进来

三、事故二:缓存雪崩——集体失效的群体事件

3.1 事故复盘

某天晚上 20:00,电商大促开始前 1 小时:

[告警] MySQL CPU 100%
[告警] Redis 命中率 0%
[告警] 商品列表接口超时

Grafana 截图:

20:00 整点:
  Redis 命中率从 95% → 0%
  MySQL QPS 从 2 千 → 30 万
  MySQL CPU 飙到 100%
  应用大量超时

3.2 事故场景

大促前预热数据,把几百万商品全部加载到 Redis:

// ❌ 预热代码
@Scheduled(cron = "0 0 19 * * *")  // 19:00 预热
public void warmupCache() {
    List<Product> products = productMapper.selectAll();
    for (Product p : products) {
        // 全部缓存 1 小时(错误:统一过期时间)
        redis.set("product:" + p.getId(), p, 1, TimeUnit.HOURS);
    }
}

19:00 预热 → 20:00 整点全部同时过期 → 几百万请求瞬间打到 DB。

3.3 排查过程

第 1 步:看 Redis Key 过期时间分布

# 随机抽样 1000 个 product:* Key,看 TTL
redis-cli --bigkeys
# 发现大量 Key 的 TTL 都是 3598-3600 秒
# 即将同时过期

第 2 步:看 QPS 时间点

19:59 → Redis 命中率 95%,MySQL QPS 2 千
20:00 → Redis 命中率 0%,MySQL QPS 30 万(瞬间 150 倍)
20:01 → 服务雪崩,应用超时

时间点完全吻合"统一过期"特征。

3.4 解决方案

方案 1:过期时间随机化(最简单)

// ❌ 错误:统一过期时间
redis.set(key, value, 1, TimeUnit.HOURS);

// ✅ 正确:过期时间 + 随机扰动
int baseExpire = 3600;  // 1 小时
int random = new Random().nextInt(600);  // 0-10 分钟随机
redis.set(key, value, baseExpire + random, TimeUnit.SECONDS);

让原本 20:00 同时过期的 Key,分散到 20:00-20:10 之间陆续过期。

生产建议:基础时间 ± 10-30% 的随机扰动。

方案 2:永不过期 + 异步更新(彻底解决)

@Service
public class ProductService {

    @Autowired
    private ProductCacheRefresher refresher;

    public Product getProduct(Long id) {
        String key = "product:" + id;
        Object cached = redis.get(key);

        if (cached != null) {
            // 逻辑过期:检查是否需要刷新
            ProductCacheEntry entry = (ProductCacheEntry) cached;
            if (entry.isExpired()) {
                // 异步刷新,当前返回旧数据
                refresher.refreshAsync(id);
            }
            return entry.getProduct();
        }

        // 缓存真的没有,查 DB
        Product product = productMapper.selectById(id);
        if (product != null) {
            redis.set(key, new ProductCacheEntry(product, LocalDateTime.now()), 7, TimeUnit.DAYS);
        }
        return product;
    }
}

ProductCacheEntry

@Data
public class ProductCacheEntry implements Serializable {
    private Product product;
    private LocalDateTime refreshAt;  // 逻辑过期时间

    public boolean isExpired() {
        return LocalDateTime.now().isAfter(refreshAt);
    }
}

异步刷新:

@Service
public class ProductCacheRefresher {

    @Async("cacheRefresherExecutor")
    public void refreshAsync(Long id) {
        Product product = productMapper.selectById(id);
        if (product != null) {
            redis.set("product:" + id,
                new ProductCacheEntry(product, LocalDateTime.now().plusHours(1)),
                7, TimeUnit.DAYS);
        }
    }
}

优点

  • 缓存永不过期(物理上 7 天兜底)
  • 逻辑过期触发异步刷新
  • 请求永远不打到 DB

方案 3:多级缓存(终极方案)

请求 → 本地缓存(Caffeine)→ Redis → DB
       100μs           1ms      10ms
@Service
public class ProductService {

    // L1:本地缓存
    private Cache<Long, Product> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();

    public Product getProduct(Long id) {
        // L1 本地缓存
        Product cached = localCache.getIfPresent(id);
        if (cached != null) return cached;

        // L2 Redis
        String key = "product:" + id;
        cached = redis.get(key);
        if (cached != null) {
            localCache.put(id, cached);
            return cached;
        }

        // L3 DB
        Product product = productMapper.selectById(id);
        if (product != null) {
            redis.set(key, product, 1, TimeUnit.HOURS);
            localCache.put(id, product);
        }
        return product;
    }
}

多级缓存让 Redis 失效时,本地缓存兜底,DB 几乎没压力。

3.5 复盘结论

措施效果
过期时间随机化分散失效,简单有效
永不过期 + 异步刷新彻底杜绝雪崩
多级缓存本地缓存兜底
限流降级雪崩时保护 DB

四、事故三:缓存击穿——单点热点的瞬间爆破

4.1 事故复盘

某天 10:00:00 整点开抢茅台:

[告警] MySQL CPU 100%
[告警] product:12345 (茅台) 查询 QPS 10 万
[告警] 应用连接池满

Grafana:

09:59:59 → Redis 命中率 100%(茅台在缓存)
10:00:00 → 茅台缓存过期
10:00:01 → 10 万 QPS 全部打到 DB
10:00:02 → DB CPU 飙到 100%
10:00:03 → 应用连接池耗尽,开始报错

4.2 事故场景

秒杀商品缓存 1 小时,正好在 10:00 过期,1 秒内涌入 10 万请求:

09:00 → 加载茅台到缓存,过期 1 小时
10:00 → 缓存失效
10:00:00.001 → 10 万请求同时进来
             → Redis miss
             → 10 万请求同时查 DB
             → DB 瞬间炸了

4.3 排查过程

第 1 步:看告警时间点

10:00:01 [告警] MySQL CPU 100%

10:00 整点,秒杀开始时间。吻合。

第 2 步:看 Redis Key 的 TTL

# 09:59 查看
redis.ttl("product:12345")
→ 60(还有 60 秒过期)

# 10:00 查看
redis.ttl("product:12345")
→ -2(已过期)

第 3 步:看 MySQL 慢查询

SELECT * FROM products WHERE id = 12345;
SELECT * FROM products WHERE id = 12345;
SELECT * FROM products WHERE id = 12345;
... 10 万次同样的 SQL

单个 Key 失效 + 高 QPS = 击穿特征。

4.4 解决方案

方案 1:互斥锁(SETNX,最常用)

只有一个请求查 DB,其他等结果:

@Service
public class ProductService {

    public Product getProduct(Long id) {
        String key = "product:" + id;
        Object cached = redis.get(key);

        if (cached != null) {
            return (Product) cached;
        }

        // 缓存未命中,加互斥锁
        String lockKey = "lock:product:" + id;
        try {
            // SETNX,超时 10 秒
            boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

            if (locked) {
                // 双重检查(可能其他线程已经写好了缓存)
                cached = redis.get(key);
                if (cached != null) {
                    return (Product) cached;
                }

                // 查 DB + 写缓存
                Product product = productMapper.selectById(id);
                if (product != null) {
                    redis.set(key, product, 1, TimeUnit.HOURS);
                }
                return product;
            } else {
                // 没拿到锁,等待重试
                Thread.sleep(50);
                return getProduct(id);  // 递归重试
            }
        } finally {
            redis.delete(lockKey);
        }
    }
}

关键点

  • setIfAbsent(SETNX):原子性获取锁
  • 双重检查:避免重复查 DB
  • 短暂等待 + 重试:让其他线程拿到缓存后的结果
  • 锁超时:防止持锁线程异常导致死锁

方案 2:逻辑过期(不真正过期)

缓存永远不过期,但记录"逻辑过期时间":

public Product getProduct(Long id) {
    String key = "product:" + id;
    Object cached = redis.get(key);

    if (cached == null) {
        // 缓存真的没有,走互斥锁查 DB
        return getWithMutex(id);
    }

    ProductCacheEntry entry = (ProductCacheEntry) cached;

    // 逻辑过期 → 返回旧数据 + 异步刷新
    if (entry.isExpired()) {
        // 尝试拿锁,拿到才刷新
        if (redis.setIfAbsent("lock:refresh:" + id, "1", 10, TimeUnit.SECONDS)) {
            refresher.refreshAsync(id);  // 异步刷新
        }
        // 立即返回旧数据
        return entry.getProduct();
    }

    return entry.getProduct();
}

关键点

  • 物理上永不过期(或 7 天兜底)
  • 逻辑过期触发异步刷新
  • 请求永远拿到数据(旧数据 + 后台刷新)
  • 没有任何请求打到 DB

方案 3:永不过期(最暴力)

热点数据直接不过期

// 秒杀商品永不过期
redis.set(key, product);  // 不设 TTL

// 通过定时任务更新
@Scheduled(cron = "0 0 * * * *")  // 每小时刷新
public void refreshHotProducts() {
    List<Long> hotIds = getHotProductIds();
    for (Long id : hotIds) {
        Product product = productMapper.selectById(id);
        redis.set("product:" + id, product);
    }
}

适用:超热点数据(首页推荐、秒杀商品)。

4.5 复盘结论

措施效果
互斥锁单请求查 DB,其他等待
逻辑过期 + 异步刷新不阻塞请求
永不过期 + 定时刷新彻底解决,但代码复杂
多级缓存本地缓存兜底

五、三大坑对比汇总

5.1 特征对比

维度穿透雪崩击穿
失效范围不存在的数据大量 Key 同时失效单个热点 Key 失效
请求特征查不存在的 ID各种 Key同一热点 Key 高 QPS
触发原因攻击/爬虫过期时间相同热点过期
影响速度立即瞬间瞬间
持续时间持续几分钟几秒-几分钟

5.2 方案对比

主要方案兜底方案
穿透布隆过滤器 + 空值缓存WAF 限流
雪崩过期时间随机化 + 异步刷新限流降级
击穿互斥锁 + 逻辑过期多级缓存

5.3 排查决策树

DB 突然压力飙升,是哪个坑?

1. 看 Redis 命中率
   └─ 命中率高(95%)→ 单个 Key 问题 → 击穿

2. 命中率低
   ├─ 看 MySQL 查询是否都是"查不到"
   │   └─ 是 → 穿透
   └─ 查到的是各种数据
       └─ 看时间点
           ├─ 集中失效(同一时刻)→ 雪崩
           └─ 持续低命中 → 容量不足/预热不够

六、统一防御方案

实际项目中,三个坑要一起防:

@Service
public class CachedProductService {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private RedisTemplate<String, Object> redis;

    @Autowired
    private BloomFilter<Long> productBloomFilter;  // 防穿透

    @Autowired
    private CacheRefresher refresher;             // 防雪崩(异步刷新)

    // 本地缓存:防击穿兜底
    private Cache<Long, Product> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();

    public Product getProduct(Long id) {
        // ===== 1. 防穿透:布隆过滤器 =====
        if (!productBloomFilter.mightContain(id)) {
            return null;
        }

        // ===== 2. L1 本地缓存(防击穿)=====
        Product local = localCache.getIfPresent(id);
        if (local != null) return local;

        // ===== 3. L2 Redis =====
        String key = "product:" + id;
        Object cached = redis.opsForValue().get(key);

        if (cached != null) {
            if (cached instanceof NullObject) return null;
            ProductCacheEntry entry = (ProductCacheEntry) cached;

            // ===== 4. 防雪崩:逻辑过期 + 异步刷新 =====
            if (entry.isExpired()) {
                if (redis.opsForValue().setIfAbsent(
                        "lock:refresh:" + id, "1", Duration.ofSeconds(10))) {
                    refresher.refreshAsync(id);
                }
            }
            localCache.put(id, entry.getProduct());
            return entry.getProduct();
        }

        // ===== 5. 防击穿:互斥锁 =====
        return getWithMutex(id);
    }

    private Product getWithMutex(Long id) {
        String lockKey = "lock:product:" + id;
        String key = "product:" + id;

        try {
            if (Boolean.TRUE.equals(
                    redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)))) {
                // 拿到锁,双重检查
                Object cached = redis.opsForValue().get(key);
                if (cached instanceof ProductCacheEntry) {
                    return ((ProductCacheEntry) cached).getProduct();
                }

                // 查 DB
                Product product = productMapper.selectById(id);
                if (product == null) {
                    // 防穿透:空值短缓存
                    redis.opsForValue().set(key, NULL_OBJECT, Duration.ofMinutes(5));
                    return null;
                }

                // 防雪崩:过期时间随机化
                int ttl = 3600 + new Random().nextInt(600);
                redis.opsForValue().set(key,
                    new ProductCacheEntry(product, LocalDateTime.now().plusHours(1)),
                    Duration.ofSeconds(ttl));
                return product;
            } else {
                Thread.sleep(50);
                return getProduct(id);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } finally {
            redis.delete(lockKey);
        }
    }
}

七、监控指标

再好的方案也要监控。三个坑都要监控这些指标:

7.1 关键指标

指标含义告警阈值
Redis 命中率命中数 / 总查询< 80% 告警
DB QPS数据库查询数> 5 万/秒 告警
慢查询数超过 1s 的 SQL> 10/分钟 告警
接口 P99响应时间> 1s 告警
空值缓存数Redis 中 NULL_OBJECT 数监控异常增长

7.2 Grafana 面板

# Redis 命中率
redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)

# 缓存写入速率(判断预热是否正确)
rate(redis_commands_total{cmd="set"}[1m])

# DB QPS(核心)
rate(mysql_queries_total[1m])

7.3 告警规则

groups:
  - name: cache-alerts
    rules:
      # 命中率掉到 80% 以下
      - alert: LowCacheHitRate
        expr: |
          1 - (rate(redis_keyspace_hits_total[5m])
               / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])))
          > 0.2
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Redis 命中率低于 80%"

      # DB QPS 异常飙升
      - alert: HighDBQPS
        expr: rate(mysql_queries_total[1m]) > 50000
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "MySQL QPS 超过 5 万"

八、总结

三大坑对照

核心问题主方案兜底方案
穿透查不存在的数据布隆过滤器 + 空值缓存WAF 限流
雪崩大量 Key 同时失效过期时间随机化 + 异步刷新限流降级
击穿热点 Key 失效瞬间互斥锁 + 逻辑过期多级缓存

排查口诀

命中率看穿与崩,慢查询看击穿。
同一时刻大量 miss → 雪崩
单 ID 反复查不到 → 穿透
单 ID 高 QPS miss → 击穿

防御口诀

穿透用布隆 + 空值
雪崩用随机 + 异步
击穿用锁 + 逻辑过期
全兜底用多级缓存

监控必装

  • Redis 命中率(< 80% 必告警)
  • DB QPS(异常飙升必告警)
  • 慢查询数(持续高位必告警)
  • 接口 P99(> 1s 必告警)

一句话

穿透是查没有的、雪崩是集体失效、击穿是热点失效。布隆过滤 + 随机过期 + 互斥锁 + 多级缓存,四个方案组合起来,99% 的缓存事故都能预防。

互动话题:你遇到过最严重的缓存事故是哪种?最后怎么解决的?欢迎留言讨论!


参考资料


标题:缓存三大坑:穿透 + 雪崩 + 击穿实战复盘——每个都能让数据库瞬间崩掉
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/11/1786163205745.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消