一、什么是缓存穿透?
想象你开了一家超市,有个顾客每天都来问"有没有会飞的鱼罐头",而你的超市根本没卖过这种商品。每次你都要翻遍整个仓库确认,结果白白浪费体力。缓存穿透就像这样:恶意请求不断查询根本不存在的数据,导致系统每次都要去数据库空跑一趟,最终拖垮整个系统。
二、为什么会发生缓存穿透?
常见于三种场景:
- 黑客故意用随机ID发起海量请求
- 业务代码误删了缓存但没及时更新
- 冷启动时缓存尚未预热
比如这个Java示例(技术栈:SpringBoot + Redis):
// 有问题的查询方法
public Product getProduct(Long id) {
// 1.先查Redis
Product product = redisTemplate.opsForValue().get("product:" + id);
if (product == null) {
// 2.Redis没有就查数据库(危险操作!)
product = database.query("SELECT * FROM products WHERE id = " + id);
// 3.结果存入Redis
redisTemplate.opsForValue().set("product:" + id, product);
}
return product;
}
当恶意请求传入不存在的id(如-1或999999)时,每次都会穿透到数据库。
三、五大解决方案实战
3.1 布隆过滤器(Bloom Filter)
就像超市门口的货物清单,能快速告诉你"这东西我们肯定没有"。
Java实现示例:
// 初始化布隆过滤器(使用Guava库)
BloomFilter<Long> filter = BloomFilter.create(
Funnels.longFunnel(),
1000000, // 预期数据量
0.01 // 误判率
);
// 预热数据:把已有商品ID加入过滤器
List<Long> existIds = productDao.getAllIds();
existIds.forEach(filter::put);
// 查询改造
public Product getProduct(Long id) {
// 先过布隆过滤器
if (!filter.mightContain(id)) {
return null; // 肯定不存在
}
// ...后续正常流程
}
注意:可能存在1%误判(把存在的判为不存在),但绝不会放行不存在的请求。
3.2 缓存空对象
给不存在的商品也建个"空档案",避免重复查询:
public Product getProduct(Long id) {
Product product = redisTemplate.opsForValue().get("product:" + id);
if (product != null) {
// 特殊标记的空对象
if (product.getId() == -1) return null;
return product;
}
product = database.query("SELECT * FROM products WHERE id = " + id);
if (product == null) {
// 缓存空对象,设置较短过期时间
Product empty = new Product();
empty.setId(-1L); // 特殊标记
redisTemplate.opsForValue().set(
"product:" + id,
empty,
5, TimeUnit.MINUTES // 5分钟过期
);
return null;
}
// ...正常缓存
}
3.3 互斥锁保护数据库
像超市的"正在补货"牌子,防止多人同时查库存:
public Product getProduct(Long id) {
String lockKey = "lock:product:" + id;
try {
// 尝试获取分布式锁(Redis实现)
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
Thread.sleep(50);
return getProduct(id); // 重试
}
// ...正常查询流程
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
}
3.4 接口层防护
在入口处设置规则:
- 对id范围校验(如只允许>0的整数)
- 频率限制(1秒内相同IP最多10次请求)
@RestController
public class ProductController {
// 使用Spring的@RequestLimit注解
@RequestLimit(count=10, time=1000)
@GetMapping("/product/{id}")
public Product get(@PathVariable Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("非法ID");
}
return productService.getProduct(id);
}
}
3.5 热点数据预加载
提前把可能被频繁查询的数据加载到缓存,比如:
@PostConstruct // 服务启动时执行
public void preheatCache() {
List<Long> hotProductIds = Arrays.asList(1001L, 1002L, 1003L);
hotProductIds.forEach(id -> {
Product p = database.queryById(id);
redisTemplate.opsForValue().set("product:" + id, p);
});
}
四、方案对比与选型建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 布隆过滤器 | 内存占用极小 | 有误判率 | 海量数据且允许误判 |
| 缓存空对象 | 实现简单 | 可能缓存大量无效数据 | 数据差异性不大 |
| 互斥锁 | 保证数据库绝对安全 | 降低并发性能 | 超高并发场景 |
| 接口层防护 | 从源头拦截 | 规则可能被绕过 | 所有系统都应该做 |
| 数据预加载 | 完全避免穿透 | 需要准确预测热点 | 已知的热点查询 |
终极建议:
- 必做:接口层基础校验 + 缓存空对象
- 高并发系统:加布隆过滤器
- 秒杀类场景:额外增加互斥锁
五、避坑指南
- 空对象缓存时间建议5-30分钟,避免占用太多内存
- 布隆过滤器的容量要预留20%以上空间,否则误判率飙升
- 分布式锁一定要设置超时时间,防止死锁
- 监控Redis的缓存命中率,低于90%就需要优化
记住:没有银弹!根据你的业务特点组合使用这些方案才是王道。
评论