Redis缓存与数据库双写一致性

数据库和缓存(比如:redis)双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。今天这篇文章我会从浅入深,跟大家一起聊聊,数据库和缓存双写数据一致性问题常见的解决方案,这些方案中可能存在的坑,以及最优方案是什么。

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

在这里,我们讨论四种更新策略:

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

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

  1. 先删缓存,再更新数据库

  1. 先更新数据库,再删缓存

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

这种更新策略应该没有项目上使用的吧,存在的问题显而易见:

如上图所示,某一个用户的每一次写操作,如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。

结果是缓存更新成了最新数据,但数据库没有,这样缓存中的数据不就变成脏数据了?如果此时该用户的查询请求,正好读取到该数据,就会出现问题,因为该数据在数据库中根本不存在,这个问题非常严重。

我们都知道,缓存的主要目的是把数据库的数据临时保存在内存,便于后续的查询,提升查询速度。

但如果某条数据,在数据库中都不存在,你缓存这种 假数据 又有啥意义呢?

因此,先更新缓存,再更新数据库的方案是不可取的,在实际工作中用得不多。

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

这套方案,大家也是普遍反对的。主要有以下两个原因:

原因一:

从线程安全角度看,同时有请求A和请求B进行更新操作,那么会出现:

(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存

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

原因二:

从业务场景角度看,存在以下两个问题:

(1)如果是一个数据库写多读少的业务场景求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。

显然,删除缓存更为适合。接下来讨论的就是争议最大的,先删缓存,再更新数据库;还是先更新数据库,再删缓存的问题。

三、先删缓存,再更新数据库:

该方案会导致不一致的原因是:同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

(1)请求A进行写操作前,先删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库

这里如果又不采用给缓存设置过期时间策略,该数据永远都是脏数据!!!

解决方案:延时双删策略:

(1)先删除缓存
(2)再写数据库
(3)休眠一段时间(如一秒),再次删除缓存

这么做的目的是将休眠时间内产生的缓存脏数据再次删除(这个休眠时间需要具体根据项目的业务逻辑耗时指定)。

如果是 MySQL 的读写分离架构怎么办?

在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。

(1)请求A进行写操作,删除缓存;
(2)请求A将数据写入数据库了;
(3)请求B查询缓存发现,缓存没有值;
(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值;
(5)请求B将旧值写入缓存;
(6)数据库完成主从同步,从库变为新值;

上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

采用延时双删除策略,吞吐量降低怎么办?

可以另起一个线程异步执行第二次删除操作,这样写的请求就不用沉睡一段时间后再返回了,从而加大吞吐量。

接下来,还有一个问题:如果第二次删除缓存时,删除失败了该怎么办?

四、先更新数据库,再删缓存

这种策略并不是不存在并发问题,如果发生下述情况,还是会产生脏数据的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:

(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存

但这种情况还是比较少的,需要同时满足以下条件才可以:

1.缓存刚好自动失效。

2.请求A从数据库查出旧值,更新缓存的耗时,比请求B写数据库,并且删除缓存的时间还长。

我们都知道查询数据库的速度,一般比写数据库要快,更何况写完数据库,还要删除缓存。所以绝大多数情况下,写数据请求比读数据情况耗时更长。

由此可见,系统同时满足上述两个条件的概率非常小。

推荐大家使用先写数据库,再删缓存的方案,虽说不能100%避免数据不一致问题,但出现该问题的概率,相对于其他方案来说是最小的。

但在该方案中,如果删除缓存失败了该怎么办呢?

答:需要加重试机制。

在接口中如果更新了数据库成功了,但更新缓存失败了,可以立刻重试3次。如果其中有任何一次成功,则直接返回成功。如果3次都失败了,则写入数据库,准备后续再处理。

当然,如果你在接口中直接同步重试,该接口并发量比较高的时候,可能有点影响接口性能。

这时,就需要改成异步重试了。

异步重试方式有很多种,比如:

1.每次都单独起一个线程,该线程专门做重试的工作。但如果在高并发的场景下,可能会创建太多的线程,导致系统OOM问题,不太建议使用。

2.将重试的任务交给线程池处理,但如果服务器重启,部分数据可能会丢失。

3.将重试数据写表,然后使用elastic-job等定时任务进行重试。

4.将重试的请求写入mq等消息中间件中,在mq的consumer中处理。

然而,以上方法都有一个缺点,对业务线代码造成大量的侵入。于是有了方法5:

订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。

阿里已经有了现成的中间件canal,有兴趣可自行学习~

那同样的,即便使用了canel,一样会存在删除失败的问题,这就需要加上前面聊过的重试机制了。

如果canal的客户端(即订阅者)再次删除缓存失败,建议写入mq,让mq自动重试。

如下图所示:

猜你喜欢

转载自blog.csdn.net/cj_eryue/article/details/129737398