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;
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 "商品不存在"; // 直接返回,不走缓存和数据库
}
// 走正常缓存-数据库流程
方案三:参数校验+限流
这是最便宜的一层。接口层校验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);
进阶方案:本地缓存 + 互斥锁
对于热点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); // 返回本地旧值
}
3.5 一张图理清穿透与雪崩
3.6 我的复盘习惯
每次出现缓存相关事故,我都会按下面三步复盘。嗯,这已经成了我的条件反射。
- 还原现场:查日志、监控、调用链。看故障前1分钟发生了什么。是不是某个key刚好过期?是不是有人发了恶意请求?
- 画时序图:把请求、缓存、数据库的交互时间画出来。哪一步耗时陡增?哪一步阻塞了线程?
- 提炼规则:把这次事故抽象成一条检测规则。比如“同一前缀的key过期时间是否超过阈值”,然后加入自动化检测。
缓存穿透和缓存雪崩,本质上都是缓存与数据库之间的流量洪峰。你不可能完全消除风险,但可以用组合策略把冲击降到可控范围。别怕踩坑,关键是踩完能总结出防坑的栅栏。