面试官常拷打:如何下保证MySQL数据库与Redis缓存数据一致性?

这题我会,一答漏洞百出。

大家好,我是皇子。

有时候感觉MySQL我们懂了,Redis我们懂了,但是面试的时候一直答不好,经常被难住,问题在哪呢?

答案是:面试官考的不是专项能力,而是多项技术结合应用能力。

就拿并发场景下如何保证MySQL与Redis缓存一致性?这个面试官常见的拷打考点举例。

对于读多写少并且要求高性能的业务逻辑,我们通常在应用服务器访问MySQL数据库的中间加上一层Redis缓存层,以提高数据的查询效率,减轻MySQL数据库的压力,避免在MySQL出现性能瓶颈。

图片

image-20240916023730395

该问题,如果在数据存储后,只读场景下是不会出现MySQL与Redis缓存的一致性问题的,所以真正需要考虑的是并发读写场景下的数据一致性问题。

如果我们不加分析,单独利用MySQL和Redis的知识进行回答并发场景下如何保证MySQL与Redis缓存一致性?很难把这个问题回答好,因为看起来很简单的方案实际上是漏洞百出的。

简单方案下的漏洞百出

我们先看下简单的更新数据库、删除缓存和更新缓存方案下,会出现什么问题?

图片

image-20240915220902509

更新缓存,再更新数据库

先说结论:不考虑

原因是更新缓存成功后,数据库可能更新失败,出现数据库为旧值,缓存为新值。导致后续的所有的读请求,在缓存未过期或缓存未重新正确更新的情况下,会一直保持了数据的完全不一致!并且当前数据库中的值为旧值,而业务数据的正确性应该以数据库的为准。

那么如果更新缓存成功后,数据库可能更新失败,我们重新更新缓存是不是可以了?

图片

image-20240916004707314

抛开需要重新更新缓存时,要单表或多表重新查询数据,再更新数据带来的性能问题,还可能期间有数据变更再次陷入脏数据的情况。实际上仍然还是会出现并发一致性问题。

只要缓存进行了更新,后续的读请求在更新数据库前更新数据库失败并准备更新缓存前,基本上都能命中缓存情况,而这时返回的数据都是未落库的脏数据。

图片

image-20240916004728685

更新数据库,再更新缓存

不考虑。

原因是当数据库更新成功后,缓存更新失败,出现数据库为最新值,缓存为旧值。导致后续的所有的读请求,在缓存未过期或缓存未重新正确更新的情况下,会一直保持了数据的完全不一致!

图片

image-20240916004758430

该方案就算在更新数据库、更新缓存都成功的情况下,还是会存在并发引发的一致性问题,如下图所示(点击图片查看大图):

图片

image-20240916005545173

可以看到在并发多写多读的场景下数据存在的不一致性问题。

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

不考虑,但是通过使用延时双删策略后可以考虑。

采用“先删除缓存,再更新数据库”的方案是一种常见的方法来尝试解决这个问题的策略。

这种方法逻辑较为简单,易于理解和实现,理论上删除旧缓存后,下次读取时将从数据库获取最新数据。

但在并发的极端情况下,删除缓存成功后,如果再有大量的并发请求进来,那么便会直接请求到数据库中,对数据库造成巨大的压力。而且此方案还是可能会发生数据不一致性问题。

图片

image-20240916022747289

通过上图发现在删除缓存后,如果有并发读请求1.1进来,那么查询缓存肯定是不存在,则去读取数据库,但因为此时更新数据库x=10的操作2.更新数据库还未完成,所以读取到的仍然是旧值x=5并设置缓存后,在2.更新数据库完成后,数据是新值10,而缓存是旧值,造成了数据不一致的问题。

对此我们可以先进行一波的小优化,那就是延时双删策略。即在更新数据库之后,先延迟等待一下(等待时间参考该读请求的响应时间+几十毫秒),再继续删除缓存。这样做的目的是确保读请求结束(已经在1.2读库中读取到了旧数据,后续会在该请求中更新缓存),写请求可以删除读请求造成的缓存脏数据,保证再删除缓存之后的所有读请求都能读到最新值。

图片

image-20240916022826860

可以看出此优化方案关键点在于等待多长时间后,再次删除缓存尤为重要,但是这个时间都是根据历史查询请求的响应时间判断的,实际情况会有浮动。这也导致如果等待的延时时间过短,则仍然会出现数据不一致的情况;等待延迟时间过长,则导致延迟期间出现数据不一致的时间变长。

另外延时双删策略还需要考虑如果再次删除缓存失败的情况如何处理?

因为删除失败将导致后续的所有的读请求,在缓存未过期或缓存未重新正确更新的情况下,会一直保持了数据的完全不一致!这个在下文的技术优化方案继续讨论。

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

比较推荐。

采用的“先更新数据库,再删除缓存”策略,跟“先删除缓存,再更新数据库”中我们进行延时双删策略的小优化基本一样,仍然需要考虑删除缓存失败的情况如何处理。

单纯从“先更新数据库,再删除缓存”和“先删除缓存,再更新数据库”对比起来。在大多数情况下,“先更新数据库,再删除缓存”被认为是一个更好的选择,原因如下:

  1. 数据的一致性:这种方法更倾向于保持数据的最终一致性,即使缓存删除失败,也能保证数据的一致性不会长期受损。

  2. 用户体验:在“先删除缓存,再更新数据库”的情况下,如果数据库更新失败,用户可能会一直看到旧数据,直到缓存过期。相比之下,“先更新数据库,再删除缓存”可以在某种程度上避免这种情况。

但该方案同样也会出现数据不一致性问题,如下图所示。

图片

image-20240916025434705

当数据库的数据被更新后,缓存也被删除。接下来的出现读请求3.1写请求3.2同时进来。

读请求先读了缓存发现缓存无命中,则查询数据库并在准备更新缓存时,3.2写请求已经完成了数据的更新和删除缓存的动作,之后3.1读请求才更新了缓存。最后导致了数据库中的值未新值,缓存中的值为旧值。

优化后方案

从上面的简单方案方案中,似乎没有一种方案真正能解决并发场景下MySQL数据与Redis缓存数据一致性的问题。

这里有个说明下,如果业务要求必须要满足强一致性,那么不管如何优化缓存策略,都无法满足,而最好的办法是不用缓存。

强一致性:它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大。

解决方案是读写串行化,而此方案会大大增加系统的处理效率,吞吐量也会大大降低。

另外在大型分布式系统中,其实分布式事务大多数情况都不会使用,因为维护成本太高了、复杂度也高。所以在分布式系统,我们一般都会推崇最终一致性,即这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态。

现在我们接着继续优化..

延迟双删策略+重试机制

从上面简单方案下的漏洞百出下的先删除缓存,再更新数据库中,我们可以看出来其实延迟双删策略,算是融合“先删除缓存,再更新数据库”和“先更新数据库,再删除缓存”的策略,可以解决大部分的数据一致性的业务逻辑处理问题。

但我们前面还遗留了一个待解决的问题:如果再次删除缓存失败的情况如何处理?

-----当然是补救去继续删除这个缓存Key了,而补救方法则是重试

重试机制可以在当前中启动新协程(Golang中属于用户态的轻量级线程)中进行重试;也可以放到消息队列中进行重试;还可以是先启动新协程重试3次,重试失败后继续放到消息队列中重试,如下图展示的是放到消息队列中进行重试。

新协程中进行重试需要注意的是使用的新上下文context.Background(),而不是当前请求的上下文

一般消息队列会支持高可靠性的队列,例如 RabbitMQ、Kafka 等。这些消息队列提供了非常强的消息传递、异步处理和持久化功能,可以有效地解决数据同步的问题。

图片

image-20240916152621424

此方案仍然存在一些需要,如:选择合适的延迟等待时间进行删除缓存;协程中重试删除缓存次数、间隔时间;消息队列中删除失败缓存失败后是否需要重试等。

读取binlog异步删除缓存

重试删除缓存机制还可以吧,就是会造成好多业务代码入侵。

其实,还可以这样优化:

  1. 通过Canal将binlog日志采集发送到MQ队列来异步淘汰key。

  2. 删除缓存的应用程序通过ACK手动机制确认处理这条更新消息,删除缓存,保证数据缓存一致性。

图片

image-20240916162051682

异步淘汰key相比于等新对比缓存数据并更新会简单一些,因为可能一份缓存数据涉及多张表的数据查询、聚合、排序等。

尽管该方案看起来也不错了,但是因为引入额外的组件(如Canal、消息队列)复杂性增加了也不少,需要维护和监控这些组件的运行状态,保证组件运行正常。

定时任务

在某些业务场景的需求下,也可以通过定时任务的方式进行 Redis 和 MySQL 的数据同步。

具体做法是通过定时任务从 Redis 中读取数据,然后跟 MySQL 中的数据进行比对,如果 Redis 中数据有变化,则进行同步。

图片

image-20240916155006045

这种方式虽然实现起来比较简单,但需要注意同步的时效性,如果时间间隔设置不当,可能会导致同步的数据丢失或者不准确。

双写一致性

在更新数据库的同时也更新缓存/删除缓存,即所谓的“双写”。

这样可以确保在数据库更新后,缓存中的数据也是最新的,从而减少数据不一致的时间窗口。

图片

image-20240916171033188

并发控制:在高并发场景下,多个请求同时对同一个数据进行更新时,如果没有妥善处理并发控制,可能会导致数据不一致的问题。所以这里引入了分布式锁和事务操作:

  • 使用分布式锁:在执行双写操作之前,获取一个分布式锁(如Zookeeper、Redis的SETNX命令等),确保同一时刻只有一个线程/进程能够执行双写操作。

  • 事务处理:对于支持事务的缓存系统(如Redis的MULTI/EXEC命令)和MySQL事务,可以将Redis缓存和MySQL更新操作放入事务中,确保要么全部成功,要么全部失败。

当然在“双写”的策略中,除了并发控制外,可以结合上面提到的重试、定时策略进行组合,以应对极端情况下的数据不一致性问题。

另外也可以处理失败的逻辑上加入告警机制,及时通知开发和运维人员。

最后

⚠️注意:每一种方案的背后都有利与弊,每一个方案细节都可能被面试官挖出来拷打,面试现场我们都应该沉着冷静。

面试官拷打的目的,抛开是面试官为了炫耀自己牛逼之外,更多的是希望看到应聘者看待问题的角度和解决问题的思路。

看完就算是优化后的方案,发现其实没有所谓的最优解,只有根据自己项目业务特点和技术条件的来选择合适的方案。

背后是业务复杂度、性能、可用资源以及对数据一致性的容忍度等维度的一个综合衡量。

觉得有用,欢迎点赞收藏关注

最后推荐一个程序员学习网站:https://itgogogo.cn 包含数据结构、操作系统、容器服务、分库分表、安全框架、API网关、消息队列、搜索引擎、缓存、注册中心、配置中心、设计模式等免费学习资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

皇子谈技术

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值