3、缓存穿透与缓存雪崩:我的血泪史

说到缓存,你可能会觉得——不就是存个数据嘛,能有什么坑?

我以前也这么想。直到线上报警响个不停,数据库被打到连接池溢出,用户直接看到502。那时候我才明白:缓存用不对,比不用还可怕。

这一章我就聊聊自己踩过的两个大坑:缓存穿透和缓存雪崩。以及后来总结出的复盘方法。

3.1 什么是缓存穿透?我差点把数据库打崩

先看一个场景:你的API查商品详情,先走Redis缓存。缓存没有,就去MySQL查。这很常见对吧?

但问题来了——如果用户查的是一个不存在的商品ID呢?比如传个-1,或者一个随机数。缓存里当然没有,MySQL里也没有。于是每次请求都直接打到数据库。并发一高,数据库立马跪。

这就是缓存穿透:请求绕过缓存,直接穿透到后端存储层。

核心定义:缓存穿透是指查询一个根本不存在的数据,由于缓存中没有,每次请求都落到底层数据库。

我记得有一次线上事故,就是恶意用户用脚本刷不存在的SKU,几万个线程同时查MySQL。DBA给我打电话说“你的慢查询把整个库拖慢了”。那次我把所有不存在的数据查了个遍,最后加了一层布隆过滤器才算完。

3.2 缓存穿透的三种解法

方案 原理 适用场景 踩坑提醒
缓存空值 查不到时,缓存一个null(短TTL) key数量有限,且不存在key比例不高 空值太多会占用内存,TTL别设太长
布隆过滤器 用位数组快速判断key是否可能存在 key数量大,且不存在key攻击频繁 布隆有误判率,不能100%过滤;且无法删除
参数校验+限流 入口处拦截非法格式(如负数、超长字符串) 所有场景的基础防线 只能防显式非法,防不了合法ID但数据被删

方案一:缓存空值

简单粗暴。查不到数据时,往缓存里写一个特殊标记(比如NULL),并设一个较短的过期时间(比如30秒)。这样后续相同请求直接读缓存,不会打到数据库。

// 伪代码
Object result = redis.get(key);
if (result != null) {
    if (result instanceof NullValue) {
        return null; // 缓存空值,直接返回
    }
    return result;
}
// 查数据库
Object dbResult = dao.query(key);
if (dbResult == null) {
    redis.set(key, new NullValue(), 30); // 缓存30秒空值
} else {
    redis.set(key, dbResult, 3600);
}
return dbResult;
我的习惯:空值的TTL不要超过1分钟。否则数据真的恢复后,缓存还以为是空,导致读取延迟。另外,使用一个专用的标记类(比如NullValue),不要直接存null字符串,避免和真实值混淆。

方案二:布隆过滤器

布隆过滤器是一个概率型数据结构。它告诉你“这个key可能存在”或“肯定不存在”。如果布隆说“不存在”,那就直接拦掉,连Redis都不用查。

// 初始化布隆过滤器(假设用Guava)
BloomFilter<String> filter = BloomFilter.create(
    Funnels.stringFunnel(Charset.forName("UTF-8")),
    1000000,     // 预计元素数量
    0.01         // 误判率
);
// 预热:将所有存在的商品ID加入
for (String id : allExistIds) {
    filter.put(id);
}

// 请求入口
if (!filter.mightContain(requestId)) {
    return "商品不存在"; // 直接返回,不走缓存和数据库
}
// 走正常缓存-数据库流程
注意:布隆过滤器无法删除元素。如果数据删除了,你没法从布隆里去掉它。解决办法:用定时重建布隆,或者改用cuckoo filter(支持删除)。我在项目里选择每半小时全量重建一次布隆,虽然有点笨,但稳定。

方案三:参数校验+限流

这是最便宜的一层。接口层校验ID格式、范围。比如商品ID必须是正整数且小于某个上限。同时配合令牌桶限流,避免单一IP发疯。

// 参数校验
if (id == null || id <= 0 || id > MAX_PRODUCT_ID) {
    throw new IllegalArgumentException("参数非法");
}
// 限流(使用RateLimiter)
RateLimiter limiter = RateLimiter.create(100); // 每秒100个令牌
if (!limiter.tryAcquire()) {
    return "请求过于频繁";
}

3.3 缓存雪崩:那一夜我加班到凌晨三点

缓存穿透是查“无”的数据,缓存雪崩则是大量缓存同时失效,导致请求全落到数据库。

为什么会出现雪崩?最常见的原因:

  • 缓存设置统一的过期时间(比如所有key都设3600秒)
  • Redis实例宕机或重启,缓存全没了
  • 热点key被大量请求,导致数据库连接池耗尽

我记得有一次双十一活动,我们给所有商品缓存设置了相同的TTL——正好12小时。凌晨零点活动上线,大量用户刷新。结果到了中午12点,所有缓存同时过期,数据库瞬间被打爆。那是我第一次被拉进故障群,DBA、运维、产品都在盯着我……

3.4 缓存雪崩的防御方案

策略 做法 优缺点
过期时间加随机偏移 基础TTL上每个key加一个随机值(1~300秒) 简单有效,基本能打散过期时间
多级缓存 本地缓存(L1) + Redis(L2) + 数据库(L3) 性能好,但需要处理数据一致性
缓存预热 + 互斥锁 启动时预加载热点key;重建缓存时加锁 能防热点key的雪崩,但引入锁开销
Redis高可用 主从 + Sentinel / Cluster 防宕机,但不能防业务设计导致的雪崩

核心方案:过期时间加随机偏移

这是最实用的招数。别把所有key设成同一秒过期。在基础TTL上加一个随机偏移量,比如baseTtl + random(0, 300)秒。这样虽然极端情况下仍有少量key同时过期,但不会出现万马奔腾。

// 设置缓存时加随机偏移
int baseTtl = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(0, 300);
redis.setex(key, baseTtl + randomOffset, value);
我建议:随机偏移量至少占基础TTL的5%~10%。对于超长的TTL(比如24小时),可以设更大范围。另外,如果缓存数据更新频率高,可以在更新时重新设置过期时间,也能分散过期点。

进阶方案:本地缓存 + 互斥锁

对于热点key,可以在应用层加一层本地缓存(比如Caffeine)。当本地缓存过期后,只允许一个线程去查Redis和数据库,其他线程等待(或者直接返回旧值)。这样能极大降低对后端的冲击。

// 使用互斥锁重建缓存
String lockKey = "lock:product:" + productId;
boolean locked = redis.setnx(lockKey, "1", 10); // 10秒锁超时
if (locked) {
    try {
        // 查数据库重建缓存
        Product p = dao.query(productId);
        redis.setex("product:" + productId, 3600, p);
        localCache.put(productId, p); // 更新本地缓存
    } finally {
        redis.del(lockKey);
    }
} else {
    // 其他线程:等一会儿读旧缓存或降级
    Thread.sleep(50);
    return localCache.get(productId); // 返回本地旧值
}
注意锁的粒度:锁的key要精细,比如按商品ID加锁,不要用全局锁。锁超时时间要合理,防止数据库慢查询导致锁一直不释放。我曾经设了5秒超时,结果数据库查询偶尔跑到6秒——后续请求全被阻塞,雪上加霜。

3.5 一张图理清穿透与雪崩

缓存穿透与缓存雪崩对比 缓存穿透 查询不存在的数据(如-1) 缓存永远没有,DB被打爆 危害 · 数据库连接池耗尽 · 响应超时/502 解决方案 ① 缓存空值(短TTL) ② 布隆过滤器(拦截不存在) ③ 参数校验+限流 缓存雪崩 大量缓存同时过期/宕机 请求蜂拥到DB,瞬间压垮 危害 · 数据库严重过载/崩溃 · 业务链式故障 解决方案 ① 过期时间+随机偏移 ② 多级缓存(本地+Redis) ③ 缓存预热+互斥锁 核心区别 穿透:查不存在 → 空值/布隆;雪崩:大批量过期 → 打散/多级

3.6 我的复盘习惯

每次出现缓存相关事故,我都会按下面三步复盘。嗯,这已经成了我的条件反射。

  1. 还原现场:查日志、监控、调用链。看故障前1分钟发生了什么。是不是某个key刚好过期?是不是有人发了恶意请求?
  2. 画时序图:把请求、缓存、数据库的交互时间画出来。哪一步耗时陡增?哪一步阻塞了线程?
  3. 提炼规则:把这次事故抽象成一条检测规则。比如“同一前缀的key过期时间是否超过阈值”,然后加入自动化检测。
我以前就是跳过了第3步,导致同样的雪崩在另一个业务线又发生了。后来我强制自己每次故障都写一条规则到配置中心,下次上线前自动扫描。这个习惯救了我好几次。

缓存穿透和缓存雪崩,本质上都是缓存与数据库之间的流量洪峰。你不可能完全消除风险,但可以用组合策略把冲击降到可控范围。别怕踩坑,关键是踩完能总结出防坑的栅栏。