数据库和缓存(redis)双写数据一致性问题再高并发的场景下,是一个很严重的问题,无论在工作中,还是面试,遇到的概率非常大,这里就聊一聊目前的常见解决方案以及最优方案。
常见方案
缓存的主要目的是为了提升查询的效率,下面是缓存使用的大概流程:
首先用户请求到达服务端,先查缓存有没有数据,如果能查到则直接返回
如果缓存中没数据,则去查询数据库
如果数据库中有数据,则将该数据放入缓存中,然后返回
如果数据库中没数据,则直接返回
乍一看,这种用法没什么问题,但是如果数据库中的某条数据,放入缓存后,立马被更新了,那么此时该如何更新缓存呢?
如果不更新缓存,那么在缓存的过期时间内,用户请求从缓存中获取的数据都是旧值,和数据库中的不一致,这就是个问题。
目前有如下几种缓存的更新方案:
- 先写缓存,再写数据库
- 先写数据库,再写缓存
- 先删缓存,再写数据库
- 先写数据库,再删缓存
接下来,针对这四种方案,详细分析一下。
先写缓存,再写数据库
这种方案有个不能接受的弊端:当某个请求的写操作访问时,刚写完缓存,突然网络出现异常,导致写数据库失败了
结果就是缓存更新了,而数据库没有,这样缓存中的数据就变成了脏数据。如果此时该用户的查询请求刚好读到了该数据,就会出现问题,因为数据在数据库中不存在。由于缓存的主要目的是为了把数据库中的临时数据保存在内存中,便于后续查询,但是如果某条数据在数据库中都不存在,那么缓存这条数据的意义就没有了。
所以这种方案是不可取的,实际工作用的不多。
先写数据库,再写缓存
用户的写操作,先写数据库,再写缓存,可以避免上面讲到的假数据问题,但是却带来了新的问题
写缓存失效了
如果把写数据库和写缓存操作放在同一个事务中,当写缓存失败,可以把写数据库操作回滚。并发量较小且对接口性能要求不高的系统可以这样操作,但是如果在高并发的场景下,为了防止出现大事务造成的死锁问题,通常写数据库操作和写缓存操作不要放在同一个事务中。也就是说在该方案中,如果写数据库成功了,但写缓存失败了,数据库中已写入的数据不会回滚。这样就会出现:数据库是新数据,缓存时旧数据,两边数据不一致的情况。
高并发下的问题
假设现在是高并发的场景,针对同一个用户的同一条数据,有两个写请求:a和b,它们同时请求服务器。其中请求a获取的是旧数据,而请求b获取的是新数据:
请求a先到,刚写完数据库,由于网络问题,卡了一下,还没来得及写缓存,此时请求b到了,先写了数据库,然后写了缓存,此时a卡顿结束,也写了缓存。
在这个过程中,请求b在缓存中的新数据,被请求a给覆盖了,且数据库存的是请求b的值,而缓存时请求a的值,两边又不一致了。
浪费系统资源
每个写操作都是写完数据库接着写缓存,如果写的缓存并不是简单的数据内容,而是要经过非常复杂的计算得出的最终结果,这样每写一次缓存都要经过一次非常复杂的计算,就很浪费系统资源。
还有一些特殊业务场景,写多读少。这类场景中,每个写操作都要写一遍缓存,有点得不偿失。
先删缓存,再写数据库
通过上面的内容可知,直接更新缓存的问题有很多。那么换一种思路,不去更新缓存,而是直接删除呢?
高并发下的问题
假设在高并发的场景中,同一个用户的同一条数据,有一个读数据请求c,还有另一个写数据请求d,同时请求到业务系统,如图所示:
请求d先到,把缓存删除了,由于网络原因,卡了一下,还没来得及写数据库,这时请求c也到了,先查缓存发现没数据,再查数据库,有数据,但是旧值。请求c将数据更新到缓存中,此时,请求d卡顿结束,把新值写入了数据库中。
在上面的过程中,请求d的新值并没有被请求c写入缓存,同样会导致缓存和数据库的数据都不一致。那么针对此类情况该如何处理?
缓存双删
在写数据库之前删除缓存一次,写完数据库后再删一次。该方案有个关键的地方是,第二次删除时需要间隔一段时间。那么流程就变成了:请求d先到,把缓存删了,但是卡住了,然后请求c到了,把数据库中的值更新到了缓存中,然后请求d卡顿结束,把新值写到了数据库,一段时间后,请求d将缓存中的数据删除。
为什么要隔一段时间呢?
如果请求d立即删除缓存,而请求c还没来得及将旧值更新到缓存中,就会导致删除没有意义。
先写数据库,再删缓存
依旧是高并发的场景,有一个读请求f和一个写请求e,更新过程:请求e先写数据库,但是卡住了,没来得及删,请求f查询缓存,然后返回。请求e删除缓存。这样看,没啥问题,那如果请求f先到呢?
请求f查询缓存,直接返回,请求e写数据库,再写缓存。这样看也没啥问题。
有一种特殊情况:缓存恰好失效。
缓存过期时间到了,自动失效。请求f查询缓存,没查到数据,于是去查数据库,更新缓存时卡住了,请求e写数据库,接着删除了缓存,此时请求f卡顿结束,更新了缓存。这样又出现两边不一致的情况了。
不过这种场景较少,且条件苛刻,相较于其他方案,该方案的业务影响是最小的。
缓存删除失败怎么办?
先写数据库再删缓存和缓存双删方案一样,都有个共同的风险点:缓存删除失败了,也会导致两边数据不一致。
所以需要添加重试机制。
在接口中如果更新数据库成功了,但是缓存更新失败了,可以立刻重试几次,如果其中有任何一次成功,那么就返回成功,如果都失败了,需要记录下来,后续处理。
下面介绍一下重试方案。
定时任务
当删除缓存失败后,将数据写入重试表中,使用定时任务异步读取表中的用户数据,重试表需要记录一个重试次数字段,当重试次数到达阈值时,需要记录重试失败,后续进一步处理。
这种方式有个缺点,就是实时性不高,对于实时性要求较高的业务场景,该方案并不适用。
消息队列
在高并发的场景中,消息队列是不可或缺的中间件之一。
Mq的生产者,生产了消息之后,通过指定的topic发送到了mq服务器,然后mq的消费者订阅该topic,读取消息数据之后,做业务逻辑的处理。
那么添加mq的处理方案为:
当用户操作写完数据库后,删除缓存失败了,产生一条mq消息,发送给mq服务器,消费者读取到这条消息后,开始重试,任意一次成功就返回成功,否则将该消息添加到死信队列中。
Mysql的binlog
无论是mq还是定时任务,对业务都有一定的侵入性,定时任务的方案中需要在业务代码中增加额外逻辑,mq的方案中需要在业务代码中发送mq消息,除了这两个方案以外,我们还可以使用canal中间件监听binlog日志。
在业务接口中写数据库之后,就直接返回成功,MySQL会将变更的数据写到binlog中,然后binlog订阅者获取变更的数据,并删除缓存。
这种方案在业务接口中只需要关心数据库操作,但是重删缓存还是会失败,所以在这里就可以使用上述两个方案了。既不会对业务场景造成侵入,也使得功能有一定的健壮性。