一、什么是缓存穿透?
想象一下,你开了一家超市,每天都有顾客来买东西。为了提高效率,你把热销商品放在门口的货架上(这就是缓存)。但有一天,突然有100个人同时来问一种根本不存在的商品(比如"会飞的洗衣机")。由于货架上没有,每个请求都得跑到仓库翻个底朝天(查询数据库),结果当然是白忙活。这就是缓存穿透——大量请求直接绕过缓存,反复查询数据库中不存在的数据。
在分布式系统中,这个问题会被放大。比如用Redis做缓存的电商平台,如果有人恶意发起大量不存在的商品ID查询,可能导致数据库直接挂掉。
二、为什么会发生缓存穿透?
主要原因有三个:
- 恶意攻击:黑客故意构造不存在的关键词发起请求
- 业务设计缺陷:比如APP启动时自动查询用户未创建的配置项
- 数据自然淘汰:缓存过期瞬间遭遇高并发请求
举个Java+Redis的典型例子:
// 危险代码示例:存在缓存穿透风险
public String getProductInfo(String productId) {
// 1. 先查Redis
String cacheKey = "product:" + productId;
String value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return value;
}
// 2. 查数据库(如果数据不存在,每次请求都会执行这里)
Product product = productDao.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product.toJSON());
return product.toJSON();
}
// 3. 数据不存在时返回null
return null;
}
这段代码的问题在于:当productId不存在时,每次请求都会穿透到数据库。
三、五大解决方案实战
方案1:布隆过滤器(Bloom Filter)
布隆过滤器就像个超级高效的"黑名单记事本",它能确定"某个元素绝对不存在"或"可能存在"。
Java+Redisson实现示例:
// 初始化布隆过滤器(需要提前预热数据)
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("product_filter");
bloomFilter.tryInit(100000L, 0.01); // 预期10万数据,误判率1%
// 查询时先检查过滤器
public String getProductInfoSafe(String productId) {
// 1. 布隆过滤器拦截
if (!bloomFilter.contains(productId)) {
return "产品不存在"; // 确定不存在时直接返回
}
// 2. 正常缓存查询流程...
// ...(与前面示例相同)
}
注意事项:
- 需要预热所有有效ID到过滤器
- 1%的误判率意味着可能有1%的合法请求被误拦截
方案2:空值缓存
给不存在的查询结果也设置缓存,就像在超市货架上放个"此商品缺货"的牌子。
public String getProductInfoWithNullCache(String productId) {
String cacheKey = "product:" + productId;
String value = redisTemplate.opsForValue().get(cacheKey);
// 特殊标记识别空缓存
if ("NULL_OBJECT".equals(value)) {
return null;
}
if (value != null) {
return value;
}
Product product = productDao.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product.toJSON(), 1, TimeUnit.HOURS);
} else {
// 设置5分钟的空值缓存
redisTemplate.opsForValue().set(cacheKey, "NULL_OBJECT", 5, TimeUnit.MINUTES);
}
return product != null ? product.toJSON() : null;
}
方案3:互斥锁(Mutex Key)
像卫生间门锁一样,只允许一个人去查数据库,其他人等着用结果。
public String getProductInfoWithLock(String productId) {
String cacheKey = "product:" + productId;
String value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return value;
}
// 尝试获取分布式锁
String lockKey = "lock:" + productId;
try {
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 查数据库
Product product = productDao.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product.toJSON(), 1, TimeUnit.HOURS);
} else {
redisTemplate.opsForValue().set(cacheKey, "NULL_OBJECT", 5, TimeUnit.MINUTES);
}
return product != null ? product.toJSON() : null;
} else {
// 等待100ms后重试
Thread.sleep(100);
return getProductInfoWithLock(productId);
}
} finally {
redisTemplate.delete(lockKey);
}
}
方案4:接口层校验
像保安检查身份证一样,先验证请求的合法性。
// 校验productId是否符合规则
private boolean isValidProductId(String productId) {
// 示例规则:必须是10位数字
return productId != null && productId.matches("\\d{10}");
}
public String getProductInfoWithValidation(String productId) {
if (!isValidProductId(productId)) {
throw new IllegalArgumentException("非法产品ID格式");
}
// ...后续正常流程
}
方案5:热点数据预加载
就像提前把双十一爆款都摆到货架上。
@Scheduled(fixedRate = 3600000) // 每小时执行一次
public void preloadHotProducts() {
List<String> hotProductIds = productDao.selectHotProductIds();
hotProductIds.forEach(id -> {
Product product = productDao.selectById(id);
redisTemplate.opsForValue().set("product:" + id,
product.toJSON(), 2, TimeUnit.HOURS);
});
}
四、方案选型与注意事项
不同场景下的选择
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| ID规律性强 | 接口层校验 | 实现简单成本低 |
| 数据量固定 | 布隆过滤器 | 内存占用恒定 |
| 突发流量大 | 互斥锁+空缓存 | 避免雪崩效应 |
| 热点数据明显 | 预加载 | 提升用户体验 |
需要警惕的坑
- 布隆过滤器误判:需要根据业务容忍度调整参数
- 空缓存过期时间:建议设置较短时间(5-30分钟)
- 锁超时时间:太短会导致重复查询,太长会影响并发
- 缓存污染:恶意构造不同key可能导致缓存被无用数据占满
最佳实践组合
在实际项目中,我们通常会组合使用多种方案:
public String getProductInfoBestPractice(String productId) {
// 1. 基础校验
if (!isValidProductId(productId)) return null;
// 2. 布隆过滤器拦截
if (!bloomFilter.contains(productId)) return null;
// 3. 正常查询流程(含空缓存和锁机制)
return getProductInfoWithLock(productId);
}
五、总结
解决缓存穿透就像给系统穿上防弹衣,需要根据业务特点选择合适的"防护材料"。布隆过滤器适合防御大规模随机攻击,空值缓存应对临时性穿透,互斥锁解决并发风暴,而参数校验则是性价比最高的第一道防线。
记住没有银弹,最好的方案往往是多种策略的组合。建议在测试环境用JMeter模拟极端场景,观察不同方案的效果。毕竟,没经过压力测试的方案就像没试穿过防弹衣就上战场——你永远不知道它会不会关键时刻掉链子。
评论