本文主要是介绍Redis之缓存穿透、缓存击穿、缓存雪崩、无底洞,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
Redis之缓存穿透、缓存击穿、缓存雪崩、无底洞
- (一)缓存穿透
- 方案一: 缓存空对象:
- 方案二: 布隆过滤器:
- 前提
- 1. 引入依赖
- 2. 代码实现
- 3. 相关例子
- 细节
- (二)缓存击穿
- (三)缓存雪崩
- (四)无底洞
(一)缓存穿透
Redis缓存穿透是指查询一个不存在的数据,由于缓存中也没有该数据,导致每次查询都需要去数据库中查询,而数据库中也没有该数据,这样就造成了
缓存和数据库的“双未命中
。
在高并发场景下,大量的这种查询请求会直击数据库,可能导致**数据库压力过大甚至宕机
**。
缓存穿透的发生一般有这两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务
常见的解决方案有两种:
方案一: 缓存空对象:
当查询一个不存在的数据时,可以将这个空结果(或默认值)缓存起来,并设置一个较短的过期时间。
这样,后续的相同请求可以直接从缓存中获取这个空结果或默认值,而不是去查询数据库。需要注意的是,这种方法可能会导致缓存中存在大量的空对象,因此需要合理设置过期时间,并及时清理这些空对象。
以下是具体“缓存空对象”的具体实现
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; @Service
public class CacheService { @Autowired private RedisTemplate<String, Object> redisTemplate; // 假设这是从数据库中查询数据的方法 public Object getDataFromDb(String key) { // 这里模拟数据库查询,实际上应该查询数据库 // 如果数据不存在,返回null或某个默认值 return null; // 或某个默认值 } // 获取数据,首先尝试从缓存中获取 public Object getData(String key) { // 从Redis中获取数据 Object result = redisTemplate.opsForValue().get(key); if (result == null) { // 缓存中不存在,去数据库中查询 result = getDataFromDb(key); if (result == null) { // 数据不存在于数据库,缓存一个空对象或默认值,并设置过期时间 // 设置5分钟过期时间 redisTemplate.opsForValue().set(key, result, 5, TimeUnit.MINUTES); } else { // 数据存在于数据库,正常缓存数据,并设置合理的过期时间 redisTemplate.opsForValue().set(key, result, /* 合理的过期时间 */); } } return result; }
}
方案二: 布隆过滤器:
布隆过滤器是一种空间效率极高的概率型数据结构,它利用位数组来表示集合,并且允许有一定的误判率。
当查询一个元素是否存在于某个集合时,它只会告诉你“可能存在”或“一定不存在”。
将布隆过滤器放在缓存之前,当请求的数据不存在于布隆过滤器时,则直接返回,避免了对数据库的无效查询。
为了解决Redis缓存穿透带来的服务宕机问题,我们可以使用布隆过滤器来预先判断一个查询是否可能存在于Redis中。如果布隆过滤器判断该查询不可能存在,则直接返回空值或错误信息,避免进一步查询Redis或数据库。
以下是使用布隆过滤器解决Redis缓存穿透问题的具体步骤:
前提
当一个数据被存入数据库时,其关键信息(如主键)也需要放入布隆过滤器中。用于后续查询
1. 引入依赖
首先,确保你的项目中包含了Redis客户端的依赖和布隆过滤器的实现。
如果你使用的是Spring Boot项目,可以通过Maven或Gradle添加依赖。
以Maven为例,添加Redis客户端(如Lettuce)和Guava(包含布隆过滤器的实现)的依赖:
<dependencies> <!-- Redis客户端依赖,这里以Lettuce为例 --> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>你的lettuce版本号</version> </dependency> <!-- Guava依赖,包含布隆过滤器 --> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>你的guava版本号</version> </dependency> <!-- 其他依赖... -->
</dependencies>
2. 代码实现
接下来,我们实现布隆过滤器的初始化和查询逻辑。这里以Guava的布隆过滤器为例。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis; import javax.annotation.PostConstruct;
import java.nio.charset.Charset;
import java.util.BitSet;
import java.util.concurrent.ConcurrentHashMap; @Component
public class BloomFilterCache { // 假设我们从配置中读取布隆过滤器的参数 // 预期插入的元素数量 @Value("${bloomfilter.expectedInsertions:1000000}") private long expectedInsertions; // 误报率@Value("${bloomfilter.falsePositiveProbability:0.01}") private double falsePositiveProbability; // 使用Guava的布隆过滤器 private BloomFilter<String> bloomFilter; // 本地缓存,用于快速判断元素是否在布隆过滤器中 private ConcurrentHashMap<String, Boolean> localCache = new ConcurrentHashMap<>(); // 初始化布隆过滤器 @PostConstruct public void initBloomFilter() { bloomFilter = BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), expectedInsertions, falsePositiveProbability ); } // 向布隆过滤器中添加元素 public void put(String key) { bloomFilter.put(key); localCache.put(key, true); } // 判断元素是否可能存在于布隆过滤器中 public boolean mightContain(String key) { // 首先检查本地缓存 if (localCache.containsKey(key)) { return localCache.get(key); } // 如果本地缓存没有,则查询布隆过滤器并更新本地缓存 boolean mightContain = bloomFilter.mightContain(key); localCache.put(key, mightContain); return mightContain; } // 查询缓存,如果布隆过滤器认为不存在,则直接返回null public String getFromCache(String key) { // 使用布隆过滤器进行预判断 if (!mightContain(key)) { // 布隆过滤器认为不存在,直接返回null,避免查询Redis return null; } // 布隆过滤器认为可能存在,查询Redis Jedis jedis = new Jedis("localhost"); // 假设Redis服务在本地 String value = jedis.get(key); // 根据业务逻辑处理Redis返回的结果 if (value == null) { // 缓存中不存在,可能是之前被删除了,或者布隆过滤器误报 // 这里可以记录日志,或者选择性的回退到数据库查询 } return value; }
}
在上面的代码中,我们创建了一个BloomFilterCache
类,它负责初始化布隆过滤器、向布隆过滤器中添加元素、判断元素是否可能存在于布隆过滤器中,以及查询缓存的逻辑。
3. 相关例子
假设我们有一个在线购物平台,用户可以通过API接口查询商品信息。
为了提高性能,我们通常会使用Redis作为缓存层来存储热门商品的查询结果。
然而,如果某个商品不在Redis中,并且也不在数据库中(即发生了缓存穿透),那么大量的请求可能会直接打到数据库上,导致数据库压力骤增,甚至可能引发服务宕机。
为了避免这种情况,我们可以使用布隆过滤器来预先判断一个商品ID是否可能存在于Redis缓存中。
以下是基于前面BloomFilterCache
类的商品查询接口示例:
@RestController
@RequestMapping("/products")
public class ProductController { @Autowired private BloomFilterCache bloomFilterCache; @Autowired private ProductService productService; // 假设有一个ProductService用于从Redis或数据库获取商品信息 @GetMapping("/{id}") public ResponseEntity<Product> getProductById(@PathVariable String id) { // 使用布隆过滤器判断商品ID是否可能存在于Redis中 if (!bloomFilterCache.mightContain(id)) { // 布隆过滤器认为不存在,直接返回404或错误信息,避免进一步查询 return ResponseEntity.notFound().build(); } // 布隆过滤器认为可能存在,继续查询Redis Product product = productService.getProductByIdFromCache(id); if (product == null) { // Redis中不存在,可能是缓存失效或被删除,可以考虑回退到数据库查询 product = productService.getProductByIdFromDatabase(id); if (product != null) { // 如果数据库中有,更新Redis缓存并返回结果 productService.putProductIntoCache(product); bloomFilterCache.put(id); // 更新布隆过滤器 } else { // 数据库中也不存在,返回404或错误信息 return ResponseEntity.notFound().build(); } } // 返回商品信息 return ResponseEntity.ok(product); }
}
在上面的例子中,getProductById
方法首先使用布隆过滤器来判断商品ID是否可能存在于Redis中。
如果布隆过滤器认为不存在,则直接返回404或错误信息,避免了不必要的Redis和数据库查询。
如果布隆过滤器认为可能存在,则继续查询Redis。如果Redis中不存在该商品,再回退到数据库查询。
如果数据库中有该商品,除了返回结果外,还会更新Redis缓存和布隆过滤器,以便后续的请求能够直接从Redis中获取数据。
需要注意的是,布隆过滤器存在误报率,即可能会将不存在的元素误判为存在。
因此,即使布隆过滤器认为某个元素可能存在,我们仍然需要实际查询Redis或数据库来确认该元素是否真的存在。
同时,为了保持布隆过滤器的同步和准确性,当Redis中的数据发生变化时(如添加、删除元素),也需要相应地更新布隆过滤器。这通常需要在业务逻辑中进行适当的处理。
细节
初始化布隆过滤器时
BloomFilter.create
方法存在
指定预期的插入数量(expectedInsertions
)和期望的误报率(falsePositiveProbability
)。
Guava库中的Bloom过滤器实现允许你根据这些参数来构建一个合适的过滤器。
通过调整这些参数,你可以平衡过滤器的内存使用和误报率。
以下是关于这些参数的一些要点:
- 预期的插入数量(
expectedInsertions
):- 这个参数是用来估计你将要插入到Bloom过滤器中的元素数量的。这个数量会影响过滤器内部位数组的大小。
- 如果你低估了这个数量,那么过滤器的误报率可能会比期望的高。
假设 预期的插入数量是10000,设置期望的误报率为0.01,但实际的插入值达到了120000, 实际的误报率会比期望的误报率(0.01)高
- 如果你高估了这个数量,那么过滤器会占用更多的内存,但误报率可能会低于期望的。
- 期望的误报率(
falsePositiveProbability
):- 这个参数是你愿意接受的误报率的上限。误报率是指过滤器错误地认为一个不在集合中的元素实际上在集合中的概率。
- 误报率越低,过滤器需要的内存就越多。因此,这是一个在内存使用和准确性之间权衡的参数。
- 注意,这个参数是一个概率上限,实际误报率可能会低于这个值,但绝对不会高于它。
- 位数组和哈希函数:
- Bloom过滤器内部使用一个位数组来存储信息,并且使用多个哈希函数来将元素映射到位数组的不同位置。
- 当插入一个元素时,Bloom过滤器会使用这些哈希函数计算元素应该设置位数组中的哪些位。
- 当查询一个元素时,过滤器会检查这些位是否被设置。如果所有相关的位都被设置了,那么过滤器就认为元素可能存在于集合中(可能有误报);如果有任何一位没有被设置,那么过滤器就确定元素不在集合中(没有误报)。
- 不可变性:
- 一旦创建了一个Bloom过滤器并添加了元素,你就不能从中删除元素或调整其参数。如果你需要删除元素或改变误报率,你需要创建一个新的过滤器。
- 性能和空间效率:
- Bloom过滤器提供了空间和时间效率很高的成员查询,但牺牲了精确性(即存在误报)。它们非常适合用于快速检查元素是否可能存在于集合中,但不适用于需要精确成员关系的场景。
(二)缓存击穿
缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个
Key在失效的瞬间
,持续的大并发
就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。
导致后端存储负载增大、响应时间变慢,甚至可能导致系统瘫痪
解决方案
1.设置热点数据永远不过期
2.互斥锁
用互斥锁来保证只有一个线程读取数据库并写入缓存,其他线程必须从缓存中读取。
具体如何实现另一篇(SpringBoot之集成Redis)文章讲到的 分布式锁 的操作
(三)缓存雪崩
缓存雪崩的英文原意是stampeding herd(奔逃的野牛)
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力,造成数据库后端故障,从而引起应用服务器雪崩。
雪崩效应产生的几种场景
- 流量激增:比如异常流量、用户重试导致系统负载升高;
- 缓存刷新:假设A为client端,B为Server端,假设A系统请求都流向B系统,请求超出了B系统的承载能力,就会造成B系统崩溃;
- 程序有Bug:代码循环调用的逻辑问题,资源未释放引起的内存泄漏等问题;
- 硬件故障:比如宕机,机房断电,光纤被挖断等。
- 数据库严重瓶颈,比如:长事务、sql超时等。
- 线程同步等待:系统间经常采用同步服务调用模式,核心服务和非核心服务共用一个线程池和消息队列。
如果一个核心业务线程调用非核心线程,这个非核心线程交由第三方系统完成,当第三方系统本身出现问题,导致核心线程阻塞,
一直处于等待状态,而进程间的调用是有超时限制的,最终这条线程将断掉,也可能引发雪崩;
(四)无底洞
2010年,Facebook的Memcache节点(服务器)已经达到了3000个,承载着TB级别的缓存数据。
但开发和运维人员发现了一个问题,为了满足业务要求添加了大量新Memcache节点(服务器),但是发现性能不但没有好转反而下降了,当时将这种现象称为缓存的“无底洞”现象。
用一句通俗的话总结就是,更多的节点不代表更高的性能,
所谓“无底洞”就是说投入越多不一定产出越多。
但是分布式又是不可以避免的,因为访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式缓存中批量操作是一个难点。
后续再说
这篇关于Redis之缓存穿透、缓存击穿、缓存雪崩、无底洞的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!