首页 > 其他分享 >缓存双写一致性之更新策略探讨

缓存双写一致性之更新策略探讨

时间:2023-12-24 23:23:00浏览次数:47  
标签:缓存 数据库 redis 更新 user mysql 一致性 双写

缓存双写一致性之更新策略探讨

image-20231224105231931

面试题

上面业务逻辑你用java代码如何写?

你只要用缓存,就可能涉及到Redis缓存与数据库双存储双写,只要是双写就一定会有数据一致性的问题,那么如何解决?

双写一致性,你先动缓存Redis还是数据库MySQL?Why?

延时双删你做过吗?会有哪些问题?

有这么一种情况,微服务查询Redis无MySQL有,为保证数据双写一致性,回写Redis需要注意什么?双检加锁策略你了解过吗?如何尽量避免缓存击穿?

Redis和MySQL双写100%会出现纰漏,做不到强一致性,你如何保证最终一致性?

缓存双写一致性,谈谈你的理解

如果Redis中有数据:需要和数据库中的值相同

如果Redis中无数据:数据库中的值要是最新值,且准备回写Redis

 

缓存按照操作来分,分为2种

  1. 只读缓存

  2. 读写缓存

    1. 同步直写策略:

      写数据库后也同步写Redis缓存,缓存和数据库中的数据一致;

      对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。

    2. 异步缓写策略:

      正常业务运行中,MySQL数据变动了,但是业务上可以容许出现一定时间后才作用于Redis,比如仓库、物流系统

      异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者RabbitMQ等消息中间件,实现重试重写。

 

上图的业务逻辑用Java代码如何写?

多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。

其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。

后面的线程进来发现已经有缓存了,就直接走缓存。

image-20231224110850965

@Service
@Slf4j
public class UserService {
   public static final String CACHE_KEY_USER = "user:";
   @Resource
   private UserMapper userMapper;
   @Resource
   private RedisTemplate redisTemplate;

   /**
    * 业务逻辑没有写错,对于小厂中厂(QPS《=1000)可以使用,但是大厂不行
    * @param id
    * @return
    */
   public User findUserById(Integer id)
  {
       User user = null;
       String key = CACHE_KEY_USER+id;

       //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
       user = (User) redisTemplate.opsForValue().get(key);

       if(user == null)
      {
           //2 redis里面无,继续查询mysql
           user = userMapper.selectByPrimaryKey(id);
           if(user == null)
          {
               //3.1 redis+mysql 都无数据
               //你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
               return user;
          }else{
               //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
               redisTemplate.opsForValue().set(key,user);
          }
      }
       return user;
  }


   /**
    * 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
    * @param id
    * @return
    */
   public User findUserById2(Integer id)
  {
       User user = null;
       String key = CACHE_KEY_USER+id;

       //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
       // 第1次查询redis,加锁前
       user = (User) redisTemplate.opsForValue().get(key);
       if(user == null) {
           //2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
           synchronized (UserService.class){
               //第2次查询redis,加锁后
               user = (User) redisTemplate.opsForValue().get(key);
               //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
               if (user == null) {
                   //4 查询mysql拿数据(mysql默认有数据)
                   user = userMapper.selectByPrimaryKey(id);
                   if (user == null) {
                       return null;
                  }else{
                       //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                       redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                  }
              }
          }
      }
       return user;
  }

}

 

数据库和缓存一致性的几种更新策略

目的:达到最终一致性

给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。

我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准。

上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,不是100%绝对正确,不保证绝对适配全部情况。

可以停机的情况:挂牌报错,凌晨升级,温馨提示,服务降级。单线程,这样重量级的数据操作最好不要多线程。

4种更新策略:

  • 先更新数据库,再更新缓存

    • 异常问题1:

      1. 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个。

      2. 先更新mysql修改为99成功,然后更新redis。

      3. 此时假设异常出现,更新redis失败了,这导致mysql里面的库存是99而redis里面的还是100 。

      4. 上述发生,会让数据库里面和缓存redis里面数据不一致,读到redis脏数据。

    • 异常问题2:

      1. 多线程情况下,AB两个线程有快有慢、有前有后并行。

      2. A update mysql 100 -> B update mysql 80 -> B update redis 80 -> A update redis 100

      3. 最终结果,mysql和redis数据不一致,o(╥﹏╥)o,mysql80,redis100

  • 先更新缓存,再更新数据库

    • 不推荐,业务上一般把MySQL作为底单数据库,保证最后解释

    • 异常情况

      1. 多线程情况下,AB两个线程有快有慢、有前有后并行。

      2. A update redis 100 -> B update redis 80 -> B update mysql 80 -> A update mysql 100

      3. 最终结果,mysql和redis数据不一致,o(╥﹏╥)o,mysql100,redis80

  • 先删除缓存,再更新数据库

    • 异常问题

      1. 请求A进行写操作,删除redis缓存后,工作正在进行中,更新mysql......A还没有彻底更新完mysql,还没commit

      2. 请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)

      3. 请求B继续,去数据库查询得到了mysql中的旧值(A还没有更新完)

      4. 请求B将旧值写回redis缓存

      5. 请求A将新值写入mysql数据库

      6. 上述情况就会导致不一致的情形出现。

    • 解决方案

      采用延时双删策略

      image-20231224120503009

      加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程A再进行删除。所以,线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。

      这样一来,其他线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们把它叫做“延迟双删”。

      这个删除该休眠多久呢?

      线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间

      这个时间怎么确定呢?

      第一种方法:

      在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,

      以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。

      第二种方法:

      新启动一个后台监控程序,比如后面要讲解的WatchDog监控程序,会加时

    • 这种同步淘汰策略,吞吐量降低怎么办?

      image-20231224123035672

  • 先更新数据库,再删除缓存

    • 异常问题

      假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。

    • 业务指导思想

      微软云Cache-Aside pattern - Azure Architecture Center | Microsoft Learn

      阿里巴巴的canal也是类似的思想:上述的订阅binlog程序在MySQL有线程的中间件叫canal,可以完成订阅binlog日志的功能。

    • 解决方案

      1. 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。

      2. 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。

      3. 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试

      4. 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。

    • 类似经典的分布式事务问题,只有一个权威答案:最终一致性!

      • 流量充值,先发短信,实际充值可能滞后5分钟,可以接受。

      • 电商发货,短信下发,但是物流明天才更新。

 

总结

在大多数业务场景下,优先使用先更新数据库,再删除缓存的方案(先更库→后删存)。理由如下:

  1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满mysql。

  2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。

如果使用先更新数据库,再删除缓存的方案

如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,

但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性,请大家参考。

策略高并发多线程条件下问题现象解决方案
先删除redis缓存,再更新mysql 缓存删除成功但数据库更新失败 Java程序从数据库中读到旧值 再次更新数据库,重试
  缓存删除成功但数据库更新中......有并发读请求 并发请求从数据库读到旧值并回写到redis,导致后续都是从redis读取到旧值 延迟双删
先更新mysql,再删除redis缓存 数据库更新成功,但缓存删除失败 Java程序从redis中读到旧值 再次删除缓存,重试
  数据库更新成功但缓存删除中......有并发读请求 并发请求从缓存读到旧值 等待redis删除完成,这段时间有数据不一致,短暂存在。

标签:缓存,数据库,redis,更新,user,mysql,一致性,双写
From: https://www.cnblogs.com/chenyonghua/p/17925046.html

相关文章

  • 【flink番外篇】4、flink的sink(内置、mysql、kafka、redis、clickhouse、分布式缓存、
    文章目录Flink系列文章一、maven依赖二、Jdbc/mysql示例1、maven依赖2、实现1)、userbean2)、内部匿名类实现3)、lambda实现4)、普通继承RichSinkFunction实现5)、完整代码3、验证本文介绍了Flink将数据sink到mysql中,其实是通过jdbc来将数据sink到rmdb中,mysql是一个常见的数据库,故......
  • 后端架构师必知必会系列:高可用数据库与数据一致性
    作者:禅与计算机程序设计艺术1.背景介绍什么是数据库?数据库(Database)是一个建立在计算机存储设备上的文件,用来存储、组织、管理和保护敏感的数据,其中的数据包括结构化数据和非结构化数据。数据库通过控制数据访问权限、提供数据备份功能、实现数据共享、确保数据完整性等功能,从而帮助......
  • Spring 解决循环依赖为什么需要三级缓存,而不是两级缓存?
    ......
  • Redis7 数据双写一致性
    1、缓存双写一致性如果redis中有数据,需要和数据库中的值相同如果redis中无数据,数据库中的值要是最新值,且准备回写redis缓存细分1、只读缓存2、读写缓存2.1、同步直写策略写数据库后也同步写redis缓存,缓存和数据库中的数据一致对于读写缓存来说,要想保证缓存和数据库中的数据......
  • [群晖]DSM SSD Cache缓存寿命与S.M.A.R.T识别硬盘真伪
    群晖NASSSDCache缓存机制缓存技术不是群晖独有,但是群晖科技公司有其自己开发的缓存算法。这种缓存机制的主要目的是通过使用固态硬盘(SSD)的高速读写能力,来提高存储设备的数据处理速度和性能。具体来说,群晖SSDCache缓存机制的工作原理是,当存储设备接收到数据读写请求时,首先会检查......
  • Spring三级缓存和循环依赖
    2023年12月22日17:02:18今天咪宝想买迪士尼娃娃,但是我买不起,还得加油。 SpringBean注入方式有至少3种,1.构造方法注入2.set方法注入(@Autowired)3.prototype多例bean注入 构造器注入和prototype注入的循环依赖会直接报错,set方式注入循环依赖不会报错,spring使用3级缓存来......
  • 若依关闭页签不会销毁 keep-alive 缓存的组件
    问题场景使用keep-alive缓存的组件。离开该页签时,组件状态为inactive;点击该页签时,组件状态为active。点击按钮关闭该页签this.$tab.closePage(view),该组件被销毁。需求:在页面A中删除数据B,则之前点击数据B打开的页签C会被关闭(使用this.$tab.closePage(view))。该需求已......
  • 彻底清除Chrome、Firefox、Edge、Safari等浏览器的缓存文件
     浏览器的缓存,是存储在硬盘驱动器或手机/平板电脑存储中的网页集合。缓存包括你访问过的网页上包含的文本、图像和大多数其他媒体。拥有网页的本地副本可以在下次访问时快速加载,因为你的计算机或移动设备不必再次从互联网下载。然而,随着时间的推移,缓存可能会占用计算机存......
  • 微服务广播模式实践:维护内存数据的缓存一致性
    本文分享自华为云社区《微服务广播模式实践》,作者:张俭。微服务广播模式,指的是在微服务多实例部署的场景下,将消息广播到多个微服务实例的一种模式。广播模式,一般用来维护微服务的内存数据,根据数据类型的不同,有助于解决两类问题。通常广播模式会使用支持发布订阅的消息中间件实......
  • Guava自加载缓存LoadingCache使用指南
    第1章:引言大家好,我是小黑,今天我们来聊聊缓存。在Java世界里,高效的缓存机制对于提升应用性能、降低数据库负担至关重要。想象一下,如果每次数据请求都要跑到数据库里取,那服务器岂不是要累趴了?这时候,缓存就显得尤为重要了。那么,怎么实现一个既高效又好用的缓存呢?别急,咱们今天的主......