上个月,我们一个核心接口突然把数据库打爆了。
监控显示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();
}
- 数据库限流/降级:最坏情况下,数据库也要有保护,防止被打穿后引发更大的故障
事故之后的完整防线
复盘完,我们把商品详情接口改成了这样一套完整链路:
- 请求进来 → 布隆过滤器(防穿透,不存在的key直接拦截)
- 过滤器通过 → 查本地缓存Caffeine(Redis挂了还有兜底)
- 本地没有 → 查Redis(主缓存层)
- Redis没有 → 加分布式锁,单请求查库回填(防击穿)
- 数据库前还有Sentinel限流(最后一道防线)
这套链路上线后,同样的恶意流量场景,数据库QPS稳定,接口P99回到了60ms。
结尾
缓存三兄弟,单拎出来每一个都是面试八股,但生产环境里它们是联动的。你防了穿透,可能忘了热点击穿;防了击穿,可能缓存雪崩还在。真正靠谱的做法是一层一层全建起来,每层防一类问题,最后一层兜底。
也别过度设计。数据量小、并发不高的系统,缓存空值+随机过期时间基本就够用了。 布隆过滤器、互斥锁、多级缓存这些,是在确认风险真实存在之后再上。
如果这篇对你有用,点个关注。下一篇我想写ZGC——我们服务从G1换到ZGC的调优经历,那个坑更多。