缓存穿透:从原理到实战的完整解决方案
缓存穿透这个问题,我最早是在一个电商大促项目中遇到的。当时系统突然扛不住了,数据库连接池被打满,排查了半天才发现是缓存穿透在作祟。说实话,那会儿我对这个问题的理解还很浅,后来踩了不少坑才真正搞明白。
什么是缓存穿透
简单来说,缓存穿透就是请求的数据在缓存和数据库中都不存在。每次请求都直接打到数据库上,缓存形同虚设。
你想想看,正常的流程是这样的:
- 请求来了,先查缓存
- 缓存有数据,直接返回
- 缓存没有,查数据库
- 数据库有数据,回写缓存
但穿透的情况呢?缓存没有,数据库也没有。每次请求都直接穿透缓存,打到数据库。如果这种请求量一大,数据库就扛不住了。
核心特征:缓存穿透的本质是「查不到」的数据被高频访问,导致缓存层完全失效。
穿透的原因分析
我在项目中总结了几种常见场景:
- 恶意攻击:有人故意请求不存在的商品ID,比如请求ID为-1、99999999这样的数据
- 业务误操作:前端传入了错误参数,比如用户ID为空字符串
- 数据被删除:原本存在的数据被删除了,但缓存还没来得及更新
- 爬虫扫描:爬虫遍历不存在的URL参数
我记得有一次,线上突然出现大量请求ID为0的数据。排查后发现是某个新来的同事在测试环境写了个死循环,把测试数据发到了线上。嗯,这种问题其实挺常见的。
空值缓存方案
这是最直接的解决方案。说白了,就是查不到数据时,也缓存一个空值。
public String getProduct(String productId) {
// 1. 先查缓存
String cacheValue = redis.get("product:" + productId);
if (cacheValue != null) {
// 缓存命中,直接返回
return cacheValue;
}
// 2. 缓存未命中,查数据库
String dbValue = queryFromDB(productId);
if (dbValue == null) {
// 3. 数据库也没有,缓存空值
// 设置较短的过期时间,比如30秒
redis.set("product:" + productId, "NULL", 30);
return null;
}
// 4. 数据库有数据,正常缓存
redis.set("product:" + productId, dbValue, 3600);
return dbValue;
}
我的经验:空值缓存的过期时间不要设太长。我一般设30-60秒,太长了会导致正常数据恢复后用户还查不到。另外,空值最好用特殊标记,比如"NULL"或"EMPTY",别用空字符串,容易和真实数据混淆。
布隆过滤器原理与实现
空值缓存虽然简单,但有个问题:如果恶意攻击者每次都用不同的ID来请求,缓存里会塞满空值,内存扛不住。这时候就需要布隆过滤器了。
布隆过滤器的原理,我用大白话解释一下:
- 它有一个很长的二进制数组,初始全是0
- 添加数据时,用多个哈希函数计算位置,把对应位设为1
- 查询数据时,同样计算哈希位置,检查这些位是否都是1
- 如果都是1,数据可能存在(有误判率)
- 如果有任何一个位是0,数据一定不存在
说白了,布隆过滤器就是用空间换时间,用一定的误判率换来了极高的查询效率。
关键特性:布隆过滤器说「有」不一定有,但说「没有」一定没有。这个特性正好适合缓存穿透场景——我们只需要过滤掉那些「一定不存在」的请求。
布隆过滤器在Redis中的实战
Redis 4.0之后支持了布隆过滤器插件。我一般用Redisson客户端来操作,代码很简洁:
// 初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("productBloom");
// 初始化,设置预计元素数量和误判率
// 预计100万条数据,误判率1%
bloomFilter.tryInit(1000000L, 0.01);
// 商品上线时,把ID加入布隆过滤器
public void onProductOnline(String productId) {
bloomFilter.add(productId);
}
// 查询时,先用布隆过滤器判断
public String getProduct(String productId) {
// 1. 布隆过滤器判断是否存在
if (!bloomFilter.contains(productId)) {
// 一定不存在,直接返回
return null;
}
// 2. 查缓存
String cacheValue = redis.get("product:" + productId);
if (cacheValue != null) {
return cacheValue;
}
// 3. 查数据库
String dbValue = queryFromDB(productId);
if (dbValue != null) {
redis.set("product:" + productId, dbValue, 3600);
}
return dbValue;
}
避坑指南:我曾经犯过一个错误——布隆过滤器初始化后忘了更新。商品下架了,但布隆过滤器里还有它的ID,导致请求还是能穿透到缓存层。后来我加了个定时任务,每天凌晨重建布隆过滤器。另外,布隆过滤器不支持删除操作,如果业务上有删除需求,得用Counting Bloom Filter或者定期重建。
缓存穿透的监控与告警
光有解决方案还不够,你得知道什么时候出了问题。我一般会监控这几个指标:
| 指标 | 说明 | 告警阈值 |
|---|---|---|
| 缓存命中率 | 正常情况应该在90%以上 | 低于80%告警 |
| 空值缓存数量 | 空值缓存占总缓存的比例 | 超过10%告警 |
| 数据库QPS | 数据库每秒查询次数 | 超过正常值2倍告警 |
| 布隆过滤器误判率 | 实际误判率是否接近预设值 | 超过预设值2倍告警 |
我习惯用Prometheus + Grafana来做监控。在代码里埋点,把关键指标暴露出来:
// 缓存穿透监控埋点
public String getProductWithMonitor(String productId) {
// 记录总请求数
metrics.counter("cache.request.total").inc();
// 布隆过滤器拦截
if (!bloomFilter.contains(productId)) {
metrics.counter("cache.bloom.reject").inc();
return null;
}
// 缓存查询
String cacheValue = redis.get("product:" + productId);
if (cacheValue != null) {
if ("NULL".equals(cacheValue)) {
metrics.counter("cache.hit.null").inc();
} else {
metrics.counter("cache.hit.data").inc();
}
return cacheValue;
}
// 数据库查询
metrics.counter("cache.db.query").inc();
String dbValue = queryFromDB(productId);
if (dbValue == null) {
metrics.counter("cache.penetration").inc();
redis.set("product:" + productId, "NULL", 30);
}
return dbValue;
}
我的建议:告警别设得太敏感。我刚开始做的时候,把缓存命中率低于95%就告警,结果一天收到几百条告警,运维同事差点把我拉黑。后来改成低于80%才告警,配合空值缓存数量和数据库QPS一起看,效果就好多了。
总结一下,缓存穿透的解决方案其实就三板斧:空值缓存兜底、布隆过滤器拦截、监控告警兜底。三者配合使用,基本能挡住99%的穿透问题。剩下的1%,嗯,那就得看你的业务场景了,比如限流、降级这些手段也得备着。