数据库和缓存(比如:redis)双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。 接下来我和大家一起来探讨一下
常见方案
通常情况下,我们使用缓存的主要目的是为了提升查询的性能。大多数情况下,我们是这样使用缓存的:
简单来说就是先查缓存,如果缓存中没有数据再去查数据库,同时将数据库中查出的结果更新同步至缓存,如果数据库中也没有,则返回空。 这种缓存用法表面上看着很合理,可是我们忽略了一些重要的场景
如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?
不更新缓存行不行?
答案是肯定不行,要不然本文也就没有存在的必要了。 如果长期不更新,只依赖于缓存的过期时间的话,那么用户可能在很长一段时间内使用的都是旧的数据,比如:如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么就会出现上面这个情况。
那么,我们该如何更新缓存呢? 目前有以下4种方案:
- 先写缓存,再写数据库
- 先写数据库,再写缓存
- 先删缓存,再写数据库
- 先写数据库,再删缓存
我们看看这些方案到底行不行的通
1.先写缓存,再写数据库
我们知道缓存快的主要原因是其 I/O的瓶颈,由于缓存直接读写内存,所以操作速度很快,但是直接读写内存的话,如果遇到缓存数据库宕机,就会导致写入到内存数据丢失,同时我们还会遇到当写入缓存成功之后,如果刚写完缓存,突然网络出现了异常,导致写数据库失败了,这种情况是缓存有,数据库没有,此时缓存中的数据就变成了脏数据。 我们必须要理解缓存的主要目的是暂存,也就是说把数据库的数据临时保存在内存,便于后续的查询,提升查询速度,但如果某条数据,在数据库中都不存在,那这条数据可以说是毫无意义。因此这种方案不可取。
2.先写数据库,再写缓存
这种方案可以避免假数据带来的问题,所谓假数据就是指数据库中没有,但是缓存中有的数据就是假数据。但是新的问题又产生了。
2.1 写缓存失败了
如果把写数据库和写缓存操作,放在同一个事务当中,当写缓存失败了,我们可以把写入数据库的数据进行回滚。 这种场景也可以接受,但是只能应用于并发量较小的业务场景,对接口性能要求不太高的系统,可以这么做。 但如果在高并发的业务场景中,写数据库和写缓存,都属于远程操作。为了防止出现大事务,造成的死锁问题,通常建议写数据库和写缓存不要放在同一个事务中。 如果同时写入缓存和数据库就会出现写数据库成功了,但写缓存失败了,数据库中已写入的数据不会回滚,导致数据库是新数据,而缓存是旧数据,两边数据不一致的情况。
2.2 高并发场景
假设在高并发场景下,针对同一个用户的同一条数据,有两个写数据请求:a和b,它们同时请求到业务系统,其中请求a获取的是旧数据,而请求b获取的是新数据,如下图所示:
我么就按照上图的流程走一遍:
- 1.请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
- 2.这时候请求b过来了,先写了数据库
- 3.接下来,请求b顺利写了缓存
- 4.过了一会,请求a卡顿结束,也写入了缓存
上面的过程就会出现一个很严重的情况:数据库中是新值,而数据库中是旧值。
而且这种方式会严重浪费系统资源。 为什么这么说呢?
如果写的缓存,并不是简单的数据内容,而是要经过非常复杂的计算得出的最终结果。这样每写一次缓存,都需要经过一次非常复杂的计算,不是非常浪费系统资源吗!!!
尤其是当我们遇到的业务场景是写多读少,在这类业务场景下,每一次写操作都需要写一次缓存,这样的话就有点得不偿失了。
3.先删缓存在写数据库
上面两种方案我们可以看到直接更新缓存的问题很多。 所以我们换一种思路:不去直接更新缓存,而改为删除缓存 但是删缓存也有两种方案:
3.1 先删缓存,在写数据库
大致流程如下:
这个流程有没有问题呢? 我们一起来分析一下 还是先讨论一下高并发下的问题
3.1.1 高并发下的问题
假设在高并发的场景中,同一个用户的同一条数据,有一个读数据请求c,还有另一个写数据请求d(一个更新操作),同时请求到业务系统。如下图所示:
上图流程如下:
- 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 请求c将数据库中的旧值,更新到缓存中。
- 此时,请求d卡顿结束,把新值写入数据库。
在这个过程当中,请求d的新值并没有被请求c写入缓存,同样会导致缓存和数据库的数据不一致的情况。
那么,这种场景的数据不一致问题,能否解决呢?
3.1.2 缓存双删
针对上面这种场景有一种很简单的处理办法,思路很简单: 当d请求写入成功之后,我们在将缓存重删一次。
这就是我们所说的缓存双删,即:
即在写数据库之前删除一次,写完数据库后,再删除一次。
该方案有个非常关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后。
有了缓存删除方案之后,我们在回顾一下高并发下的场景问题:
- 1.请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 2.这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 3.请求c将数据库中的旧值,更新到缓存中。
- 4.此时,请求d卡顿结束,把新值写入数据库。
- 5.一段时间之后,比如:500ms,请求d将缓存删除。
这样看确实解决了缓存不一致的问题,但是为什么我们非得等一会在删除缓存呢? 请求d卡顿结束,把新值写入数据库后,请求c将数据库中的旧值,更新到缓存中。 此时,如果请求d删除太快,在请求c将数据库中的旧值更新到缓存之前,就已经把缓存删除了,这次删除就没任何意义。我们必须要搞清楚,我们之所以要再删除一次缓存的原因是因为c请求导致缓存中更新了数据库中旧值,我们需要把这个旧值删除掉,所以必须要在请求c更新缓存之后,再删除缓存,才能把旧值及时删除了,删除删除太快,可能后面。
现在解决了一个问题之后,又遇到一个问题:如果第二次删除缓存时,删除失败了该怎么办呢? 由于下面的场景同样也会遇到这个问题,所以我单独拿出来讲解缓存删除失败的解决方案。
3.2 先写数据库,再删缓存
从前面得知,先删缓存,再写数据库,在并发的情况下,也可能会出现缓存和数据库的数据不一致的情况。 接下来,我们重点看看先写数据库,再删缓存的方案。 在高并发的场景中,有一个读数据请求,有一个写数据请求,更新过程如下:
- 请求e先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。
- 请求f查询缓存,发现缓存中有数据,直接返回该数据。
- 请求e删除缓存。
在这个过程中,只有请求f读了一次旧数据,后来旧数据被请求e及时删除了,看起来问题不大,但如果是读数据请求先过来呢?
- 请求f查询缓存,发现缓存中有数据,直接返回该数据。
- 请求e先写数据库。
- 请求e删除缓存。
这种情况有问题吗? 完全没问题!!! 但是呢,我们别忘了,我们的缓存如果设置了有效期,即缓存 自己失效了。
上面的流程大致如下:
- 缓存过期时间到了,自动失效。
- 请求f查询缓存,发缓存中没有数据,查询数据库的旧值,但由于网络原因卡顿了,没有来得及更新缓存。
- 请求e先写数据库,接着删除了缓存。
- 请求f更新旧值到缓存中。
当然这种情况发生的概率比较下,只有同时满足:
- 缓存刚好自动失效。
- 请求f从数据库查出旧值,更新缓存的耗时,比请求e写数据库,并且删除缓存的还长。
查询数据库的速度,一般比写数据库要快,更何况写完数据库,还要删除缓存。所以绝大多数情况下,写数据请求比读数据情况耗时更长。
我们先做个总结:
推荐大家使用先写数据库,再删缓存的方案,虽说不能100%避免数据不一致问题,但出现该问题的概率,相对于其他方案来说是最小的。
但是这个方案也会也到缓存删除失败的情况,解决缓存删除失败的情况
4.删缓存失败怎么办?
如果缓存删除失败了,也会导致缓存和数据库的数据不一致。 所以为了解决这个方案,我们加入一个重试机制。 在接口中如果更新了数据库成功了,但更新缓存失败了,可以立刻重试3次。如果其中有任何一次成功,则直接返回成功。如果3次都失败了,则写入数据库,准备后续再处理。
当然,如果你在接口中直接同步重试,该接口并发量比较高的时候,可能有点影响接口性能,这个我们不怕,可以改为异步。 异步重试方式有很多种:
- 1.每次都单独起一个线程,该线程专门做重试的工作。但如果在高并发的场景下,可能会创建太多的线程,导致系统OOM问题,不太建议使用。
- 2.将重试的任务交给线程池处理,但如果服务器重启,部分数据可能会丢失。
- 3.将重试数据写表,然后使用elastic-job等定时任务进行重试。
- 4.将重试的请求写入mq等消息中间件中,在mq的consumer中处理。
- 5.订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。
4.1 定时任务
我们创建一个重试表,表中有个字段记录重试次数,初始值为0,同时设置一个最大的重试次数,用一个定时任务异步的去读取重试表中的数据,然后去执行删除缓存操作,每删除一次,重试次数加1,如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则我们需要在重试表中记录一个失败的状态,等待后续进一步处理。 在高并发场景中,定时任务推荐使用elastic-job。相对于xxl-job等定时任务,它可以分片处理,提升处理速度。同时每片的间隔可以设置成:1,2,3,5,7秒等。
使用定时任务重试的话,有个缺点就是实时性没那么高,对于实时性要求特别高的业务场景,该方案不太适用。但是对于一般场景,还是可以用一用的。
但它有一个很大的优点,即数据是落库的,不会丢数据。
4.2 MQ
在高并发的业务场景中,mq(消息队列)是必不可少的技术之一。它不仅可以异步解耦,还能削峰填谷。对保证系统的稳定性是非常有意义的。
mq的生产者,生产了消息之后,通过指定的topic发送到mq服务器。然后mq的消费者,订阅该topic的消息,读取消息数据之后,做业务逻辑处理。
使用mq重试的具体方案如下:
当用户操作写完数据库,但删除缓存失败了,产生一条mq消息,发送给mq服务器。 mq消费者读取mq消息,重试5次删除缓存。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则写入死信队列中。 当然在该方案中,删除缓存可以完全走异步。即用户的写操作,在写完数据库之后,不用立刻删除一次缓存。而直接发送mq消息,到mq服务器,然后有mq消费者全权负责删除缓存的任务。
因为mq的实时性还是比较高的,因此改良后的方案也是一种不错的选择。
4.3 binlog
前面两种删除的重试方案 都有一定的侵入性:
- 在使用定时任务的方案中,需要在业务代码中增加额外逻辑,如果删除缓存失败,需要将数据写入重试表。
- 使用mq的方案中,如果删除缓存失败了,需要在业务代码中发送mq消息到mq服务器。
其实,还有一种更优雅的实现,即监听binlog,比如使用:canal等中间件。 具体方案如下:
- 在业务接口中写数据库之后,就不管了,直接返回成功。
- mysql服务器会自动把变更的数据写入binlog中。
- binlog订阅者获取变更的数据,然后删除缓存。
这套方案中业务接口确实简化了一些流程,只用关心数据库操作即可,而在binlog订阅者中做缓存删除工作。 但是呢,这种方案还会出现删除失败的情况,因为基于binlog实现的删除只会删除一次,所以我们最终还是需要依赖于基于定时任务或者mq的重试机制。 在binlog订阅者中如果删除缓存失败,则发送一条mq消息到mq服务器,在mq消费者中自动重试5次。如果有任意一次成功,则直接返回成功。如果重试5次后还是失败,则该消息自动被放入死信队列,后面可能需要人工介入。
|