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

发布时间 2023-12-24 23:14:12作者: 小陈_winwah

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

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删除完成,这段时间有数据不一致,短暂存在。