数据库和缓存双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。
一、简介
一般情况下,使用缓存都是为了提升查询的性能(redis 单机支持 10万 QPS),减轻DB访问压力。对于查询请求,引入缓存之后的流程通常如下:
- 用户请求到达服务器,首先去缓存查询。如果缓存命中,直接返回;缓存没命中,下一步;
- 去数据库查询,如果数据不存在,直接返回(是否缓存空值取决于实际业务);如果数据存在,更新缓存,返回结果。
但是,如果在高并发的情况下,某条记录在被放入缓存之后,又立马被更新了,此时需要跟着将缓存中的数据更新,目前有四种方案:
- 先更新数据库,后更新缓存;
- 先更新缓存,后更新数据库;
- 先删除缓存,再更新数据库;
- 先更新数据库,再删缓存。
二、先更新数据库,后更新缓存
如果一开始就先去更新数据库,更新成功之后,再去更新缓存;更新失败直接返回。
此种方案存在以下问题:
- 更新缓存的代价很高。如果此时有大量的写请求,但是读请求并不多,如果每次写请求都更新一下缓存,那么性能损耗是非常大的而且中间的很多次更新也是没有必要的;
- 如果此时有多个并发写请求,会有几率出现数据不一致的情况。
暂时无法在文档外展示此内容
三、先更新缓存,后更新数据库
此种方案跟先更新数据库后更新缓存一样,会存在同样的问题。不仅如此,此种方案还有更加严重的问题:生产中,所有的核心数据一定是要入DB保存的,缓存中存放的数据都是能够接受一定程度的缓存不一致性的数据,如果缓存更新成功之后,更新数据库失败了,就会导致缓存与DB数据不一致,但是DB是没有保存真正的更改后的数据的,一旦缓存失效了,对应的数据也会丢失,此方案是一定不会被采用的。
四、先删除缓存,再更新数据库
在用户的写操作中,先执行删除缓存操作,再去更新数据库。
此种方案存在以下问题:
- 在高并发的场景中,同一个用户的同一条数据,有一个读数据请求,还有另一个写数据请求(一个更新操作),同时请求到业务系统。如下图所示:
在上面的业务场景中,一个读请求,一个写请求。当写请求把缓存删了之后,读请求,可能把当时从数据库查询出来的旧值,写入缓存当中。为了解决这种情况导致的数据不一致,可以在写请求更新了DB之后,再次删除缓存,这就是缓存延迟双删。
缓存延迟双删关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后(这样才能保证将读请求设置的旧的缓存值干掉)。
五、先更新数据库,再删缓存
写操作,先去更新DB,然后再删除缓存。
在高并发的场景中,有一个读请求,有一个写请求,更新过程如下:
- 写请求先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存;
- 读请求查询缓存,命中缓存,直接返回缓存中数据;
- 写请求删除缓存,后续读请求查询DB,设置缓存后返回数据。
在这个过程中,只有第一次读请求读了一次旧数据,后来旧数据被写请求及时删除了,看起来问题不大。
另一种情况,读请求先到达服务器:
- 读请求查询缓存,命中缓存,直接返回缓存中数据;
- 写请求先写数据库,然后删除缓存。
在这种情况下,也不会出现问题。
但是存在另一种情况,缓存自己过期失效:
但是上述情况出现的几率是很小的,需要同时满足以下两个条件:
- 缓存刚好到了过期时间,失效;
- 读请求从DB查询数据,更新缓存的耗时比写请求写DB和删除缓存的时间要长(查询数据库的速度,一般比写数据库要快,更何况写完数据库,还要删除缓存。所以绝大多数情况下,写数据请求比读数据情况耗时更长)。
在上面的业务场景中,当缓存刚好过期且读请求更新缓存耗时比写请求写DB和删除缓存的耗时更长,还是会出现缓存不一致的情况。为了解决这种情况导致的数据不一致,可以在写请求更新了DB,删除缓存之后,再次删除缓存(跟延迟双删一样,间隔一段时间后再次删除缓存)。
六、缓存删除失败如何处理?
不管是先更新数据库,再删除缓存,还是基于先删除缓存,再更新数据库改进来的延时双删,都存在一个问题:一旦缓存删除失败,DB和缓存是数据就会不一致。
解决缓存删除失败的方法很简单:添加重试机制。
在接口中如果更新了数据库成功了,但删除缓存失败了,可以立刻重试3次。如果其中有任何一次成功,则直接返回成功。如果3次都失败了,则写入数据库,准备后续再处理。
如果在接口中直接同步重试,该接口并发量比较高的时候,可能有点影响接口性能。推荐使用异步重试,异步重试的方式可以有很多种:
- 将重试的任务交给线程池处理,但是如果服务不采取优雅停服机制,线程池中的任务存在丢失的情况;
- 将重试数据写表,然后使用elastic-job等定时任务进行重试;
- 将重试的请求写入mq等消息中间件中,在mq的consumer中处理;
- 订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。
1、定时任务重试
当用户操作写完数据库,但删除缓存失败了,需要将数据写入重试表中。流程如下图所示:
在定时任务中,异步读取重试表中的数据。重试表需要记录一个重试次数字段,初始值为0。然后重试5次,不断删除缓存,每重试一次该字段值+1。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则需要在重试表中记录一个失败的状态,等待后续进一步处理。
使用定时任务重试的话,有个缺点就是实时性没那么高,对于实时性要求特别高的业务场景,该方案不太适用。但是对于一般场景,还是可以用一用的。但它有一个很大的优点,即数据是落库的,不会丢数据。
2、消息队列
使用消息队列实现缓存删除的方案如下:
- 当用户操作写完数据库,产生一条mq消息,发送给mq服务器;
- mq消费者读取mq消息,重试5次删除缓存。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则写入死信队列中。
3、订阅binlog
无论是定时任务,还是mq(消息队列),做重试机制,对业务都有一定的侵入性:
- 在使用定时任务的方案中,需要在业务代码中增加额外逻辑,如果删除缓存失败,需要将数据写入重试表;
- 使用mq的方案中,如果删除缓存失败了,需要在业务代码中发送mq消息到mq服务器。
还有一种更优雅的实现,即监听binlog,比如使用:canal等中间件。具体流程如下:
- 在业务接口中更新数据库之后,直接返回成功;
- mysql服务器会自动把变更的数据写入binlog日志中;
- binlog订阅者获取变更的数据,然后删除缓存;
- 如果删除缓存失败,不断重试(推荐使用MQ),直到成功。