1. 什么是缓存击穿?
在探讨怎样解决缓存击穿的问题之前,我们首先需要了解什么是缓存击穿。
缓存击穿是指某个热点数据在缓存中过期或未被缓存,导致该数据在查询时由数据库读取,并发量较高的情况下请求对数据库造成了极大的压力,甚至可能导致数据库宕机。
下面我们通过一个简单的例子来更好地理解缓存击穿的概念:
public String queryDataFromCache(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 如果缓存中未获取到对应的值,则查询数据库
value = queryDataFromDatabase(key);
// 将查询结果写入缓存
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
}
return value;
}
public String queryDataFromDatabase(String key) {
String value = null;
// 模拟从数据库查询数据
// 注意:这里为了模拟缓存击穿的情况,我们故意查询了一个不存在的记录
if ("key1".equals(key)) {
value = "value1";
}
return value;
}
在上述代码中,我们在缓存中设置了5分钟的过期时间。如果在过期时间内读取到某个热点数据,那么就不用查询数据库,直接从缓存中获取该数据即可。但是,如果这个热点数据不在缓存中,而且缓存失效后被频繁地查询,就会导致大量的请求都落到数据库上,造成数据库崩溃的风险。
2. 缓存穿透如何产生?
除了缓存击穿外,缓存穿透也是我们需要关注的另一个问题。
缓存穿透是指查询一个不存在的数据,由于缓存中没有这个数据,因此每次查询都会落到数据库上。缓存穿透会导致数据库的 IO 压力极大,并且也可能导致缓存被击穿(因为缓存中连不存在的数据也缓存不了)。
我们通过一段代码来模拟缓存穿透的情况:
public String queryDataFromCache(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 如果缓存中未获取到对应的值,则查询数据库
value = queryDataFromDatabase(key);
if (value != null) {
// 将查询结果写入缓存
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
} else {
// 将不存在的值也写入缓存,有效时间为1分钟
redisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES);
}
}
return value;
}
public String queryDataFromDatabase(String key) {
String value = null;
// 模拟从数据库查询数据
if ("key1".equals(key)) {
value = "value1";
}
return value;
}
在上述代码中,我们将不存在的值也写入了缓存,并且设置了1分钟的过期时间。这样做的目的是为了避免缓存穿透。(因为我们可以认为1分钟内这个不存在的数据一定不会被查询到。)
3. 缓存击穿和缓存穿透的区别
虽然缓存击穿和缓存穿透都可能导致大量的请求落到数据库上,但它们的本质不同。
缓存击穿是因为缓存中的某个热点数据过期了或未被缓存导致的,它可以通过加锁、设置热点数据不过期等方式来避免。
缓存穿透是因为查询不存在的数据导致的,它可以通过将不存在的数据也写入缓存、使用布隆过滤器等方式来避免。
4. 如何避免缓存击穿?
4.1. 加锁
在缓存查询时,我们可以加锁防止缓存击穿。
加锁的思路很简单:当某个线程发现某个热点数据在缓存中不存在时,会进入到一个加锁的状态,这个加锁的状态可以使用分布式锁实现。当这个线程获取到了该数据后就会释放锁,其他线程就可以获得锁并从缓存中获取到数据了。
下面是基于加锁的缓存代码实现:
public String queryDataFromCache(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 如果缓存中未获取到对应的值,则查询数据库
value = queryDataFromDatabase(key);
if (value != null) {
// 将查询结果写入缓存
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
} else {
// 将不存在的值也写入缓存,有效时间为1分钟
redisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES);
}
}
return value;
}
public String queryDataFromDatabase(String key) {
String value = null;
// 先加锁,防止缓存击穿
RLock lock = redissonClient.getLock(key);
try {
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
// 再次查询缓存,因为有可能在获取锁的时间间隙内,已经有其他线程写入了缓存
value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 如果缓存中未获取到对应的值,则查询数据库
value = queryDataFromDatabase(key);
if (value != null) {
// 将查询结果写入缓存
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
} else {
// 将不存在的值也写入缓存,有效时间为1分钟
redisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES);
}
}
} finally {
lock.unlock();
}
} else {
// 如果获取锁失败,则直接获取数据库数据
value = queryDataFromDatabase(key);
}
} catch (Exception e) {
e.printStackTrace();
}
return value;
}
上述代码中,我们使用了 Redisson 实现了分布式锁。当一个线程获取到锁后就会执行查询缓存的操作,如果查询到了数据就释放锁,其他线程可以获取到这个锁,并从缓存中读取到数据。
虽然加锁能够有效地避免缓存击穿,但它也有一些缺点:
加锁需要消耗一定的时间和资源。
加锁会导致并发度降低,性能下降。
加锁可能引发死锁或活锁等问题。
4.2. 设置热点数据不过期
对于一些“热点”数据,我们可以将它的过期时间设为永久。
这样可以保证查询时,即使该数据在缓存中过期了,仍然可以从缓存中读取出来,而无需深入查询数据库。
下面是永久缓存的代码实现:
public Object queryDataFromCache(String key) {
Object value = redisTemplate.opsForValue().get(key);
// 如果缓存中未获取到对应的值,则查询数据库
if (value == null) {
// 加锁,防止缓存击穿
RLock lock = redissonClient.getLock(key);
try {
lock.lock();
// 再次查询缓存,因为有可能在获取锁的时间间隙内,已经有其他线程写入了缓存
value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 如果缓存中还是未获取到对应的值,则查询数据库
value = queryDataFromDatabase(key);
if (value == null) {
// 避免缓存穿透,将不存在的值也写入缓存中
redisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES);
} else {
// 设置热点数据不过期
redisTemplate.opsForValue().set(key, value);
}
}
} finally {
lock.unlock();
}
}
return value;
}
上述代码中,我们使用了 Redisson 实现了分布式锁。
同时,我们将缓存中热点数据的过期时间设置为永久,这样当数据过期后,Redis 将不会将其清除。但是这样做可能会导致缓存空间的浪费,因此需要仔细衡量。
5. 如何避免缓存穿透?
5.1. 将不存在的数据写入缓存
缓存穿透可以通过将不存在的数据也写入缓存从而避免。
在上面的代码中我们已经看到了如何将不存在的数据写入缓存,这里就不再做重复讲解了。
5.2. 使用布隆过滤器
布隆过滤器是一种可以高效地判断某个元素是否存在的数据结构。
布隆过滤器的思想很简单:将元素映射到一个比特数组上,并使用多个哈希函数对元素进行多重映射。当一个元素被多个哈希函数映射后,它在比特数组上对应的位置都会被置为 1。
当我们需要判断某个元素是否存在时,只需要将它的哈希值传入多个哈希函数即可。如果它的哈希值对应的比特数组上所有的位置都是 1,那么我们可以认为该元素存在,否则该元素不存在。
未命中时就可以直接拦截,从而避免了缓存穿透问题的发生。
下面是使用布隆过滤器的代码实现:
// 初始化布隆过滤器
private BloomFilter bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01);
public Object queryDataFromCache(String key) {
if (!bloomFilter.mightContain(key)) {
// 如果对应的 key 映射到比特数组上所有的位置都是 0,则直接返回
return null;
}
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 如果缓存中未获取到对应的值,则查询数据库
value = queryDataFromDatabase(key);
if (value != null) {
// 将查询结果写入缓存
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
} else {
// 将不存在的值也写入缓存,有效时间为1分钟
redisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES);
}
}
return value;
}
上述代码中,我们使用了 Google Guava 的 BloomFilter 实现了布隆过滤器。
初始化布隆过滤器时,我们指定了元素个数(100 万)和误判率(0.01)。误判率越小,比特数组的大小和哈希函数的个数也会相应地增加。
当检查某个 key 是否存在时,我们首先将它传入布隆过滤器中进行匹配,如果匹配失败则直接返回,不再查询缓存或数据库;如果匹配成功,则按照之前的方式查询缓存或数据库。
6. 总结
缓存击穿和缓存穿透是我们在缓存应用中必须要面对的两个问题。
避免缓存击穿的方式有加锁、设置热点数据不过期等;避免缓存穿透的方式有将不存在的数据写入缓存、使用布隆过滤器等。
我们需要根据具体的业务场景选择合适的解决方案。