54、缓存穿透模式:布隆过滤器、空值缓存与热点数据保护
缓存穿透,说白了就是「查啥啥没有,回回都落库」。
我刚开始做高并发系统时,就吃过这个亏。一个简单的商品查询接口,Redis里没数据,结果所有请求全怼到数据库上,直接把MySQL打挂了。后来一查日志,有人恶意刷了一个不存在的商品ID,每秒几万次请求……嗯,从那以后,我再也不敢忽视缓存穿透了。
什么是缓存穿透?
正常流程是这样的:请求来了 → 查缓存 → 有数据直接返回 → 没数据查数据库 → 回写缓存。
但缓存穿透呢?请求查一个缓存里没有、数据库里也不存在的数据。每次请求都绕过缓存,直接打到数据库。如果并发高,数据库瞬间就扛不住了。
三种主流解决方案
1. 布隆过滤器(Bloom Filter)
布隆过滤器是个很巧妙的数据结构。它用多个哈希函数,把数据映射到一个位数组上。判断一个key「可能存在」还是「绝对不存在」。
注意,它有一定的误判率——可能把不存在的key误判为存在,但绝不会把存在的key误判为不存在。
我在项目中用过Guava的BloomFilter,也自己实现过基于Redis的分布式版本。说实话,布隆过滤器最适合的场景是:数据量极大,但允许少量误判。
// Java示例:使用Guava的布隆过滤器
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class BloomFilterDemo {
// 预计插入100万条数据,误判率0.01%
private static BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(), 1_000_000, 0.0001);
public static void main(String[] args) {
// 初始化:加载所有合法商品ID
for (String id : getAllProductIds()) {
bloomFilter.put(id);
}
// 查询时先过布隆过滤器
String requestId = "product_999999";
if (!bloomFilter.mightContain(requestId)) {
System.out.println("该ID肯定不存在,直接返回");
return;
}
// 再走缓存-数据库流程
}
}
2. 空值缓存
这个思路更直接:数据库查不到数据时,把「空结果」也缓存起来,设置一个较短的过期时间(比如30秒)。
这样,同样的key再次请求时,直接从缓存返回空,不会打到数据库。
// Java示例:空值缓存
public Object queryProduct(String productId) {
// 1. 查缓存
Object cache = redis.get(productId);
if (cache != null) {
return cache;
}
// 2. 查数据库
Object dbResult = database.query(productId);
if (dbResult == null) {
// 空值也缓存,过期时间30秒
redis.setex(productId, 30, "NULL");
return null;
}
// 3. 正常数据缓存5分钟
redis.setex(productId, 300, dbResult);
return dbResult;
}
3. 热点数据保护
有些数据访问频率极高,比如秒杀商品、热搜词。这些热点数据一旦缓存失效,大量请求同时穿透,就是所谓的「缓存击穿」。
我常用的手段是:互斥锁 + 二级缓存。
// Java示例:热点数据保护(互斥锁)
public Object getHotProduct(String productId) {
// 1. 查一级缓存(Redis)
Object cache = redis.get(productId);
if (cache != null) {
return cache;
}
// 2. 加锁,只让一个线程去查数据库
String lockKey = "lock:" + productId;
if (redis.setnx(lockKey, "1", 5)) { // 5秒超时
try {
Object dbResult = database.query(productId);
redis.setex(productId, 300, dbResult);
return dbResult;
} finally {
redis.del(lockKey);
}
} else {
// 3. 其他线程等待,或者查二级缓存(本地缓存)
Thread.sleep(50);
return redis.get(productId); // 重试
}
}
高并发下的缓存优化
上面三种方案,单独用都有局限。我一般会组合使用:
- 第一层:布隆过滤器——拦截99%的不存在key
- 第二层:空值缓存——兜住漏网之鱼
- 第三层:热点数据保护——防止缓存击穿
你想想看,三层防护下来,数据库的压力能降多少?我实测过,从每秒几万次查询降到几十次,效果非常明显。
核心逻辑图
总结一下
缓存穿透不是高深的技术难题,但处理不好,后果很严重。我个人习惯是:
- 先上布隆过滤器,挡住大部分无效请求
- 再配空值缓存,兜住漏网之鱼
- 热点数据加互斥锁,防止击穿
这三板斧下去,数据库基本就稳了。当然,具体用哪种,还得看你的业务场景。数据量小、key有限,空值缓存就够了;数据量大、key无限,布隆过滤器是首选。