缓存穿透、击穿、雪崩:上个月我们数据库被打爆,全栽在这三件事上

20 阅读7分钟

上个月,我们一个核心接口突然把数据库打爆了。

监控显示Redis的QPS正常,但MySQL的连接数拉满,CPU 100%,接口P99从60ms飙到8秒,最后整个服务OOM挂掉。

复盘的时候发现,这次事故不是单一原因,而是缓存穿透、击穿、雪崩三件事凑在了一起。平时这三兄弟各写各的博客,真出事的时候是一起来的。

这篇我把这三件事的原理、真实案例、还有我们最终的防御方案完整讲清楚。技术点不复杂,但真正生产环境里,它们仨是会联动的,你得有一整套防线。


先说清楚这三兄弟的区别

网上讲这三件事的文章很多,但很多人看完还是分不清。我用一句话先给你立住:

  • 穿透:查询一个根本不存在的数据,缓存和数据库都没有,请求直接打到数据库
  • 击穿:查询一个非常热的数据,缓存刚好过期,大量请求同时打到数据库
  • 雪崩一大批数据同时过期,或者Redis挂了,大量请求瞬间涌向数据库

核心区别看两件事:数据存不存在,以及是不是热点数据

穿透是数据不存在;击穿是一个热点数据过期;雪崩是一堆数据同时失效。


事故现场还原

我们有个商品详情接口,接口逻辑:先查Redis,没有就查MySQL,查到后回填Redis。

看起来标准的三层结构,但有几个致命的细节问题。

第一层事故:缓存穿透。 用户端有人(我们怀疑是爬虫或者参数构造)拿着不存在的商品ID疯狂请求,比如/product/999999999。这些ID在MySQL里根本没有记录,所以缓存永远不可能命中,每次请求都打到数据库。

单个请求不算什么,但如果是恶意流量,每秒几百上千个不存在的ID请求,数据库直接被打穿。

第二层事故:缓存击穿。 有个爆款商品,平时流量极高,Redis里的缓存key恰好在那天过期了。缓存过期的瞬间,所有请求都拿不到缓存,同一时刻几千个请求同时去查数据库。数据库被这波集中请求直接干趴。

第三层事故:缓存雪崩。 复盘时发现,我们的商品缓存设置了统一的过期时间(比如都是凌晨2点过期),或者批量更新的商品过期时间相同。一旦到了那个时间点,所有key同时失效,数据库瞬间承受全部流量。

三个问题在同一时段叠加,数据库就是这么被打爆的。


逐个击破:穿透的防御

穿透的根源是"查询不存在的key"。防御手段从简单到复杂:

方案一:缓存空值。 最简单的办法,查不到数据也把空值缓存起来,设置一个较短的过期时间(比如5分钟),这样同一个不存在的key短时间内不会再打到数据库。

Object value = redis.get(key);
if (value != null) {
    return value;
}

// 查数据库
Product product = productMapper.selectById(id);
if (product == null) {
    // 缓存空值,防止穿透,过期时间设短一点
    redis.set(key, "NULL", Duration.ofMinutes(5));
    return null;
}

redis.set(key, product, Duration.ofHours(2));
return product;

方案二:布隆过滤器。 如果空值缓存扛不住(攻击者每次都换不存在的ID,空值key会无限膨胀),就用布隆过滤器。布隆过滤器的原理是用多个hash函数判断一个key是否存在,判断"不存在"是100%准确的,判断"存在"有一定误判率。

@Component
public class BloomFilterHolder {

    private static final int EXPECTED_SIZE = 100_0000;
    private static final double FPP = 0.01;

    private final BloomFilter<String> filter =
        BloomFilter.create(Funnels.stringFunnel(), EXPECTED_SIZE, FPP);

    @PostConstruct
    public void init() {
        // 启动时把存量商品ID全部加载进过滤器
        List<Long> ids = productMapper.selectAllIds();
        ids.forEach(id -> filter.put(String.valueOf(id)));
    }

    public boolean mightContain(String key) {
        return filter.mightContain(key);
    }
}

请求进来先过过滤器:过滤器说不存在,直接返回"商品不存在",根本不打数据库。 过滤器说可能存在,才继续走缓存→数据库的链路。

布隆过滤器是防穿透最有效的方案,代价是初始化时要加载全量ID,以及有极小概率误判(把不存在的key放行到数据库,但概率可控)。


逐个击破:击穿的防御

击穿的根源是"热点数据缓存过期瞬间,请求集中打到数据库"。核心思路是让请求串行化,别同时去打数据库

方案一:互斥锁(推荐)。 缓存过期后,只有一个请求能去查数据库并回填缓存,其他请求等它回填完直接读缓存。

public Product getProduct(Long id) {
    String key = "product:" + id;

    // 先查缓存
    Object value = redis.get(key);
    if (value != null) {
        return (Product) value;
    }

    // 缓存没命中,加分布式锁,只让一个请求查数据库
    String lockKey = "lock:product:" + id;
    boolean locked = redisLock.tryLock(lockKey, Duration.ofSeconds(3));
    if (locked) {
        try {
            // 双重检查:可能别的请求已经回填了
            Object again = redis.get(key);
            if (again != null) {
                return (Product) again;
            }
            // 查数据库并回填
            Product product = productMapper.selectById(id);
            redis.set(key, product, Duration.ofHours(2));
            return product;
        } finally {
            redisLock.unlock(lockKey);
        }
    } else {
        // 没拿到锁,说明别的请求正在查,稍微等一下再读缓存
        sleep(50);
        return getProduct(id);
    }
}

用Redisson的tryLock最省事,它天然支持分布式场景。

方案二:逻辑过期+异步刷新。 热点数据设置"逻辑过期时间",到期后不立即失效,而是返回旧值+触发异步刷新任务,让后台线程去更新缓存。这样用户永远不会感知到缓存过期,数据库压力也小。

// 缓存里存两个字段:业务数据和过期时间
public class CacheValue<T> {
    private T data;
    private long expireTime;
}

public T getWithLogicalExpire(String key) {
    CacheValue<T> cache = (CacheValue<T>) redis.get(key);
    if (cache == null) {
        // 缓存不存在,正常查库回填
        return loadFromDb(key);
    }
    if (cache.getExpireTime() > System.currentTimeMillis()) {
        // 逻辑上没过期,直接返回
        return cache.getData();
    }
    // 逻辑过期了:先返回旧数据,同时异步刷新
    asyncRefresh(key);
    return cache.getData();
}

互斥锁简单直接,逻辑过期对用户更友好(无感知)。热点数据量少、单点打库就几个key的,用互斥锁;热点数据多、希望完全无感的,用逻辑过期。


逐个击破:雪崩的防御

雪崩的根源是"多个key同时过期"或"Redis整体不可用"。防御从两个层面做:

层面一:防止同时过期。

  • 过期时间加随机值,让过期时间错开:
// 过期时间在2小时基础上随机波动,避免同一时刻集体失效
long expire = Duration.ofHours(2).toMillis()
    + ThreadLocalRandom.current().nextLong(0, 600_000);
redis.set(key, value, Duration.ofMillis(expire));
  • 热点数据永不失效,走逻辑过期+异步刷新,物理上不过期

层面二:防止Redis挂了系统跟着挂。

  • 本地缓存兜底(多级缓存):Redis挂了之后,还有一层进程内缓存(Caffeine)扛着,挡住一部分流量
// Caffeine本地缓存,过期时间比Redis短,作为第二道防线
@Bean
public Cache<String, Object> localCache() {
    return Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .build();
}
  • 数据库限流/降级:最坏情况下,数据库也要有保护,防止被打穿后引发更大的故障

事故之后的完整防线

复盘完,我们把商品详情接口改成了这样一套完整链路:

  1. 请求进来 → 布隆过滤器(防穿透,不存在的key直接拦截)
  2. 过滤器通过 → 查本地缓存Caffeine(Redis挂了还有兜底)
  3. 本地没有 → 查Redis(主缓存层)
  4. Redis没有 → 加分布式锁,单请求查库回填(防击穿)
  5. 数据库前还有Sentinel限流(最后一道防线)

这套链路上线后,同样的恶意流量场景,数据库QPS稳定,接口P99回到了60ms。


结尾

缓存三兄弟,单拎出来每一个都是面试八股,但生产环境里它们是联动的。你防了穿透,可能忘了热点击穿;防了击穿,可能缓存雪崩还在。真正靠谱的做法是一层一层全建起来,每层防一类问题,最后一层兜底。

也别过度设计。数据量小、并发不高的系统,缓存空值+随机过期时间基本就够用了。 布隆过滤器、互斥锁、多级缓存这些,是在确认风险真实存在之后再上。

如果这篇对你有用,点个关注。下一篇我想写ZGC——我们服务从G1换到ZGC的调优经历,那个坑更多。