性能文章>解决缓存和数据库双写数据一致性问题>

解决缓存和数据库双写数据一致性问题原创

1年前
323314

缓存的作用

在公司刚起步,业务量比较小的时候。对于系统的读写请求,我们一般的做法都是直接操作数据库。但是随着业务体量的不断增长,用户请求增多,这时候只用数据库处理业务是不够的,存在着「性能问题」。业内通用做法就是引入「缓存」。

缓存可以提升性能,缓解数据库压力,但是使用缓存也会出现「缓存和数据库数据不一致」的问题。

如果数据不一致,就会导致应用在缓存中读取的不是最新的数据,这显然是不能接受的。在解决这个问题之前,我们首先要了解:「导致缓存和数据库双写不一致」的原因。

 

缓存和数据库双写不一致的原因

我们先来看看缓存和数据库一致性定义

  • 缓存中有数据,且和数据库数据一致;

  • 缓存中无数据,数据库数据是最新的。

 

那么不符合这两种情况就属于缓存和数据库不一致的问题了。

 

当客户端发送一个数据修改的请求,我们不仅要修改数据库,还要一并操作(修改/删除)缓存。对数据库和缓存的操作又存在一个顺序的问题:到底是先操作数据库还是先操作缓存

下面我们以客户端向 MySQL 中删改数据为例来分析数据不一致的情况。

先不考虑并发问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑异常情况。

此时应用既要修改数据库也要修改缓存(删除和修改的影响是类似的,方便起见,下面只描述修改操作)的数据。

这里有两种场景我们分别来看下:

  • 先修改缓存,再修改数据库

  • 先修改数据库,再修改缓存

 

我们假设应用先修改缓存,再修改数据库。如果缓存修改成功,但是数据库操作失败,那么,应用再访问数据时,缓存中的值是正确的,但是一旦缓存中「数据失效」或者「缓存宕机」,然后,应用再访问数据库,此时数据库中的值为旧值,应用就访问到旧值了。

如果我们先更新数据库,再更新缓存中的值,是不是就可以解决这个问题呢?我们继续分析。

如果应用先完成了数据库的更新,但是,在更新缓存时失败了。那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。

这个时候,如果有其他的并发请求来访问数据,按照正常的访问流程,就会先在缓存中查询,此时,就会读到旧值了。

好了,到这里,我们可以看到,在操作数据库和更新缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要「第二步的操作」失败了,就会导致客户端读取到旧值。

我们继续分析,除了「第二步操作失败」的问题,还有什么场景会影响数据一致性:并发问题

 

并发引发的一致性问题

这里列出来所有策略,并且对删除和修改操作分开讨论:

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

  2. 先更新缓存,后更新数据库

  3. 先更新数据库,后删除缓存

  4. 先删除缓存,后更新数据库

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

假设我们采用「先更新数据库,后更新缓存」的方案,并且在两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?

有线程 A 和线程 B 两个线程,需要更新「同一条」数据 x,可能会发生这样的场景:

  1. 线程 A 更新数据库(x = 1)

  2. 线程 B 更新数据库(x = 2)

  3. 线程 B 更新缓存(x = 2)

  4. 线程 A 更新缓存(x = 1)

 

最后我们发现,数据库中的 x 是 2,而缓存中是 1 。显然是不一致的。

另外这种场景一般是不推荐使用的。因为某些业务因素,最后写到缓存中的值并不是和数据库是一致的,可能需要一系列计算得出的,最后才把这个值写到缓存中;如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。

比如现在数据库中 x=1,此时我们有 10 个请求对其每次加 1 的操作。但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有10个请求对缓存进行更新,会有大量的冷数据产生。

至于「先更新缓存,后更新数据库」这种情况和上述问题的是一致的,我们就不在继续讨论。

不管是先修改缓存还是后修改缓存,这样不仅对缓存的利用率不高,还浪费了机器性能。所以此时我们需要考虑另外一种方案:删除缓存

 

先删除缓存,后更新数据库

假设有两个线程:线程A(更新 x ),线程B(读取 x )。可能会发生如下场景:

  1. 线程 A 先删除缓存中的 x ,然后去数据库进行更新操作;

  2. 线程B 此时来读取 x,发现数据不在缓存,查询数据库并补录到缓存中;

  3. 而此时线程 A 的事务还未提交。

 

 

这个时候「先删除缓存,后更新数据库」仍会产生数据库与缓存的不一致问题。

 

先更新数据库,后删除缓存

我们还用两个线程:线程 A(更新 x ),线程B(读取 x )举例。

  1. 线程 A 要把数据 x 的值从 1更新为 2,首先先成功更新了数据库;

  2. 线程 B 需要读取 x 的值,但线程 A 还没有把新的值更新到缓存中;

  3. 这个时候线程 B 读到的还是旧数据 1;

 

 

不过,这种情况发生的概率很小,线程 A 会很快删除缓存中值。这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

由此,我们可以采用这种方案,来尽量避免数据库和缓存在并发情况下的一致性问题。

下面,我们继续分析「第二步操作失败」,我们该如何处理?

如何保证「第二步操作失败」的双写一致?

前面我们分析到,无论是「更新缓存」还是「删除缓存」,只要第二步发生失败,那么就会导致数据库和缓存不一致。

 

这里的关键在于如何保证第二步执行成功

首先,介绍一种方法:「基于消息队列的重试机制」。

具体来说,就是把「第二步」(操作缓存,或者操作数据库)的请求暂存到队列中。通过消费队列来重新处理这些请求。

流程如下:

  1. 请求 A 先对数据库进行更新操作;

  2. 在对 Redis  进行删除操作的时候发现删除失败;

  3. 此时将 对 Redis 的删除操作 作为消息体发送到消息队列中;

  4. 系统接收到消息队列发送的消息,再次对 Redis 进行删除操作。

 

 

消息队列的两个特性满足了我们重试的需求:

  • 保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心);

  • 保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)。

 

引入队列带来的问题:

  • 业务代码造成大量的侵入,同时增加了维护成本;

  • 写队列时也会存在失败的问题。

 

对于这两个问题,第一个,我们在项目中一般都会用到消息队列,维护成本并没有新增很多。而且对于同时写队列和缓存都失败的概率还是很小的。

如果是实在不想在应用中使用队列重试的,目前也有比较流行的解决方案:订阅数据库变更日志,再操作缓存。我们对MySQL 数据库进行更新操作后,在binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 MySQL 数据库的 binlog 日志对缓存进行操作。

订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal

大概流程如下:

  1. 系统修改数据库,生成 binlog 日志;

  2. canal 订阅这个日志,获取具体的操作数据,投递给消息队列;

  3. 通过消息队列,删除缓存中的数据。

 

总结:推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来保证数据库和缓存一致性。

如何保证并发场景下的数据一致性

我们前面分析,在并发场景下,「先删除缓存,再更新数据库」,由于存在网络延迟等,可能会存在数据不一致问题。我再把上面的图贴过来。

 

 

这个问题的核心在于:缓存都被种了「旧值」。

解决这种问题,最有效的办法就是,把缓存删掉。但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略

 

 

在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。

之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。

但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
线程 A sleep 的时间,需要大于线程 B 读取数据再写入缓存的时间。这需要在实际业务运行时估算。

除此之外,其实还有一种场景也会出现不一致问题:如果数据库采用读写分离架构,主从同步之间也会有时间差,也可能会导致不一致:

  1. 线程 A 更新主库 x = 2(原值 x = 1);

  2. 线程 A 删除缓存;

  3. 线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 x = 1);

  4. 从库「同步」完成(主从库 x = 2);

  5. 线程 B 将「旧值」写入缓存(x = 1)。

 

最终缓存中的 x 是旧值 1,而主从库最终值是新值 2。发生了数据不一致问题。

针对该问题的解决办法是,对于线程 B 的这种查询操作,可以强制将其指向主库进行查询,也可以使用上述「延迟删除」策略解决。

采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。

所以实际使用中,还是建议采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。

总结

  1. 系统引入缓存提高应用性能问题

  2. 引入缓存后,需要考虑缓存和数据库双写一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」

  3. 不管哪种方案,只要第二步操作失败,都无法保证数据的一致性,针对这类问题,可以通过消息队列重试解决

  4. 「更新数据库 + 更新缓存」方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生,一般不建议使用

  5. 在「更新数据库+删除缓存」的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案

  6. 在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性

  7. 在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「强制读主库」或者「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。

点赞收藏
ShawnBlog

我是 Shawn 一 Java 后端开发。欢迎关注我的公众号「ShawnBlog」。

请先登录,查看1条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

一次 Rancher go 应用内存占用过高问题排查

一次 Rancher go 应用内存占用过高问题排查

实现定时任务的六种策略

实现定时任务的六种策略

4
1