在缓存架构中,“缓存穿透” 和 “缓存击穿” 是两种常见的性能风险 —— 前者是恶意请求不断查询不存在的数据,绕过缓存直击数据库;后者是热点数据缓存失效瞬间,大量请求穿透到数据库。这两种情况都可能导致数据库压力骤增,甚至宕机。有效的防护机制能像 “盾牌” 一样,守护缓存与数据库的安全。
缓存穿透:不存在的数据攻击
什么是缓存穿透?
缓存穿透指查询的数据在缓存和数据库中都不存在(如查询 ID 为 - 1 的用户),导致每次请求都穿透缓存,直接访问数据库。若攻击者高频发送此类请求,会使数据库连接耗尽。
防护方案
1. 空值缓存:给不存在的数据 “占位”
查询结果为空时,仍将空值存入缓存(设置较短过期时间,如 5 分钟),避免重复查询:
public User getUser(Long id) {
// 1. 查缓存
User user = redisTemplate.opsForValue().get("user:" + id);
if (user != null) {
return user;
}
// 2. 缓存为空,查数据库
user = userMapper.selectById(id);
if (user == null) {
// 3. 空值存入缓存(5分钟过期)
redisTemplate.opsForValue().set("user:" + id, new User(), 5, TimeUnit.MINUTES);
return null;
}
// 4. 有值则存入缓存(正常过期时间)
redisTemplate.opsForValue().set("user:" + id, user, 30, TimeUnit.MINUTES);
return user;
}
注意:空值缓存的过期时间需远短于正常数据,避免缓存大量无效空值占用空间。
2. 布隆过滤器:提前拦截不存在的请求
布隆过滤器是一种空间效率极高的概率性数据结构,可快速判断数据是否存在(存在可能误判,不存在则绝对正确)。将数据库中所有有效 ID 存入布隆过滤器,请求先经过过滤器校验:
// 初始化布隆过滤器(预计数据量100万,误判率0.01)
BloomFilter<Long> idFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000,
0.01
);
// 预热:将数据库所有用户ID存入过滤器
List<Long> allIds = userMapper.selectAllIds();
allIds.forEach(id -> idFilter.put(id));
// 接口中使用
public User getUser(Long id) {
// 1. 布隆过滤器判断ID是否可能存在
if (!idFilter.mightContain(id)) {
return null; // 直接返回,不查缓存和数据库
}
// 2. 后续流程:查缓存→查数据库(同空值缓存方案)
// ...
}
适用场景:数据总量固定且 ID 范围明确(如用户 ID、商品 ID),不适用于频繁新增数据的场景(需频繁更新过滤器)。
缓存击穿:热点数据失效的瞬间冲击
什么是缓存击穿?
缓存击穿指一个热点数据(如秒杀商品)的缓存突然过期,此时大量并发请求同时穿透到数据库,导致数据库压力激增。
防护方案
1. 互斥锁:控制并发查询
缓存失效时,只允许一个线程查询数据库并重建缓存,其他线程等待重试:
public Product getSeckillProduct(Long id) {
String cacheKey = "seckill:product:" + id;
// 1. 查缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 2. 缓存失效,尝试获取锁
String lockKey = "lock:seckill:product:" + id;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 拿到锁,查数据库
product = productMapper.selectById(id);
if (product != null) {
// 4. 重建缓存(设置较长过期时间,如1小时)
redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS);
}
return product;
} finally {
// 5. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 6. 未拿到锁,等待50ms后重试
Thread.sleep(50);
return getSeckillProduct(id); // 递归重试
}
}
注意:锁的过期时间需大于数据库查询 + 缓存重建的时间,避免锁提前释放导致多个线程同时查库。
2. 热点数据永不过期:物理不过期 + 逻辑过期
-
物理不过期:缓存不设置过期时间,避免自动失效
-
逻辑过期:在缓存数据中嵌入过期时间,定期异步更新
// 缓存数据结构(含逻辑过期时间)
@Data
public class CacheData<T> {
private T data;
private long expireTime; // 逻辑过期时间戳(毫秒)
}
// 初始化热点数据(物理永不过期)
public void initHotProduct(Long id) {
Product product = productMapper.selectById(id);
CacheData<Product> cacheData = new CacheData<>();
cacheData.setData(product);
cacheData.setExpireTime(System.currentTimeMillis() + 3600 * 1000); // 1小时后逻辑过期
redisTemplate.opsForValue().set("seckill:product:" + id, cacheData);
}
// 定时任务异步更新过期的热点数据
@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void refreshHotProducts() {
// 扫描所有热点商品缓存,更新逻辑过期的数据
// ...
}
// 接口查询
public Product getSeckillProduct(Long id) {
String cacheKey = "seckill:product:" + id;
CacheData<Product> cacheData = redisTemplate.opsForValue().get(cacheKey);
if (cacheData == null) {
return null; // 走降级逻辑
}
// 判断是否逻辑过期
if (System.currentTimeMillis() < cacheData.getExpireTime()) {
return cacheData.getData(); // 未过期,直接返回
}
// 过期则异步更新(不阻塞当前请求)
asyncRefreshProduct(id);
return cacheData.getData(); // 返回旧数据,保证可用性
}
优势:避免请求阻塞,适合可用性优先于一致性的场景(如商品详情)。
防护策略选择指南
| 场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 恶意攻击(ID 不存在) | 布隆过滤器 + 空值缓存 | 布隆过滤器需定期更新,空值缓存过期时间不宜过长 |
| 热点数据(如秒杀) | 互斥锁 + 逻辑过期 | 锁粒度要细(按 ID 加锁),避免大面积阻塞 |
| 低频查询空数据 | 仅空值缓存 | 无需引入布隆过滤器,减少复杂度 |
缓存穿透与击穿的防护,核心是 “减少数据库的无效访问”。通过结合多种机制,既能保证缓存的高效利用,又能为数据库筑起 “防火墙”,这是高并发系统稳定性的关键一环。