总结

缓存的场景:缓存 量大但又不常变化的数据,比如详情,评论等。对于那些经常变化的数据,其实并不适合缓存,除非是统计等需要计算的数据,经常变动,统计耗时,也需要进行缓存。这种缓存更适合由后台定时进行计算然后进行缓存。

大多是场景下,选择:先更新数据库,再删缓存 就可以,要求数据有比较高的一致性可以选择延时双删策略;

先删除缓存,后更新数据库:可能会出现删除缓存成功,数据库更新失败,不是非常热点的数据问题不大,由读请求重新获取缓存即可;还有一种并发情况,写操作先删除缓存,读操作读取了旧数据写入缓存,写操作完成之后,更新数据库,会造成数据不一致问题。读请求比写请求要快,所以读取到的脏数据并写入缓存的几率不低。不推荐这种方式。

先更新数据库,然后删除缓存:可能会出现数据更新成功,删除缓存失败,造成数据库中的是新数据,缓存中的是旧数据;数据不一致问题。比上面的方法好,一般访问量的系统推荐这种方式,因为删除缓存失败的几率并不高

延时双删:先删除缓存,后更新数据库,异步延时一会再次删除缓存;解决了 写读写顺序 造成的数据不一致问题。只会出现短时间的数据不一致问题。延时双删同样适用于主从架构,睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。
异步删缓存失败了怎么办:重试机制

读操作:先读缓存,缓存中没有数据的话就去数据库读取,然后再存入缓存中,同时返回响应。

写操作:先更新数据库,后删除缓存

先更新数据库,然后删除缓存。读请求过来的时候,发现 Redis 中没有数据,就会去数据库里读取,然后写入缓存中。

为什么不更新缓存,而是删除缓存?

多个写操作执行顺序交叉问题

多个写操作执行顺序交叉,先到的写不一定先执行完,导致 最新的缓存数据被旧的写操作覆盖掉。出现数据不一致问题。且是大概率事件,需要加锁才能避免这种情况。

频繁更新浪费资源

你想想,如果修改库中的某个字段,一段时间内频繁进行更新。那么你修改多少次,缓存也跟着更新多少次。但是这个缓存数据在这段时间内也就被偶尔使用了几次。

缓存数据计算复杂

还有一种情况,如果这个缓存的数据计算成本比较高。比如为了一个数据,要通过多张表来计算才能得到结果。那么每修改一次,为了更新缓存还要再查询多张表来算一次,

缓存击穿问题

如果选择删除缓存,有读请求的时候在更新缓存,在缓存失效的时候,大量的读请求同时进来,发现key失效了,都去数据库读,也就是缓存击穿问题,怎么解决?
1 互斥锁方案,保证同一时间只有一个业务线程更新缓存。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
2 热点数据,写数据库操作的时候,不负责缓存的删除与更新。采用后台更新缓存的方式。如一分钟,每几秒更新一次热点数据。需要业务能接受短暂的数据不一致。
如果定时任务失败,那么过一小时后读取的还是旧数据,可以接受旧数据的话,是没问题的,如果不能的话,那需要重试检查机制,如给这个统计数据存入Redis中的时候增加一个更新时间字段,重试机制,每隔一个间隔检查这个更新时间和当前时间,是否大于一小时,大于的话就进行数据跟新。

问题:数据库更新成功,删除缓存失败,缓存中还是旧数据

虽然有可能会出现这种问题,但是删除缓存失败的可能性小
同理,先更新数据库,然后更新缓存;也存在更新缓存失败的可能性

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

问题:读写同时到来,写操作先删除了缓存,读操作更新了缓存,写操作事务完成更新数据库;此时就发送了缓存中的是旧数据,造成数据不一致问题

读请求比写请求要快,所以读取到的脏数据并写入缓存的几率不低。不推荐这种方式。
缓存与数据库一致性 - 图1

缓存延时双删策略:删除缓存-更新数据-删除缓存

写请求过来先把 Redis 缓存删掉,等数据库更新成功后,异步等待一段时间再次把缓存删掉。
这种方案只会出现短时间的脏数据。如果对数据一致性问题要求比较严格,又不想引入其它中间件如阿里开源的 canal和消息队列,可以使用这种方法;

缓存与数据库一致性 - 图2

参考:

(https://mp.weixin.qq.com/s/4JcMG9UpAgFqsI1SXaJA2A)

前言

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

项目源码在这里

https://github.com/qqxx6661/miaosha

正文

缓存热点数据

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

为何要使用缓存

拿出我之前三篇文章的项目代码来,在其中增加两个查询库存的接口 getStockByDB 和 getStockByCache,分别表示从数据库和缓存查询某商品的库存量。

随后我们用 JMeter 进行并发请求测试。(JMeter 的使用请参考我的第一篇秒杀系统文章

  1. /**
  2. * 查询库存:通过数据库查询库存
  3. * @param sid
  4. * @return
  5. */
  6. @RequestMapping("/getStockByDB/{sid}")
  7. @ResponseBody
  8. public String getStockByDB(@PathVariable int sid) {
  9. int count;
  10. try {
  11. count = stockService.getStockCountByDB(sid);
  12. } catch (Exception e) {
  13. LOGGER.error("查询库存失败:[{}]", e.getMessage());
  14. return "查询库存失败";
  15. }
  16. LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count);
  17. return String.format("商品Id: %d 剩余库存为:%d", sid, count);
  18. }
  19. /**
  20. * 查询库存:通过缓存查询库存
  21. * 缓存命中:返回库存
  22. * 缓存未命中:查询数据库写入缓存并返回
  23. * @param sid
  24. * @return
  25. */
  26. @RequestMapping("/getStockByCache/{sid}")
  27. @ResponseBody
  28. public String getStockByCache(@PathVariable int sid) {
  29. Integer count;
  30. try {
  31. count = stockService.getStockCountByCache(sid);
  32. if (count == null) {
  33. count = stockService.getStockCountByDB(sid);
  34. LOGGER.info("缓存未命中,查询数据库,并写入缓存");
  35. stockService.setStockCountToCache(sid, count);
  36. }
  37. } catch (Exception e) {
  38. LOGGER.error("查询库存失败:[{}]", e.getMessage());
  39. return "查询库存失败";
  40. }
  41. LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count);
  42. return String.format("商品Id: %d 剩余库存为:%d", sid, count);
  43. }

在设置为 10000 个并发请求的情况下,运行 JMeter,结果首先出现了大量的报错,10000 个请求中 98% 的请求都直接失败了。打开日志,报错如下:

缓存与数据库一致性 - 图3

原来是 SpringBoot 内置的 Tomcat 最大并发数搞的鬼,其默认值为 200,对于 10000 的并发,单机服务实在是力不从心。当然,你可以修改这里的并发数设置,但是你的小机器仍然可能会扛不住。

缓存与数据库一致性 - 图4

将其修改为如下配置后,我的小机器才在通过缓存拿库存的情况下,保证了 10000 个并发的 100% 返回请求:

  1. server.tomcat.max-threads=10000
  2. server.tomcat.max-connections=10000

不使用缓存的情况下,吞吐量为 668 个请求每秒,并且有 5% 的请求由于服务压力实在太大,没有返回库存数据:

缓存与数据库一致性 - 图5

使用缓存的情况下,吞吐量为 2177 个请求每秒:

缓存与数据库一致性 - 图6

在这种 “不严谨” 的对比下,有缓存对于一台单机,性能提升了 3 倍多,如果在多台机器,更多并发的情况下,由于数据库有了更大的压力,缓存的性能优势应该会更加明显。

测完了这个小实验,我看了眼我挂着 Mysql 的小水管腾讯云服务器,生怕他被这么高流量搞挂。这种突发的流量,指不定会被检测为异常攻击流量呢~

缓存与数据库一致性 - 图7

我用的是腾讯云服务器 1C4G2M,活动买的,很便宜。

缓存与数据库一致性 - 图8

哪类数据适合缓存

缓存量大但又不常变化的数据,比如详情,评论等。对于那些经常变化的数据,其实并不适合缓存,一方面会增加系统的复杂性(缓存的更新,缓存脏数据),另一方面也给系统带来一定的不稳定性(缓存系统的维护)。

「但一些极端情况下,你需要将一些会变动的数据进行缓存,比如想要页面显示准实时的库存数,或者其他一些特殊业务场景。这时候你需要保证缓存不能(一直)有脏数据,这就需要再深入讨论一下。」

缓存的利与弊

我们到底该不该上缓存的,这其实也是个 trade-off 的问题。

上缓存的优点:

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

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

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

「本文想要重点讨论的,就是缓存和数据库的一致性问题,客观且往下看。」

缓存和数据库双写一致性

说了这么多缓存的必要性,那么使用缓存是不是就是一个很简单的事情了呢,我之前也一直是这么觉得的,直到遇到了需要缓存与数据库保持强一致的场景,才知道让数据库数据和缓存数据保持一致性是一门很高深的学问。

从远古的硬件缓存,操作系统缓存开始,缓存就是一门独特的学问。这个问题也被业界探讨了非常久,争论至今。我翻阅了很多资料,发现其实这是一个权衡的问题。值得好好讲讲。

不使用更新缓存而是删除缓存

「大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。」

《分布式之数据库和缓存双写一致性方案解析》孤独烟:

「原因一:线程安全角度」 同时有请求 A 和请求 B 进行更新操作,那么会出现 (1)线程 A 更新了数据库 (2)线程 B 更新了数据库 (3)线程 B 更新了缓存 (4)线程 A 更新了缓存 这就出现请求 A 更新缓存应该比请求 B 更新缓存早才对,但是因为网络等原因,B 却比 A 更早更新了缓存。这就导致了脏数据,因此不考虑。 「原因二:业务场景角度」 有如下两点: (1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。 (2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。 ❞

「其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次 cache miss,建议作为通用的处理方式。」

先删除缓存,还是先操作数据库?

孤独烟老师《分布式之数据库和缓存双写一致性方案解析》:

「先删缓存,再更新数据库」 该方案会导致请求数据不一致 同时有一个请求 A 进行更新操作,另一个请求 B 进行查询操作。那么会出现如下情形: (1)请求 A 进行写操作,删除缓存 (2)请求 B 查询发现缓存不存在 (3)请求 B 去数据库查询得到旧值 (4)请求 B 将旧值写入缓存 (5)请求 A 将新值写入数据库 上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。 ❞

「所以先删缓存,再更新数据库并不是一劳永逸的解决方案,再看看先更新数据库,再删缓存」

「先更新数据库,再删缓存」这种情况不存在并发问题么? 不是的。假设这会有两个请求,一个请求 A 做查询操作,一个请求 B 做更新操作,那么会有如下情形产生 (1)缓存刚好失效 (2)请求 A 查询数据库,得一个旧值 (3)请求 B 将新值写入数据库 (4)请求 B 删除缓存 (5)请求 A 将查到的旧值写入缓存 ok,如果发生上述情况,确实是会发生脏数据。 然而,发生这种情况的概率又有多少呢? 发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,「数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。」

「先更新数据库,再删缓存」依然会有问题,不过,问题出现的可能性会因为上面说的原因,变得比较低!

所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请「先更新数据库,再删缓存!」

我一定要数据库和缓存数据一致怎么办

「没有办法做到绝对的一致性,这是由 CAP 理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于 CAP 中的 AP。」

所以,我们得委曲求全,可以去做到 BASE 理论中说的「最终一致性」

❝ 最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性 ❞

大佬们给出了到达最终一致性的解决思路,主要是针对上面两种双写策略(先删缓存,再更新数据库 / 先更新数据库,再删缓存)导致的脏数据问题,进行相应的处理,来保证最终一致性。

延时双删

问:先删除缓存,再更新数据库中避免脏数据?

答案:采用延时双删策略。

上文我们提到,在先删除缓存,再更新数据库的情况下,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

「那么延时双删怎么解决这个问题呢?」

❝ (1)先淘汰缓存 (2)再写数据库(这两步和原来一样) (3)休眠 1 秒,再次淘汰缓存 这么做,可以将 1 秒内所造成的缓存脏数据,再次删除。 ❞

「那么,这个 1 秒怎么确定的,具体该休眠多久呢?」

❝ 针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。 ❞

「如果你用了 mysql 的读写分离架构怎么办?」

❝ ok,在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求 A 进行更新操作,另一个请求 B 进行查询操作。 (1)请求 A 进行写操作,删除缓存 (2)请求 A 将数据写入数据库了, (3)请求 B 查询缓存发现,缓存没有值 (4)请求 B 去从库查询,这时,还没有完成主从同步,因此查询到的是旧值 (5)请求 B 将旧值写入缓存 (6)数据库完成主从同步,从库变为新值 上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。 ❞

「采用这种同步淘汰策略,吞吐量降低怎么办?」

❝ ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。 ❞

「所以在先删除缓存,再更新数据库的情况下」,可以使用延时双删的策略,来保证脏数据只会存活一段时间,就会被准确的数据覆盖。

「在先更新数据库,再删缓存的情况下」,缓存出现脏数据的情况虽然可能性极小,但也会出现。我们依然可以用延时双删策略,在请求 A 对缓存写入了脏的旧值之后,再次删除缓存。来保证去掉脏缓存。

删缓存失败了怎么办:重试机制

看似问题都已经解决了,但其实,还有一个问题没有考虑到,那就是删除缓存的操作,失败了怎么办?比如延时双删的时候,第二次缓存删除失败了,那不还是没有清除脏数据吗?

「解决方案就是再加上一个重试机制,保证删除缓存成功。」

参考孤独烟老师给的方案图:

「方案一:」

缓存与数据库一致性 - 图9

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

方案二:

缓存与数据库一致性 - 图10

❝ 流程如下图所示: (1)更新数据库数据 (2)数据库会将操作信息写入 binlog 日志当中 (3)订阅程序提取出所需要的数据以及 key (4)另起一段非业务代码,获得该信息 (5)尝试删除缓存操作,发现删除失败 (6)将这些信息发送至消息队列 (7)重新从消息队列中获得该数据,重试操作。 ❞

「而读取 binlog 的中间件,可以采用阿里开源的 canal」

好了,到这里我们已经把缓存双写一致性的思路彻底梳理了一遍,下面就是我对这几种思路徒手写的实战代码,方便有需要的朋友参考。

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

终于到了实战,我们在秒杀项目的代码上增加接口:先删除缓存,再更新数据库

OrderController 中新增:

  1. /**
  2. * 下单接口:先删除缓存,再更新数据库
  3. * @param sid
  4. * @return
  5. */
  6. @RequestMapping("/createOrderWithCacheV1/{sid}")
  7. @ResponseBody
  8. public String createOrderWithCacheV1(@PathVariable int sid) {
  9. int count = 0;
  10. try {
  11. // 删除库存缓存
  12. stockService.delStockCountCache(sid);
  13. // 完成扣库存下单事务
  14. orderService.createPessimisticOrder(sid);
  15. } catch (Exception e) {
  16. LOGGER.error("购买失败:[{}]", e.getMessage());
  17. return "购买失败,库存不足";
  18. }
  19. LOGGER.info("购买成功,剩余库存为: [{}]", count);
  20. return String.format("购买成功,剩余库存为:%d", count);
  21. }

stockService 中新增:

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

其他涉及的代码都在之前三篇文章中有介绍,并且可以直接去 Github 拿到项目源码,就不在这里重复贴了。

实战:先更新数据库,再删缓存

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

  1. /**
  2. * 下单接口:先更新数据库,再删缓存
  3. * @param sid
  4. * @return
  5. */
  6. @RequestMapping("/createOrderWithCacheV2/{sid}")
  7. @ResponseBody
  8. public String createOrderWithCacheV2(@PathVariable int sid) {
  9. int count = 0;
  10. try {
  11. // 完成扣库存下单事务
  12. orderService.createPessimisticOrder(sid);
  13. // 删除库存缓存
  14. stockService.delStockCountCache(sid);
  15. } catch (Exception e) {
  16. LOGGER.error("购买失败:[{}]", e.getMessage());
  17. return "购买失败,库存不足";
  18. }
  19. LOGGER.info("购买成功,剩余库存为: [{}]", count);
  20. return String.format("购买成功,剩余库存为:%d", count);
  21. }

实战:缓存延时双删

如何做延时双删呢,最好的方法是开设一个线程池,在线程中删除 key,而不是使用 Thread.sleep 进行等待,这样会阻塞用户的请求。

更新前先删除缓存,然后更新数据,再延时删除缓存。

OrderController 中新增接口:

  1. // 延时时间:预估读数据库数据业务逻辑的耗时,用来做缓存再删除
  2. private static final int DELAY_MILLSECONDS = 1000;
  3. /**
  4. * 下单接口:先删除缓存,再更新数据库,缓存延时双删
  5. * @param sid
  6. * @return
  7. */
  8. @RequestMapping("/createOrderWithCacheV3/{sid}")
  9. @ResponseBody
  10. public String createOrderWithCacheV3(@PathVariable int sid) {
  11. int count;
  12. try {
  13. // 删除库存缓存
  14. stockService.delStockCountCache(sid);
  15. // 完成扣库存下单事务
  16. count = orderService.createPessimisticOrder(sid);
  17. // 延时指定时间后再次删除缓存
  18. cachedThreadPool.execute(new delCacheByThread(sid));
  19. } catch (Exception e) {
  20. LOGGER.error("购买失败:[{}]", e.getMessage());
  21. return "购买失败,库存不足";
  22. }
  23. LOGGER.info("购买成功,剩余库存为: [{}]", count);
  24. return String.format("购买成功,剩余库存为:%d", count);
  25. }

OrderController 中新增线程池:

  1. // 延时双删线程池
  2. private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
  3. /**
  4. * 缓存再删除线程
  5. */
  6. private class delCacheByThread implements Runnable {
  7. private int sid;
  8. public delCacheByThread(int sid) {
  9. this.sid = sid;
  10. }
  11. public void run() {
  12. try {
  13. LOGGER.info("异步执行缓存再删除,商品id:[{}], 首先休眠:[{}] 毫秒", sid, DELAY_MILLSECONDS);
  14. Thread.sleep(DELAY_MILLSECONDS);
  15. stockService.delStockCountCache(sid);
  16. LOGGER.info("再次删除商品id:[{}] 缓存", sid);
  17. } catch (Exception e) {
  18. LOGGER.error("delCacheByThread执行出错", e);
  19. }
  20. }
  21. }

来试验一下,请求接口 createOrderWithCacheV3:

缓存与数据库一致性 - 图11

日志中,做到了两次删除:

缓存与数据库一致性 - 图12

实战:删除缓存重试机制

上文提到了,要解决删除失败的问题,需要用到消息队列,进行删除操作的重试。这里我们为了达到效果,接入了 RabbitMq,并且需要在接口中写发送消息,并且需要消费者常驻来消费消息。Spring 整合 RabbitMq 还是比较简单的,我把简单的整合代码也贴出来。

pom.xml 新增 RabbitMq 的依赖:

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-amqp</artifactId>
  4. </dependency>

写一个 RabbitMqConfig:

@Configuration  
public class RabbitMqConfig {  

    @Bean  
    public Queue delCacheQueue() {  
        return new Queue("delCache");  
    }  

}

添加一个消费者:

@Component  
@RabbitListener(queues = "delCache")  
public class DelCacheReceiver {  

    private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class);  

    @Autowired  
    private StockService stockService;  

    @RabbitHandler  
    public 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:

缓存与数据库一致性 - 图13

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

实战:读取 binlog 异步删除缓存

我们需要用到阿里开源的 canal 来读取 binlog 进行缓存的异步删除。
阿里开源MySQL中间件Canal快速入门

扩展阅读

更新缓存的的 Design Pattern 有四种:Cache aside, Read through, Write through, Write behind caching,这里有陈皓的总结文章可以进行学习。

https://coolshell.cn/articles/17416.html

小结

引用陈浩《缓存更新的套路》最后的总结语作为小结:

❝ 分布式系统里要么通过 2PC 或是 Paxos 协议保证一致性,要么就是拼命的降低并发时脏数据的概率 缓存系统适用的场景就是非强一致性的场景,所以它属于 CAP 中的 AP,BASE 理论。 异构数据库本来就没办法强一致,「只是尽可能减少时间窗口,达到最终一致性」。 还有别忘了设置过期时间,这是个兜底方案 ❞

结束语

本文总结了秒杀系统中关于缓存数据的思考和实现,并探讨了缓存数据库双写一致性问题。

「可以总结为如下几点:」

  • 对于读多写少的数据,请使用缓存。
  • 为了保持一致性,会导致系统吞吐量的下降。
  • 为了保持一致性,会导致业务代码逻辑复杂。
  • 缓存做不到绝对一致性,但可以做到最终一致性。
  • 对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。

参考

猜你喜欢

【面试】一口气说出 4 种 “附近的人” 实现方式

图解 TCP | 重传机制、滑动窗口、流量控制、拥塞控制

【秒杀系统】从零打造秒杀系统(一):防止超卖

【秒杀系统】零基础上手秒杀系统(二):令牌桶限流 + 再谈超卖

【秒杀系统】零基础上手秒杀系统(三):抢购接口隐藏 + 单用户限制频率