前言
通常,在高并发请求的情况下,我们的MySQL数据库会因为系统性能的瓶颈(磁盘IO速度慢,QPS限制低),造成响应速度变慢,严重的情况下,请求过多可能会导致生产环境的MySQL宕机,这个后果很严重。
为了避免上述问题的出现,开发者们会使用redis作为数据库的缓存来保护MySQL,这样,在客户端请求数据时,如果能在缓存中命中数据,那就查询缓存,不用在去查询数据库,从而减轻数据库的压力,提高服务器的性能。
不过由于数据存储在了两个地方,redis实例中和mysql实例中,开发者们需要面对缓存与数据库之间的同步问题。常见的挑战是如何确保缓存中的数据与数据库中的数据保持一致,尤其是在数据更新时。如果没有合适的策略,可能会导致缓存与数据库之间的数据不一致,进而影响系统的准确性和稳定性。
通用的缓存策略
通用的的缓存模式有三种
- Cache Aside(旁路缓存)
- Read/Write Through(读写穿透)
- Write Behind Caching(异步缓存写入)
介绍到的这三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式。
Cache Aside Pattern(旁路缓存模式)
现在介绍的这种是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景
在该模式下,由缓存调用者(即开发人员)自己维护数据库和缓存的一致性,即开发者需要自己在代码中加入管理缓存的代码:
- 读/查询时:从缓存中读取数据,读取到(命中)直接返回,没读取到则到数据库中查找读取,并写入缓存中。
- 写/更新时:直接更新数据库数据并删除缓存,待查询时再更新缓存。
旁路缓存的查询数据示意图
旁路缓存的更新数据示意图
Read/Write Through Pattern(读写穿透)
现在这种模式下,缓存不仅是数据的临时存储,还可以通过缓存来访问和写入数据库。缓存对数据库的读写操作是透明的,应用程序只与缓存进行交互,缓存负责和数据库之间的同步工作:
- 读/查询时:从缓存中读取数据,读取到(命中)直接返回,没读取到则先去数据库中查找读取,写入到缓存后响应
- 写/更新时:判断缓存中是否存在,不存在直接操作数据库,存在则更新缓存,同步更新数据库(缓存服务自己去做)
读写穿透的查询数据示意图
读写穿透的更新数据示意图
Write Behind Caching Pattern(异步缓存写入)
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache(缓存) 服务来负责 cache 和 db(database的缩写代表,数据库) 的读写。
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。
这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
一致性方案
介绍完通用的三种缓存模型后,咱们就要进入正题了,关于数据库数据和缓存数据的一致性问题。
根据对于缓存的处理方式(是删除还是更新)以及操作缓存和数据库的顺序的不同,一共可以有四种方案:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
我们依次来分析下
更新缓存
先更新缓存,再更新数据库
【写+写】并发请求:
先来看正常情况,,有两个线程,线程1和线程2,线程1将缓存和数据库数据都更新完成后,线程2再去更新,此时缓存和数据库数据都是2,保持数据一致。
【写+写】并发请求正常情况
异常情况是线程1先将缓存数据更新为1,在更新数据库前,线程2将缓存更新为2,紧接着把数据库也更新为2,然后线程1将数据库更新为1。
此时,缓存中的数据为2,但数据库中的数据为1,出现了缓存和数据库的数据不一致现象
【写+写】并发请求异常情况
【读+写】并发请求:
这种情况我就不画图了,简单给大家描述下,这种并发情况引发问题较小。
最理想的情况是,线程2在线程1将缓存和数据库全都更新完后再到缓存中读取到数据,这样缓存和数据库中的数据保持一致,但是对于用户来说,他们的得到的都是最新数据。
稍差的情况是,线程2在线程1更新完缓存后就读取了缓存中的数据,此时线程1还没有更新数据库,存在短时间的缓存和数据库数据不一致问题,但是等待数据库更新完成,数据就又一致了。此时,用户的得到的也是最新数据。
先更新数据库,再更新缓存
【写+写】并发请求:
正常情况是,线程1将数据库和缓存数据都更新完成后,线程2再去更新,此时数据库和缓存数据都是2,保持数据一致。
【写+写】并发请求正常情况
异常情况是线程1先将数据库数据更新为1,在更新缓存前,线程2将数据库更新为2,紧接着把缓存也更新为2,然后线程1将缓存更新为1。
此时,数据库中的数据为2,但缓存中的数据为1,出现了缓存和数据库的数据不一致现象
【写+写】并发请求异常情况
【读+写】并发请求:
正常情况是,线程2在线程1更新完数据库和缓存后,再去缓存中读取数据,命中返回,此时用户读取到的数据就是数据库中存储的最新数据,缓存和数据库一致。
【读+写】并发请求正常情况
【读+写】并发请求异常情况
异常情况说明:
- 线程2在线程1更新数据库前就读取了缓存中的旧数据
- 或者是线程2在线程1更新完数据库,还未更新缓存的时候读取到了缓存中的旧数据
- 这时返回给用户的数据和保存在数据库中的数据不同
总结
无论是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,这两个方案都存在并发问题,当两个请求并发更新同一条数据时(写写并发),可能会发生缓存和数据库中的数据不一致的现象。而读写并发时,前者方案可能会导致用户获取到的数据不是最新的,影响用户体验。
删除缓存
既然更新缓存的策略问题这么多,咱们就换一种,换成删除缓存的,并且这还有一个别的好处,那就是假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新,采用删除缓存方案也就是延迟更新,什么时候有人查询了,什么时候更新。
现在这种不更新缓存,而是删除缓存中的数据,然后读取数据时,发现缓存中不存在该数据后,再去数据库中读取,而后写入缓存中的策略,即使咱们上边介绍过的 Cache Aside 策略。
在删除方案中,我们只分析【读+写】并发请求的情况了,至于为什么不去分析【写+写】并发请求的情况,那是因为缓存都删掉了,一定会出现缓存和数据库数据不一致的问题的,但是当读请求发起后,读取数据库找到数据写入缓存后,就又一致了。
先删除缓存,再更新数据库
【读+写】并发请求:
【读+写】并发请求正常情况
【读+写】并发请求异常情况
异常情况说明:
- 线程1删除缓存后,还没来得及更新数据库,
- 此时线程2来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
- 然后线程1更新数据库,此时数据库是新数据,缓存是旧数据
由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高,即出现异常情况的概率很大。因此不推荐这种方案。
先更新数据库,再删除缓存
【读+写】并发请求:
【读+写】并发请求正常情况
【读+写】并发请求异常情况
异常情况说明:
- 线程1查询缓存未命中,于是去查询数据库,查询到旧数据
- 线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
- 线程1执行写入缓存的操作,写入旧数据
从上面的理论上分析,【先更新数据库,再删除缓存】也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。
因为缓存的写入通常要远远快于数据库的写入(写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低),所以在实际中发生请求2已经更新了数据库并且删除了缓存,请求 1 才更新完缓存的情况概率极为苛刻。
而一旦请求 1 早于请求 2删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,【先更新数据库+再删除缓存】的方案,是可以保证数据一致性的,而且为了确保万无一失,还给缓存数据加上了【过期时间】,要就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
综上:推荐使用【先更新数据库+再删除缓存】的方案
总结
无论是【先更新数据库,再删除缓存】,还是【先删除缓存,再更新数据库】,这两个方案在读写请求并发的情况下都存在问题,不过后者发生的概率很小,原因在于缓存的写入速度远远快于数据库的写入,所以我们在项目中最为推荐的也是这种方案。
同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。
扩展
缓存命中率
【先更新数据库,再删除缓存】的方案虽然保证了数据库和缓存的数据一致性,但是每次更新数的时候,都会删除缓存的数据(缓存中对应的更新了的那一条,不是全部),这样会对缓存的命中率带来影响。
所以如果我们的业务对于缓存命中率有很高的要求,我们可以采用【更新数据库+更新缓存】的方案,因为更新缓存并不会出现缓存未命中的情况。
不过关于更新缓存的两种方案,我们前面也分析过,因为两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作相互独立,而我们又没有对操作进行任何的并发控制,那么当两个线程并发更新的话,就会因为写入顺序的不同造成数据的不一致。
下面介绍两种手段解决这个问题:
- 在更新缓存之前加一个【分布式锁】,保证同一时间只有一个更新请求更新缓存,这样就不会产生并发问题了,当然,引入锁以后,对于写入性能就会产生影响。
- 在更新完缓存后,给缓存加上一个较短的【过期时间】,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对于业务还是能接受的。
延迟双删
针对【先删除缓存,再更新数据库】的方案,在【读+写】并发请求而造成缓存不一致的解决办法就是 延迟双删
伪代码如下
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)
加了个睡眠时间,主要是为了确保请求 A在睡眠的时候,请求B能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A睡眠完,再删除缓存。
所以,请求 A 的睡眠时间就需要大于请求 B「从数据库读取数据 +写入缓存」的时间。
但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。
因此,还是比较建议用「先更新数据库,再删除缓存」的方案。
优化
在【先更新数据库,再删除缓存】的方案中,这其实是两个操作,如果第二个操作(删除缓存)失败了,那么缓存中依旧存着旧数据。不过好在,我们设置了过期时间的兜底方案,如果没有这个,那么后续的请求读到的都是是存在缓存中的旧数据,这样问题就大了。
举个例子:
删除缓存操作成功
删除缓存操作失败
应用要把数据X的值从1更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除X的缓存,但是这个操作却失败了,这个时候数据库中X的新值为 2,Redis 中的 X的缓存值为 1,出现了数据库和缓存数据不一致的问题。
那么,后续有访问数据X的请求,会先在 Redis 中查询,因为缓存并没有删除,所以会缓存命中,但是读到的却是旧值 1。
其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。
所以新的问题来了,如何保证【先更新数据库,再删除缓存】的这两个操作都能执行成功呢?
两种办法:
- 消息队列重试机制
- 使用Canal订阅MySQL binlog,再操作缓存
消息队列重试机制
引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过一定次数,还是没有成功,我们就要向业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
这个优化方案的缺点就是,对代码入侵比较强,因为需要改造原本业务的代码。
使用Canal订阅MySQL binlog,再操作缓存
【先更新数据库,再删除缓存】的方案的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在binlog里。
于是我们就可以通过订阅 binog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送dump 请求,MySQL收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
Canal的工作原理
前面我们说到直接用消息队列重试机制方案的话,会对代码造成入侵,那么Canal方案就能很好的规避这个问题,因为它是直接订阅 binlog 日志的,和业务代码没有藕合关系,因此我们可以通过 Canal+ 消息队列的方案来保证数据缓存的一致性。
具体的做法是:将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消费者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性
这里有一个很关键的点,必须是删除缓存成功,再回 ack 机制给消息队列,否则可能会造成消息丢失的问题,比如消费服务从消息队列拿到事件之后,直接回了 ack,然后再执行删除缓存操作的话,如果删除缓存的操作还是失败了,那么因为提前给消息队列回 ack了,就没办重试了。
总结
所以,如果想保证【先更新数据库,再删除缓存】方案的第二个操作能成功执行,我们可以使用:
- 消息队列来重试缓存的删除,优点是保证缓存一致性的问题,缺点会对业务代码入侵
- 订阅 MySQL binog + 消息队列 +重试缓存的删除,优点是规避了代码入侵问题,也很好的保证缓存一致性的问题,缺点就是引入的组件比较多,对团队的运维能力比较有高要求。
这两个方法有一个共同的特点,那就是都采用异步操作缓存。