1. 什么是缓存穿透?
缓存穿透是指查询一个不存在的数据,由于缓存不会保存这样的查询结果,因此每次都会直接查询数据库。如果这种不存在的查询请求频繁发生,会给数据库带来不必要的压力,甚至可能导致数据库崩溃。
例如,一个电商系统中,用户请求查询一个不存在的商品信息(如商品ID为123456),系统会先去缓存中查找。如果缓存中没有,就会查询数据库。但如果数据库中也不存在这个商品,那么后续类似的请求都会直接打到数据库,从而引发性能问题。
2. 解决缓存穿透的方法
(1)接口层面校验
在查询之前,先对请求的参数进行合法性校验。如果参数明显不合理(如商品ID为负数、格式不正确等),直接返回错误,避免进行后续的缓存和数据库查询。
优点:简单高效,能从源头上避免无效请求。
缺点:无法完全解决恶意请求(如恶意构造不存在的合理参数)。
public Object getItemById(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("Invalid ID: " + id);
}
// 从缓存获取数据
Object item = redisTemplate.opsForValue().get("item:" + id);
if (item != null) {
return item;
}
// 缓存未命中,查询数据库
item = database.getItemById(id);
if (item != null) {
// 将数据存入缓存
redisTemplate.opsForValue().set("item:" + id, item, 60, TimeUnit.SECONDS);
}
return item;
}
(2)缓存空对象
当数据库中没有查询到数据时,将一个空对象(如null
或自定义的空对象)存入缓存,并设置一个较短的过期时间(如5分钟)。这样,后续相同的请求可以直接从缓存中获取空对象,而不会再次查询数据库。
优点:能有效减轻数据库压力。
缺点:缓存中会存在一些无用的空对象,占用一定的内存空间。
public Object getItemById(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("Invalid ID: " + id);
}
// 从缓存获取数据
Object item = redisTemplate.opsForValue().get("item:" + id);
if (item != null) {
return item;
}
// 缓存未命中,查询数据库
item = database.getItemById(id);
if (item == null) {
// 数据库中不存在,缓存空对象
redisTemplate.opsForValue().set("item:" + id, "null", 5, TimeUnit.MINUTES);
return null;
}
// 将数据存入缓存
redisTemplate.opsForValue().set("item:" + id, item, 60, TimeUnit.SECONDS);
return item;
}
(3)布隆过滤器(Bloom Filter)
布隆过滤器是一种高效的空间数据结构,用于判断一个元素是否在一个集合中。在应用启动时,将所有可能存在的数据ID(如商品ID)存入布隆过滤器。当查询时,先通过布隆过滤器判断该ID是否存在:
-
如果布隆过滤器返回不存在,则直接返回错误,不查询缓存和数据库;
-
如果布隆过滤器返回可能存在,则继续查询缓存和数据库。
优点:高效、节省内存空间。
缺点:布隆过滤器存在一定的误判率(如将不存在的ID误判为存在),需要根据实际场景选择合适的误判率。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class BloomFilterExample {
private static final int EXPECTED_INSERTIONS = 1000000; // 预期插入的元素数量
private static final double FPP = 0.001; // 误判率
private BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(), EXPECTED_INSERTIONS, FPP);
public BloomFilterExample() {
// 初始化布隆过滤器,将所有可能存在的数据 ID 加入
List<String> allItemIds = database.getAllItemIds();
for (String id : allItemIds) {
bloomFilter.put(id);
}
}
public boolean mightContain(String id) {
return bloomFilter.mightContain(id);
}
}
(4)分布式锁
如果缓存穿透问题是由高并发的首次查询导致的(如多个线程同时查询一个不存在的数据),可以使用分布式锁(如Redisson)来控制并发。当第一个线程查询数据库并写入缓存后,其他线程可以直接从缓存中获取结果。
优点:能有效解决高并发场景下的缓存穿透问题。
缺点:实现相对复杂,需要引入分布式锁组件。
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonLockExample {
private RedissonClient redissonClient;
public RedissonLockExample() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
redissonClient = Redisson.create(config);
}
public Object getItemById(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("Invalid ID: " + id);
}
// 从缓存获取数据
Object item = redisTemplate.opsForValue().get("item:" + id);
if (item != null) {
return item;
}
// 获取分布式锁
RLock lock = redissonClient.getLock("itemLock:" + id);
try {
lock.lock();
// 再次检查缓存
item = redisTemplate.opsForValue().get("item:" + id);
if (item != null) {
return item;
}
// 查询数据库
item = database.getItemById(id);
if (item == null) {
// 数据库中不存在,缓存空对象
redisTemplate.opsForValue().set("item:" + id, "null", 5, TimeUnit.MINUTES);
return null;
}
// 将数据存入缓存
redisTemplate.opsForValue().set("item:" + id, item, 60, TimeUnit.SECONDS);
} finally {
lock.unlock();
}
return item;
}
}
通常会结合多种方法来解决缓存穿透问题:
-
基础手段:接口层面校验是必须的,可以过滤掉大部分非法请求。
-
核心手段:缓存空对象是性价比最高的方案,适用于大多数场景。
-
高级手段:如果数据量较大且对性能要求较高,可以结合布隆过滤器。
-
高并发场景:如果系统并发量较高,可以结合分布式锁来避免缓存穿透问题。