大家好,我是数据库小学妹👋我踩过的坑,你别再踩。
618凌晨我值班,凌晨一点二十分,监控群同时弹出17条告警。Redis命中率从98%断崖式跌到23%。MySQL连接池直接打满。网关返回502的时间,每单支付平均慢了四秒。事后查了三个小时,根因是一批过期key的TTL设成了同一秒。同一时间全部过期,几千个请求同时打到数据库。这就是典型的缓存雪崩。
这次事故让我意识到,缓存穿透、击穿、雪崩这三个词很多教程混着讲,但底层机制完全不同,防御策略也完全不同。今天我把生产环境验证过的方案完整拆开讲,希望能帮你少走弯路少踩坑。
先说结论:三者不是一个东西
先看一张对比表,一眼分清。
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 触发条件 | 查询不存在的key | 单个热点key过期瞬间 | 大量key同时过期 |
| 命中情况 | 永远不会命中 | 命中瞬间归零后恢复 | 命中率断崖式下降 |
| 攻击面 | 恶意请求或脏数据 | 正常业务热点数据 | TTL集中到期 |
| DB压力 | 持续高,无效查询 | 瞬时打满 | 瞬间洪峰 |
| 核心防御 | 布隆过滤器+空值缓存 | 分布式锁+逻辑过期 | TTL随机化+多级缓存 |
| 排查特征 | 请求分散无热点key | 某个key请求量异常集中 | 大量key同时miss |
搞混了防御手段,等于用创可贴止大动脉出血。
第一案:缓存穿透,查一个永远不存在的数据
商品表有100万条数据,有人恶意请求ID为999999999的商品。这个ID不存在,缓存里自然没有。每次请求都穿透到数据库,一万次就是万次无效查询,数据库CPU直接飙到90%。
穿透的本质是缓存层没有拦截无效请求的能力。每一次miss都变成一次数据库查询。请求量大的话数据库连接池很快被打满,正常的业务请求也被阻塞。
生产方案用布隆过滤器加空值缓存。布隆过滤器解决的是快速判断key是否存在。底层是一个二进制位数组加上多个哈希函数。元素经过多个哈希映射到位数组的多个位置。查询时只要有一个位置是0就说明不存在。误判率可调,但不会漏判。它说不存在的一定不存在。它说存在的小概率不存在。
// Guava BloomFilter 初始化
BloomFilter<String> bf = BloomFilter.create(
Funnels.stringFunnel(Charset.forName("UTF-8")),
1000000, // 预期元素量
0.01 // 误判率1%
);
// 预加载所有存在的商品ID
for (Long id : allProductIds) {
bf.put(String.valueOf(id));
}
// 查询时先过布隆过滤器
public Product getProduct(Long id) {
String key = "product:" + id;
if (!bf.mightContain(String.valueOf(id))) {
return null; // 布隆说没有,直接返回
}
Product p = redis.get(key);
if (p != null) return p; // 缓存有直接返回
p = db.getById(id);
if (p != null) {
redis.setex(key, 3600, p);
} else {
redis.setex(key, 60, "NULL"); // 数据库也没有,缓存空值
}
return p;
}
空值缓存的TTL设60秒到120秒。设太长的话万一新数据写入缓存还是旧的null。设太短防不住高频攻击。
我踩过的坑是布隆过滤器误判率一开始设了0.001。当时以为精度越高越好,结果内存占了好几百兆。后来改成0.01,误判率1%对缓存场景完全可接受,内存降了一个数量级。不过如果是安全敏感场景,0.001仍有必要,不能一概而论。业内推荐误判率在0.1%到5%之间就够。
还有个坑是布隆过滤器不支持删除。商品下架后位数组无法回退。我的做法是定期重建过滤器,凌晨两点全量刷一次。如果业务删除频繁,可以考虑布谷鸟过滤器,它支持删除操作,RedisBloom也原生支持。
对了,上面代码用的是Guava本地布隆过滤器。如果数据量大或者需要多实例共享,可以用RedisBloom模块。Redis 4.0之后就有这个模块,直接用BF.RESERVE、BF.ADD、BF.EXISTS命令操作。100万个ID大约只需1.2MB内存,过滤器和Redis同进程,没有额外网络开销。
第二案:缓存击穿,热点key过期的那一秒
首页推荐位有一个热点key,QPS三万,TTL设了300秒。第五分钟整key过期,同一瞬间200个线程同时发现key不在缓存。200个线程同时查数据库,连接数瞬间打满。
击穿和穿透的区别在于数据本身存在,只是缓存恰好在这个时间点过期。大量并发请求同时miss,瞬时压力比穿透更集中。
分布式锁的核心思路是key过期时只让一个线程去查数据库重建缓存,其他线程等待或返回旧值。
public Product getHotProduct(Long id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
Product p = redis.get(key);
if (p != null) return p;
boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
Thread.sleep(100); // 等100ms再查一次
return redis.get(key); // 大概率另一个线程已重建
}
try {
p = db.getById(id); // 拿到锁的线程查DB重建
if (p != null) redis.setex(key, 300, p);
} finally {
redis.delete(lockKey);
}
return p;
}
但SETNX实现的分布式锁有个隐患。线程拿到锁后如果异常退出,锁可能不会释放。其他线程永远等不到。生产环境用Redisson。它有看门狗机制,默认30秒自动续期。线程异常退出后锁自动释放。
RLock lock = redisson.getLock(lockKey);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
p = db.getById(id);
redis.setex(key, 300, p);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
有些场景不允许线程等待,比如响应时间要求200ms以内。这时候用逻辑过期。缓存value里自带过期时间字段,实际Redis的TTL设成很长。查询时判断逻辑过期时间是否到了,到了就让后台线程异步重建,其他线程返回旧值。
@Data
class CacheWrapper<T> {
private T data;
private long expireTime; // 逻辑过期时间戳
}
public Product getProductWithLogicalExpire(Long id) {
String key = "product:" + id;
CacheWrapper<Product> wrapper = redis.get(key);
if (wrapper == null) return loadAndCache(id);
if (System.currentTimeMillis() > wrapper.getExpireTime()) {
executor.submit(() -> loadAndCache(id)); // 逻辑过期,异步重建
}
return wrapper.getData(); // 当前返回旧值
}
这个方案零等待,缺点是可能返回过期数据,对一致性要求高的场景不适用。我当时的选择标准是用户中心用分布式锁方案要求强一致,商品详情页用逻辑过期允许秒级延迟,首页推荐用逻辑旧数据不影响体验。
第三案:缓存雪崩,TTL集中到期的连锁反应
这就是开头618事故的那一幕。促销活动期间上了5000个新商品,运营批量导入时缓存TTL统一设了3600秒。一小时整5000个key同时过期,每秒几千个miss涌向数据库。MySQL CPU从15%飙到98%,主从延迟超过30秒。
雪崩和击穿的区别在于规模。击穿是单个key,雪崩是大批key集中在同一时间点过期,压力不是一个量级。雪崩的另一个诱因是Redis节点宕机,整个缓存层不可用,所有请求直接打到数据库,这比TTL过期更致命。
TTL随机化是最基础的防御。给每个key的过期时间加上随机偏移量。
int baseTtl = 3600;
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, randomTtl, value);
偏移量设基础TTL的5%到10%。三千六百秒加三百秒随机,key的过期时间就均匀分布在六十一分钟内,不会同时到期。
多级缓存是更稳的方案。本地缓存Caffeine做第一层,Redis做第二层,数据库是最后兜底。Redis挂的时候本地缓存还能扛一阵。
public Product getMultiLevel(Long id) {
String key = "product:" + id;
Product p = localCache.getIfPresent(key);
if (p != null) return p; // 第一层本地缓存
p = redis.get(key);
if (p != null) {
localCache.put(key, p);
return p; // 第二层Redis
}
p = db.getById(id);
if (p != null) {
redis.setex(key, 3600, p);
localCache.put(key, p); // 第三层数据库
}
return p;
}
降级熔断是最后的防线。用Sentinel或Resilience4j,数据库响应时间超过阈值就触发降级,直接返回兜底数据。
SentinelConfig.loadRules(List.of(
new DegradeRule("dbQuery")
.setGrade(CircuitBreakingStrategy.SLOW_REQUEST_RATIO)
.setSlowRatioThreshold(0.5)
.setTimeWindow(30)
.setMinRequestAmount(20)
));
监控和告警:别等炸了才知道
这三个问题的共同点是发现的时候已经炸了。事后我补了这套监控。关键指标有四个。
- 缓存命中率按小时统计,miss率突增超过10%就要告警。
- Redis慢查询日志设10毫秒阈值。
- 每秒miss数的QPS曲线,陡增必有异常。
- 数据库连接池使用率超过80%就预警。
Grafana大盘上我把这四个指标放在一起,配合Redis自带的INFO命令。instantaneous_ops_per_sec看吞吐,keyspace_hits和keyspace_misses算命中率,connected_clients看连接数。
除了监控,还有两件事要提前做。一是Redis集群高可用,生产环境建议主从加哨兵或Redis Cluster,避免单点故障直接引发雪崩。二是新业务上线前做好缓存预热,别让第一批请求直接打到数据库。
避坑清单
布隆过滤器误判率在0.1%到5%之间就够,缓存场景1%完全可接受,不是越低越好。安全敏感场景可以设更低,但普通业务没必要追求千分之一。
缓存空值的TTL要单独管理,别和业务数据用同一套策略,太长会导致新数据写入后仍然返回null,太短起不到防护作用。
Redisson看门狗默认30秒续期,业务查询耗时超过30秒锁会被其他线程抢走,慢查询场景要把watchdogTimeout调大或改用tryLock带超时参数。
TTL随机化的偏移量别超基础值的百分之十,偏移量太大会拉长数据一致性窗口。雪崩场景下别用KEYS命令排查,生产环境执行KEYS会让Redis阻塞十几秒,用SCAN代替。商品删除频繁的场景可以考虑布谷鸟过滤器替代布隆过滤器,它支持删除操作。
你们生产环境遇到过哪种缓存事故?布隆过滤器和逻辑过期你们选哪种?评论区聊聊你的方案。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋