欢迎访问 生活随笔!

生活随笔

当前位置: 首页 > 运维知识 > windows >内容正文

windows

教你从0到1搭建秒杀系统-缓存与数据库双写一致

发布时间:2025/3/20 windows 51 豆豆
生活随笔 收集整理的这篇文章主要介绍了 教你从0到1搭建秒杀系统-缓存与数据库双写一致 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

本文是秒杀系统的第四篇,我们来讨论秒杀系统中缓存热点数据的问题,进一步延伸到数据库和缓存的双写一致性问题。

在秒杀实际的业务中,一定有很多需要做缓存的场景,比如售卖的商品,包括名称,详情等。访问量很大的数据,可以算是“热点”数据了,尤其是一些读取量远大于写入量的数据,更应该被缓存,而不应该让请求打到数据库上。

当然并不是所有的数据都需要进行缓存,那么一般哪些数据适合缓存呢?缓存量大但又不常变化的数据,比如详情,评论等适合缓存。对于那些经常变化的数据,其实并不适合缓存,一方面会增加系统的复杂性(缓存的更新,缓存脏数据),另一方面也给系统带来一定的不稳定性(缓存系统的维护)。上缓存之后,可以给我们带来一定的好处:

  • 能够缩短服务的响应时间,给用户带来更好的体验;
  • 能够增大系统的吞吐量,依然能够提升用户体验;
  • 减轻数据库的压力,防止高峰期数据库被压垮,导致整个线上服务OOM。

但是上了缓存,也会引入很多额外的问题:

  • 缓存有多种选型,是内存缓存,memcached还是redis,你是否都熟悉,如果不熟悉,无疑增加了维护的难度(本来是个纯洁的数据库系统);
  • 缓存系统也要考虑分布式,比如redis的分布式缓存还会有很多坑,无疑增加了系统的复杂性;
  • 在特殊场景下,如果对缓存的准确性有非常高的要求,就必须考虑缓存和数据库的一致性问题。

本文想要重点讨论的,就是缓存和数据库的一致性问题。

缓存和数据库双写一致性

在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作:

但是在更新缓存方面,对于更新完数据库,再更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库,其实大家存在很大的争议。从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。我们讨论主要讨论以下三种更新策略:

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

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

这套方案,大家是普遍反对的。为什么呢?我们从以下两个方面来进行说明。

线程安全

假设同时有请求A和请求B进行更新操作,那么会出现以下情况:

  • 线程A更新了数据库
  • 线程B更新了数据库
  • 线程B更新了缓存
  • 线程A更新了缓存
  • 请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

    业务场景
  • 如果是写数据库比较多,而读数据比较少的业务需求,采用这种方案就会导致数据压根还没读到,缓存就被频繁的更新,浪费性能;
  • 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存,那么每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
  • 删除缓存,再更新数据库

    假设同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

  • 请求A进行写操作,删除缓存
  • 请求B查询发现缓存不存在
  • 请求B去数据库查询得到旧值
  • 请求B将旧值写入缓存
  • 请求A将新值写入数据库
  • 上述情况会导致数据不一致的情形。如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。这种情况下我们就可以采用延时双删策略:先淘汰缓存,再写数据库最后再休眠1秒,再次淘汰缓存。当然这个休眠的时间,读者应该自行评估自己的项目的读数据业务逻辑的耗时,然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
    有的人就会想到,如果我使用的是mysql的读写分离架构怎么办?在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作:

  • 请求A进行写操作,删除缓存
  • 请求A将数据写入数据库了
  • 请求B查询缓存发现,缓存没有值
  • 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
  • 请求B将旧值写入缓存
  • 数据库完成主从同步,从库变为新值
  • 这种情况还是使用双删延时策略。只是睡眠时间修改为在主从同步的延时时间基础上,加几百ms。那如果采用这种同步淘汰策略,吞吐量降低怎么办?我们可以将第二次删除作为异步的,自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后再返回,加大吞吐量。那如果第二次删除删除失败怎么办?第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:

  • 请求A进行写操作,删除缓存
  • 请求B查询发现缓存不存在
  • 请求B去数据库查询得到旧值
  • 请求B将旧值写入缓存
  • 请求A将新值写入数据库
  • 请求A试图去删除请求B写入对缓存值,结果失败了。
  • 如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。那么如何解决呢?请你继续往下看。

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

    假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:

  • 缓存刚好失效
  • 请求A查询数据库,得一个旧值
  • 请求B将新值写入数据库
  • 请求B删除缓存
  • 请求A将查到的旧值写入缓存
  • 如果发生上述情况,确实是会发生脏数据。但是发生这样的情况的条件是这样的:步骤3的写数据库操作比步骤2的读数据库操作耗时更短,才有可能使得步骤4先于步骤5。但是实际上数据库的读操作的速度远快于写操作的,因此步骤3耗时比步骤2更短,这一情形很难出现。那如果真的出现了怎么办呢?首先,给缓存设有效时间是一种方案。其次,采用先删除缓存,再更新数据库策略里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。这样又回到上一个策略中遗留的问题:第二次删除缓存失败怎么办?提供一个保障的重试机制即可,这里给出两套方案。

    方案一


    如上图,我们简化一下步骤:

  • 更新数据库数据;
  • 缓存因为种种问题删除失败
  • 将需要删除的key发送至消息队列
  • 自己消费消息,获得需要删除的key
  • 继续重试删除操作,直到成功
  • 该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

    方案二


    如上图,我们简化一下步骤:

  • 更新数据库数据
  • 数据库会将操作信息写入binlog日志当中
  • 订阅程序提取出所需要的数据以及key
  • 另起一段非业务代码,获得该信息
  • 尝试删除缓存操作,发现删除失败
  • 将这些信息发送至消息队列
  • 重新从消息队列中获得该数据,重试操作。
  • 读取binlog的中间件,可以采用阿里开源的canal。到这里我们已经把缓存双写一致性的思路彻底梳理了一遍,下面对这几种思路在我们原来代码的基础上进行代码实战,方便有需要的朋友参考。

    秒杀实战

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

    我们在秒杀项目的代码上OrderController中增加接口:先删除缓存,再更新数据库:

    /*** 下单接口:先删除缓存,再更新数据库* @param sid* @return*/ @RequestMapping("/createOrderWithCacheV1/{sid}") @ResponseBody public String createOrderWithCacheV1(@PathVariable int sid) {int count = 0;try {// 删除库存缓存stockService.delStockCountCache(sid);// 完成扣库存下单事务orderService.createPessimisticOrder(sid);} catch (Exception e) {LOGGER.error("购买失败:[{}]", e.getMessage());return "购买失败,库存不足";}LOGGER.info("购买成功,剩余库存为: [{}]", count);return String.format("购买成功,剩余库存为:%d", count); }

    stockService中新增:

    @Override public void delStockCountCache(int id) {String hashKey = CacheKey.STOCK_COUNT.getKey() + "_" + id;stringRedisTemplate.delete(hashKey);LOGGER.info("删除商品id:[{}] 缓存", id); }

    先更新数据库,再删缓存

    如果是先更新数据库,再删缓存,那么代码只是在业务顺序上颠倒了一下:

    /*** 下单接口:先更新数据库,再删缓存* @param sid* @return*/ @RequestMapping("/createOrderWithCacheV2/{sid}") @ResponseBody public String createOrderWithCacheV2(@PathVariable int sid) {int count = 0;try {// 完成扣库存下单事务orderService.createPessimisticOrder(sid);// 删除库存缓存stockService.delStockCountCache(sid);} catch (Exception e) {LOGGER.error("购买失败:[{}]", e.getMessage());return "购买失败,库存不足";}LOGGER.info("购买成功,剩余库存为: [{}]", count);return String.format("购买成功,剩余库存为:%d", count); }

    缓存延时双删

    如何做延时双删呢,最好的方法是开设一个线程池,在线程中删除key。更新前先删除缓存,然后更新数据,再延时删除缓存。OrderController中新增接口:

    // 延时时间:预估读数据库数据业务逻辑的耗时,用来做缓存再删除 private static final int DELAY_MILLSECONDS = 1000;/*** 下单接口:先删除缓存,再更新数据库,缓存延时双删* @param sid* @return*/ @RequestMapping("/createOrderWithCacheV3/{sid}") @ResponseBody public String createOrderWithCacheV3(@PathVariable int sid) {int count;try {// 删除库存缓存stockService.delStockCountCache(sid);// 完成扣库存下单事务count = orderService.createPessimisticOrder(sid);// 延时指定时间后再次删除缓存cachedThreadPool.execute(new delCacheByThread(sid));} catch (Exception e) {LOGGER.error("购买失败:[{}]", e.getMessage());return "购买失败,库存不足";}LOGGER.info("购买成功,剩余库存为: [{}]", count);return String.format("购买成功,剩余库存为:%d", count); }

    OrderController中新增线程池:

    // 延时双删线程池 private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());/*** 缓存再删除线程*/ private class delCacheByThread implements Runnable {private int sid;public delCacheByThread(int sid) {this.sid = sid;}public void run() {try {LOGGER.info("异步执行缓存再删除,商品id:[{}], 首先休眠:[{}] 毫秒", sid, DELAY_MILLSECONDS);Thread.sleep(DELAY_MILLSECONDS);stockService.delStockCountCache(sid);LOGGER.info("再次删除商品id:[{}] 缓存", sid);} catch (Exception e) {LOGGER.error("delCacheByThread执行出错", e);}} }

    调用接口createOrderWithCacheV3

    删除缓存前库存为48

    删除缓存前库存为null,没有数据

    然后正常下单以后库存变为47,此时将缓存更新到redis中

    最后异步将缓存数据再次删除:

    的确是做了两次缓存删除:

    删除缓存重试机制

    以上删除有可能会失败。要解决删除失败的问题,需要用到消息队列,进行删除操作的重试。这里我们为了达到效果,接入了RabbitMq,并且需要在接口中写发送消息,并且需要消费者常驻来消费消息。
    首先在pom.xml新增RabbitMq的依赖:

    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId> </dependency>

    写一个RabbitMqConfig:

    @Configuration public class RabbitMqConfig {@Beanpublic Queue delCacheQueue() {return new Queue("delCache");} }

    添加一个消费者:

    @Component @RabbitListener(queues = "delCache") public class DelCacheReceiver {private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class);@Autowiredprivate StockService stockService;@RabbitHandlerpublic void process(String message) {LOGGER.info("DelCacheReceiver收到消息: " + message);LOGGER.info("DelCacheReceiver开始删除缓存: " + message);stockService.delStockCountCache(Integer.parseInt(message));} }

    OrderController中新增接口:

    /*** 下单接口:先更新数据库,再删缓存,删除缓存重试机制* @param sid* @return*/ @RequestMapping("/createOrderWithCacheV4/{sid}") @ResponseBody public String createOrderWithCacheV4(@PathVariable int sid) {int count;try {// 完成扣库存下单事务count = orderService.createPessimisticOrder(sid);// 删除库存缓存stockService.delStockCountCache(sid);// 延时指定时间后再次删除缓存// cachedThreadPool.execute(new delCacheByThread(sid));// 假设上述再次删除缓存没成功,通知消息队列进行删除缓存sendDelCache(String.valueOf(sid));} catch (Exception e) {LOGGER.error("购买失败:[{}]", e.getMessage());return "购买失败,库存不足";}LOGGER.info("购买成功,剩余库存为: [{}]", count);return String.format("购买成功,剩余库存为:%d", count); }

    调用接口createOrderWithCacheV4

    可以看到,我们先完成了下单,然后删除了缓存,并且假设延迟删除缓存失败了,发送给消息队列重试的消息,消息队列收到消息后再去删除缓存。

    读取binlog异步删除缓存

    这里我们使用阿里开源的canal来读取binlog进行缓存的异步删除。Canal用途很广,并且上手非常简单,我们在下一篇单独做一下介绍。

    猜你感兴趣
    教你从0到1搭建秒杀系统-防超卖
    教你从0到1搭建秒杀系统-限流
    教你从0到1搭建秒杀系统-抢购接口隐藏与单用户限制频率
    教你从0到1搭建秒杀系统-缓存与数据库双写一致
    教你从0到1搭建秒杀系统-Canal快速入门(番外篇)
    教你从0到1搭建秒杀系统-订单异步处理

    更多文章请点击:更多…

    参考文章:
    https://cloud.tencent.com/developer/article/1574827
    https://www.jianshu.com/p/2936a5c65e6b
    https://www.cnblogs.com/rjzheng/p/9041659.html
    https://www.cnblogs.com/codeon/p/8287563.html
    https://www.jianshu.com/p/0275ecca2438
    https://www.jianshu.com/p/dc1e5091a0d8
    https://coolshell.cn/articles/17416.html

    总结

    以上是生活随笔为你收集整理的教你从0到1搭建秒杀系统-缓存与数据库双写一致的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。