当前位置: 首页 > news >正文

缓存击穿、缓存雪崩、缓存穿透以及数据库缓存双写不一致问题

在项目中,我们所需要的数据通常存储在数据库中,但是数据库的数据保存在硬盘上,硬盘的读写操作很慢,为了避免直接访问数据库,我们可以使用 Redis 作为缓存层,缓存通常存储在内存中,内存的读写速度远超硬盘‌,而引入缓存层后,会出现三种缓存异常问题:缓存穿透缓存雪崩缓存击穿,以及数据库与缓存的双写不一致问题。

1.缓存击穿(Cache Penetration)

定义:缓存击穿是指一个设置了过期时间的缓存项在过期之后,恰好有很多并发请求访问这个过期数据,这些请求发现缓存中没有值后,会去查询数据库,引起数据库压力骤增。

解决方案

互斥锁:在访问缓存的代码块中,使用互斥锁(如 Redis 的 SETNX 命令)来保证在缓存失效的瞬间只有一个请求能查询数据库并更新缓存,其他请求则在锁释放后从缓存中获取数据。(这篇博客有讲:如何通过redis实现分布式锁)。

永久缓存:对于某些不经常改变的数据,可以考虑设置为永久缓存,这样就不会有击穿的问题。

同步锁:使用synchronized加锁排队,通过双重检查锁(DCL)即在进入synchronized前查询一次redis,进入后再查询一次,防止上一个抢到锁的线程已经更新过redis(适用于仅查询的场景,如果要进行更新操作就不太适用该方法,因为synchronized不是分布式锁,在多台服务器上操作可能会导致数据不一致问题)。

String key = "product:" + id;
//先查一次缓存
String data = redisTemplate.opsForValue().get(key);
if (data != null) {//如果查到直接返回return data;
}
synchronized (this) {// 再次检查缓存,防止当很多请求到这里直接访问数据库data = redisTemplate.opsForValue().get(key);if (data != null) {return data;}// 查询数据库data = userMapper.queryFromDatabase(id);if (data != null) {// 将数据存入缓存redisTemplate.opsForValue().set(key,data);}
}

2.缓存雪崩(Cache Avalanche)

定义:缓存雪崩是指缓存在同一时间大面积的失效,这时又来了一波请求,所有的请求都去查数据库,造成数据库压力瞬间过大,宕机,结果是缓存和数据库都挂了,就像雪崩一样。(缓存集中过期或者服务器宕机都会造成雪崩)

解决方案

设置过期时间的随机性:给缓存的过期时间增加一个随机值,避免集体失效。(可通过random随机生成)

使用集群:通过增加机器来分散压力或者哨兵模式避免单一节点压力过大。

限流降级:在应用层面对请求进行限流,或者在系统负载过高时进行服务降级。

服务熔断:使用熔断机制,当检测到系统负载过高时,自动熔断一部分服务,减轻系统压力。

3. 缓存穿透(Cache Bypass)

定义:缓存穿透是指查询一个一定不存在的数据,由于缓存不命中后,还需要去查询数据库,引起大量请求直接穿透到数据库,给数据库造成巨大压力,甚至宕机。

解决方案

布隆过滤器:布隆过滤器可以快速判断一个元素是否存在于某个集合中,可以用来过滤掉不存在的数据请求。

基于redisson实现布隆过滤器示例代码:

@Autowired
private RedissonClient redissonClient;
​
public RBloomFilter<String> initBloomFilter() {RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productBloomFilter");// 预期元素数量为 100000,误判率 1%bloomFilter.tryInit(100000L, 0.01);// 预热数据(示例:从数据库加载所有合法ID)List<Long> productIds = orderService.getAllProductIds();productIds.forEach(id -> bloomFilter.add("product_" + id));return bloomFilter;}

空值缓存:如果一个查询返回的数据为空(null),也将其存储到缓存中,设置一个较短的过期时间,例如几分钟。这样再次请求相同数据时会快速响应,不会每次都去查询数据库。

 public String getData(Long id) {String key = "product:" + id;// 先从缓存中获取数据String data = redisTemplate.opsForValue().get(key);if (data == null) {return null;}else{return data;}// 查询数据库data = userMapper.queryFromDatabase(id);if (data == null) {// 将空值存入缓存redisTemplate.opsForValue().set(key,null);} else {// 将数据存入缓存redisTemplate.opsForValue().set(key,data);}return data;
}

参数校验:提前校验一些数据库中没有的数据。

4.数据库缓存双写不一致问题

数据库和缓存双写不一致‌是指在多用户并发操作中,数据库和缓存中的数据未能保持一致的状态。这种情况通常发生在以下场景:

1‌)先更新数据库再更新缓存‌如果在更新数据库后,更新缓存之前发生故障,缓存中的数据会与数据库中的数据不一致。此外,多个并发请求可能导致缓存被多次更新,而数据库中的数据未能及时更新,从而产生不一致‌。

2)先更新缓存再更新数据库‌:如果在更新缓存后,更新数据库之前发生故障,数据库中的数据会与缓存中的数据不一致。多个并发请求可能导致缓存被覆盖,而数据库中的数据未能及时更新,从而产生不一致‌。

解决方案:为了解决数据库和缓存双写不一致的问题,有以下几种常见的解决方案

1.先更新数据库再删除缓存‌:这种方法避免了缓存和数据库之间的数据不一致问题。更新数据库后删除缓存,下次读取时会从数据库中重新加载最新数据到缓存中。

缺点:然而,在高并发情况下,可能会在删除缓存和更新数据库之间出现短暂的数据不一致,可以通过设置缓存过期时间或使用缓存更新策略来减少这种情况的发生‌。

 @Transactional//保证事物原子性public void updateUser(User user) {// 先更新数据库userMapper.update(user);// 再删除缓存String key = "user:" + user.getId();redisTemplate.delete(key);}

2‌.异步更新缓存‌:通过消息队列来实现异步更新缓存。数据库更新完成后,将操作命令放入消息队列,由缓存系统消费这些命令进行更新。这种方法可以保证数据操作顺序一致性,确保缓存系统的数据正常‌。

缺点:引入了额外的中间件,增加了系统的复杂度和维护成本。

@Transactional
public void updateUser(User user) {// 先更新数据库userMapper.update(user);// 发送消息到消息队列rabbitTemplate.convertAndSend("workExchange", "workRoutingKey", user);
}
​
​
@RabbitListener(queues = "workExchange")
public void handleCacheUpdate(User user) {// 删除缓存String key = "user:" + user.getId();redisTemplate.delete(key);
}

3.延迟双删策略:先删除缓存,再更新数据库,然后在一段时间后再次删除缓存 ,以确保在更新数据库和删除缓存之间读取到旧缓存数据的线程在后续操作中也能获取到最新数据。

第二次删除缓存的原因:

为了解决读写并发请求导致数据不一致的情况,所以要再删除一次缓存(延迟)。

如果不延迟,可能存在如下情况:

  •  写请求:删除缓存
  •  读请求:缓存未命中、读取数据库的值20
  •  写请求:更新数据库的值为21
  •  写请求:第二次删除缓存
  •  读请求:更新缓存值为20还是会导致数据不一致,所以第二次删除缓存需要延迟一段时间,直到 并发的读请求都已经将旧值缓存好这时候再去删除,可以删除掉旧的缓存值。

需要延迟多久这个时间非常不好设定,因为我们不知道并发的读请求写入缓存什么时候能够结束,能够保证第二次删除缓存是能够删除旧值,经验值一般设置为500ms-1s, 如果发生了并发读请求,那么在这段时间内数据是不一致的,外界读取的是旧值第二次删除就一定能成功吗。

如果第二次删除失败了怎么办,会导致长时间的数据不一致,所以还是要引入重试机制(消息队列重试)

缺点:但是需要额外设置延迟时间,若时间设置不合理,可能仍会出现数据不一致的情况;增加了系统的处理时间,降低了系统的响应性能。

@Transactional
public void updateUser(User user) {// 先更新数据库userMapper.update(user);// 再删除缓存String key = "user:" + user.getId();redisTemplate.delete(key);// 延迟一段时间后再次删除缓存new Thread(() -> {try {TimeUnit.MILLISECONDS.sleep(500);redisTemplate.delete(key);} catch (InterruptedException e) {e.printStackTrace();}}).start();
}

4.基于数据库的 Binlog 同步:借助数据库的 Binlog(二进制日志)来捕捉数据库的变更信息,再通过中间件将这些变更信息同步到 Redis 中。

操作步骤

  1.  开启数据库的 Binlog 功能。
  2. 利用 Canal(以 MySQL 为例)等中间件监听数据库的 Binlog 变化。
  3. 中间件将捕获到的变更信息发送给处理程序。
  4. 处理程序根据变更信息更新 Redis 中的缓存数据。 

缺点:引入了额外的中间件,增加了系统的复杂度和维护成本;需要处理中间件的高可用和数据传输的稳定性问题。

5.基于读写锁:读写锁允许多个读操作同时进行,但在写操作时会阻塞其他读操作和写操作,确保在同一时间内只有一个写操作可以访问资源,以此来保证数据的完整性和一致性。

缺点:在一些复杂的业务场景下,可能存在多个数据源之间的数据依赖和更新顺序问题,如果不同数据源之间的更新顺序没有正确处理,仍然可能导致数据不一致。。

6.使用分布式锁‌:在更新操作时使用分布式锁,确保同一时间只有一个请求可以操作缓存和数据库,从而避免数据不一致的问题‌。 (这篇博客有讲:如何通过redis实现分布式锁)

缺点:增加了系统的复杂度和性能开销;分布式事务的实现和维护难度较大。

7.消息队列重试

  1. 在代码里,更新MySQL数据,同步删除缓存,如果删除失败,则发送一个消息队列的删除缓存的消息
  2. 再由一个消费者监听对应的消息,将缓存数据删除
  3. 如果删除失败,触发重试机制,重试删除。如果重试超过一定次数,则需要记录异常且告警。

缺点:该方法对代码入侵性比较强,且引入了消息队列,提升了系统的复杂性,提高了服务的风险性。

相关文章:

  • 落石石头检测数据集VOC+YOLO格式1185张1类别
  • 【MySQL】第13节|MySQL 中模糊查询的全面总结
  • Mixly1.0/2.0/3.0 (windows系统) 安装教程及使用常见问题解决
  • leetcode179_最大数
  • 从认识AI开始-----Transformer:大模型的核心架构
  • 湖北理元理律师事务所:企业债务优化的科学路径与人文关怀
  • LLaMA-Factory - 批量推理(inference)的脚本
  • 《关于有序推动绿电直连发展有关事项的通知》核心内容
  • DAY40 训练和测试
  • 基于FashionMnist数据集的自监督学习(生成式自监督学习VAE算法)
  • 数据结构测试模拟题(3)
  • 【java面试】redis篇
  • 8天Python从入门到精通【itheima】-62~63
  • 【小沐杂货铺】基于Three.JS绘制太阳系Solar System(GIS 、WebGL、vue、react,提供全部源代码)第2期
  • 回溯算法!!
  • Fashion-MNIST LeNet训练
  • 个人用户进行LLMs本地部署前如何自查和筛选
  • PHY6222 基本文件操作
  • 2023ICPC杭州题解
  • 设计模式——组合设计模式(结构型)
  • tornado 网站开发/苹果被曝开发搜索引擎对标谷歌
  • wordpress js失效/seo岗位
  • 北京市建设和城乡住房委员会/深圳网站设计实力乐云seo
  • 有公网ip 如何做一网站/网站关键词优化排名软件
  • seo网站是什么/产品推广方案ppt模板
  • 建立互联网公司网站/线上推广平台都有哪些