
在高并发系统中,缓存失效通常不会慢慢出现,而是一下子就把压力传导到数据库上去了。穿透、击穿和雪崩这三种情况看起来很像,但是它们的原因不一样,解决的方法也不同。
拦截:阻止不存在的查询
异常流量一直查询不存在的主键,缓存永远无法命中,直接访问数据库。
- 布隆过滤器作为前置过滤器:合法的ID被写入到位数组中,如果过滤器判断为“不存在”,则直接拒绝;错误的判断只会允许通过而不会拦截。
- 空结果短缓存:当数据库没有数据的时候写入一个空标记,设置TTL为60到300秒之间来防止重复请求。
- 入口参数检查:非法格式、越界ID等错误信息会被网关拦截。
击穿:守住“刚过期的热点 Key”
当某一个热点Key失效的时候,会有大量的请求去查询数据库,连接池也会全部被占满。
- 互斥锁重建:使用
SET key value NX EX来抢占锁,只有单个线程查询数据库并填充数据,其他线程会短暂地重试或者读取旧的数值。 - 逻辑过期:缓存物理上不会失效,在value中有一个内部的时间戳来标记它是否已经过期了,如果过期的话就会进行异步更新,在主链路上返回的是旧的数据以保证其可用性。
- 热点预热:大促前主动加载高频数据,避免冷启动瞬时空窗。
雪崩:把“集中失效的大批密钥”打碎
大量的Key已经过期或者Redis无法访问,导致回源流量出现高峰。
- 随机过期时间:基础TTL加上一个随机的偏移量,例如
1800+rand(0,300)来使失效曲线更加平滑。 - 多级缓存:本地缓存(Caffeine)+ Redis 兜底,减少单点故障。
- 限流降级:Sentinel 对返回源QPS进行限制,超过阈值则使用默认值来避免数据库被拖垮。
落地片段参考
// 随机 TTL:基础 30 分钟 + 0~300 秒抖动
long ttl = 1800 + ThreadLocalRandom.current().nextLong(0, 300);
redis.set(key, value, ttl, TimeUnit.SECONDS);
// 互斥锁防击穿
String lockKey = "lock:" + key;
if (redis.set(lockKey, "1", "NX", "EX", 3)) {
try {
Object data = db.load(key);
redis.set(key, data, ttl, TimeUnit.SECONDS);
} finally {
redis.del(lockKey);
}
}
主要关注三个方面:缓存命中率、数据库QPS峰值以及慢查询中无效Key所占比例,当命中率低于80%,或者回源QPS翻一番的时候就会发出警报。
总结
穿透防“无”,击穿防“单”,雪崩防“多”。布隆过滤器拦截非法查询,互斥锁收敛热点回源,随机过期解决集中失效——三者合起来并不是增加复杂性,而是把风险从数据库前面转移出去并且分散开来。生产环境根据流量大小分等级进行部署:小系统先填充空值缓存和TTL抖动,大系统再添加布隆过滤器、分布式锁以及多级缓存。