如何保证数据库和缓存双写一致性?

数据库和缓存双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。

一、简介

一般情况下,使用缓存都是为了提升查询的性能(redis 单机支持 10万 QPS),减轻DB访问压力。对于查询请求,引入缓存之后的流程通常如下:
如何保证数据库和缓存双写一致性?_第1张图片

  1. 用户请求到达服务器,首先去缓存查询。如果缓存命中,直接返回;缓存没命中,下一步;
  2. 去数据库查询,如果数据不存在,直接返回(是否缓存空值取决于实际业务);如果数据存在,更新缓存,返回结果。

但是,如果在高并发的情况下,某条记录在被放入缓存之后,又立马被更新了,此时需要跟着将缓存中的数据更新,目前有四种方案:

  • 先更新数据库,后更新缓存;
  • 先更新缓存,后更新数据库;
  • 先删除缓存,再更新数据库;
  • 先更新数据库,再删缓存。

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

如果一开始就先去更新数据库,更新成功之后,再去更新缓存;更新失败直接返回。
如何保证数据库和缓存双写一致性?_第2张图片

此种方案存在以下问题:

  1. 更新缓存的代价很高。如果此时有大量的写请求,但是读请求并不多,如果每次写请求都更新一下缓存,那么性能损耗是非常大的而且中间的很多次更新也是没有必要的;
  2. 如果此时有多个并发写请求,会有几率出现数据不一致的情况。
    暂时无法在文档外展示此内容

三、先更新缓存,后更新数据库

此种方案跟先更新数据库后更新缓存一样,会存在同样的问题。不仅如此,此种方案还有更加严重的问题:生产中,所有的核心数据一定是要入DB保存的,缓存中存放的数据都是能够接受一定程度的缓存不一致性的数据,如果缓存更新成功之后,更新数据库失败了,就会导致缓存与DB数据不一致,但是DB是没有保存真正的更改后的数据的,一旦缓存失效了,对应的数据也会丢失,此方案是一定不会被采用的。

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

在用户的写操作中,先执行删除缓存操作,再去更新数据库。
如何保证数据库和缓存双写一致性?_第3张图片

此种方案存在以下问题:

  1. 在高并发的场景中,同一个用户的同一条数据,有一个读数据请求,还有另一个写数据请求(一个更新操作),同时请求到业务系统。如下图所示:
    如何保证数据库和缓存双写一致性?_第4张图片

在上面的业务场景中,一个读请求,一个写请求。当写请求把缓存删了之后,读请求,可能把当时从数据库查询出来的旧值,写入缓存当中。为了解决这种情况导致的数据不一致,可以在写请求更新了DB之后,再次删除缓存,这就是缓存延迟双删。
如何保证数据库和缓存双写一致性?_第5张图片

缓存延迟双删关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后(这样才能保证将读请求设置的旧的缓存值干掉)。

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

写操作,先去更新DB,然后再删除缓存。
如何保证数据库和缓存双写一致性?_第6张图片

在高并发的场景中,有一个读请求,有一个写请求,更新过程如下:

  1. 写请求先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存;
  2. 读请求查询缓存,命中缓存,直接返回缓存中数据;
  3. 写请求删除缓存,后续读请求查询DB,设置缓存后返回数据。
    在这个过程中,只有第一次读请求读了一次旧数据,后来旧数据被写请求及时删除了,看起来问题不大。

另一种情况,读请求先到达服务器:

  1. 读请求查询缓存,命中缓存,直接返回缓存中数据;
  2. 写请求先写数据库,然后删除缓存。
    在这种情况下,也不会出现问题。

但是存在另一种情况,缓存自己过期失效:
如何保证数据库和缓存双写一致性?_第7张图片

但是上述情况出现的几率是很小的,需要同时满足以下两个条件:

  1. 缓存刚好到了过期时间,失效;
  2. 读请求从DB查询数据,更新缓存的耗时比写请求写DB和删除缓存的时间要长(查询数据库的速度,一般比写数据库要快,更何况写完数据库,还要删除缓存。所以绝大多数情况下,写数据请求比读数据情况耗时更长)。

在上面的业务场景中,当缓存刚好过期且读请求更新缓存耗时比写请求写DB和删除缓存的耗时更长,还是会出现缓存不一致的情况。为了解决这种情况导致的数据不一致,可以在写请求更新了DB,删除缓存之后,再次删除缓存(跟延迟双删一样,间隔一段时间后再次删除缓存)。

六、缓存删除失败如何处理?

不管是先更新数据库,再删除缓存,还是基于先删除缓存,再更新数据库改进来的延时双删,都存在一个问题:一旦缓存删除失败,DB和缓存是数据就会不一致。
解决缓存删除失败的方法很简单:添加重试机制。
在接口中如果更新了数据库成功了,但删除缓存失败了,可以立刻重试3次。如果其中有任何一次成功,则直接返回成功。如果3次都失败了,则写入数据库,准备后续再处理。
如果在接口中直接同步重试,该接口并发量比较高的时候,可能有点影响接口性能。推荐使用异步重试,异步重试的方式可以有很多种:

  1. 将重试的任务交给线程池处理,但是如果服务不采取优雅停服机制,线程池中的任务存在丢失的情况;
  2. 将重试数据写表,然后使用elastic-job等定时任务进行重试;
  3. 将重试的请求写入mq等消息中间件中,在mq的consumer中处理;
  4. 订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。

1、定时任务重试

当用户操作写完数据库,但删除缓存失败了,需要将数据写入重试表中。流程如下图所示:
如何保证数据库和缓存双写一致性?_第8张图片

在定时任务中,异步读取重试表中的数据。重试表需要记录一个重试次数字段,初始值为0。然后重试5次,不断删除缓存,每重试一次该字段值+1。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则需要在重试表中记录一个失败的状态,等待后续进一步处理。
如何保证数据库和缓存双写一致性?_第9张图片

使用定时任务重试的话,有个缺点就是实时性没那么高,对于实时性要求特别高的业务场景,该方案不太适用。但是对于一般场景,还是可以用一用的。但它有一个很大的优点,即数据是落库的,不会丢数据。

2、消息队列

使用消息队列实现缓存删除的方案如下:

  1. 当用户操作写完数据库,产生一条mq消息,发送给mq服务器;
  2. mq消费者读取mq消息,重试5次删除缓存。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则写入死信队列中。
    如何保证数据库和缓存双写一致性?_第10张图片

3、订阅binlog

无论是定时任务,还是mq(消息队列),做重试机制,对业务都有一定的侵入性:

  • 在使用定时任务的方案中,需要在业务代码中增加额外逻辑,如果删除缓存失败,需要将数据写入重试表;
  • 使用mq的方案中,如果删除缓存失败了,需要在业务代码中发送mq消息到mq服务器。

还有一种更优雅的实现,即监听binlog,比如使用:canal等中间件。具体流程如下:

  1. 在业务接口中更新数据库之后,直接返回成功;
  2. mysql服务器会自动把变更的数据写入binlog日志中;
  3. binlog订阅者获取变更的数据,然后删除缓存;
  4. 如果删除缓存失败,不断重试(推荐使用MQ),直到成功。
    如何保证数据库和缓存双写一致性?_第11张图片

你可能感兴趣的:(杂,java,redis,缓存,分布式)