缓存击穿!竟然不知道怎么写代码???

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. 总结

缓存击穿和缓存穿透是我们在缓存应用中必须要面对的两个问题。

避免缓存击穿的方式有加锁、设置热点数据不过期等;避免缓存穿透的方式有将不存在的数据写入缓存、使用布隆过滤器等。

我们需要根据具体的业务场景选择合适的解决方案。

免责声明:本文来自互联网,本站所有信息(包括但不限于文字、视频、音频、数据及图表),不保证该信息的准确性、真实性、完整性、有效性、及时性、原创性等,版权归属于原作者,如无意侵犯媒体或个人知识产权,请来电或致函告之,本站将在第一时间处理。猿码集站发布此文目的在于促进信息交流,此文观点与本站立场无关,不承担任何责任。

后端开发标签