数据库和缓存的一致性问题,看这一篇就够了

·  阅读 1057
数据库和缓存的一致性问题,看这一篇就够了

写在前面

在我们后端平时开发中,经常会讨论这样的问题:该如何保证缓存和数据库一致性呢。

相信有一大部分人,对这个问题是一知半解的,或者是有挺多疑惑:

  • 更新数据时,是要先更新数据库,再删缓存,还是先删缓存,然后再更新数据库呢?
  • 是否要考虑引入消息队列来保证数据的一致性呢?
  • 延迟双删是否可以用,用了又会有啥问题呢?
  • ......

接下来这篇文章会把以上问题讲清楚,先来看下大纲。

读写数据库1.png

为什么要引入缓存?

对于小公司,或者说对于每天请求量没多少的业务来说,引入缓存只会让系统更加复杂,简单来说就是没必要引入缓存,除非你就是想为了用缓存而引入缓存。简单的架构模型如下:

读写数据库.png

但是随着公司业务的增长,项目的请求量也随着上来了,此时如果还是用上面的简单架构支撑的话,那就会有性能问题了。

这个时候就可以引入缓存了,引入缓存可以提高性能,此时升级一版的架构如下:

读写数据库2.png

可以看到缓存用的中间件是 Redis,它不仅性能高,而且有着丰富而又简单的数据结构,没有用到特殊的数据类型都是可以满足滴。

缓存方案以及该怎么使用缓存

那么我们先来看一个相对简单而且比较直接的缓存方案:

  • 将数据库的数据全量刷到缓存,而且是不设置失效时间;
  • 然后写操作时,只更新数据库,缓存不更新;
  • 另外需要有一个定时任务,定时的把数据库的增量数据更新到缓存中。

读写数据库3.png

该方案的优点:

  • 所有请求都打在了缓存,不用查数据库;
  • 直接访问 Redis 性能非常高。

但是缺点也是很明显的:

  • 缓存的利用率比较低,不常用的数据一直保留在缓存中,占用内存;
  • 缓存和数据库数据可能会不一致,这个取决于定时任务刷新缓存的时间频率。

同样,上面这种方案比较适合业务量小,而且对数据一致性要求不是很高的业务场景。

那么如果是针对业务量大,对数据一致性有要求的场景呢?

什么情况下会出现数据一致性问题?

在讨论数据一致性的问题之前,不妨先来看下如何提高缓存利用率的问题。

前面也说到,把不常用的数据放到缓存里,会占用内存,降低缓存利用率,所以想要提高缓存的利用率,比较容易想到的方案就是:Redis 缓存中只保留最近访问比较多的数据,我们将这些数据称为“热数据”。具体实现如下:

  • 写数据依然是写到数据库;
  • 读请求先从缓存里读,如果读取的数据不在缓存,则从数据库读取,并将读取的数据刷到缓存;
  • 另外,刷到缓存中的数据,都需要设置失效时间。

读写数据库4.png

将缓存的数据设置了失效时间,这样缓存中如果不经常访问的数据,就会随着时间被过期淘汰掉,缓存剩下的都是经常被访问到的“热数据”,从而提高了缓存的利用率,其实就是 LRU 淘汰算法,可以看下我之前写过的一篇文章:LRU缓存淘汰算法你了解多少?

接下来再看数据一致性问题。

如果想要保证缓存和数据库的一致性,那前面说的定时任务刷新缓存的方法就不能用了。

也就是说,更新数据时,不仅仅要操作数据库,同时也要操作缓存。即修改一条数据,不仅要更新数据库,还要一起更新缓存。

有人可能会注意到,同样是更新数据,我是要先更新缓存,再更新数据库,还是先更新数据库,再更新缓存呢?先后顺序无非就两个:

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

那么选哪个方案优先呢?下面就分情况讨论。

我们先抛开并发带来的问题,而且在正常情况下,上面两种方案均可选,不管先更新数据还是先更新缓存,都是可以让两者的数据保持一致的,我们需要关心的就是异常情况。

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

先更新数据库,再更新缓存的情况下:如果数据库更新成功了,但是缓存更新失败,则数据库的数据是新的值,缓存中的数据还是旧数据。

接下来,有读请求进来,读缓存读到的是旧数据(缓存失效前),只直当缓存失效后,才会从数据库中读到最新值,然后重建缓存,此时缓存的数据才是最新的。

如果缓存失效前用户来读的数据,会发现前面修改的数据还没生效,一段时间后,数据才更新过来,这样会对业务有影响。

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

先更新缓存,在更新数据库的情况下:如果缓存更新成功了,但是数据库更新失败,则缓存的数据是新的值,数据库中的数据还是旧数据。

接下来,有读请求进来,虽然可以命中缓存,读到的是新的值,即正确的值,但是缓存一旦失效,就会从数据库中读取旧数据,然后重建缓存,这样缓存的数据也是旧的了。

此时用户又过来读数据,会发现之前修改的数据又变回旧的数据了,同样会对业务有影响。

综上两种方案所述:

无论先更新谁,但凡后者更新发生了异常,都会对业务造成一定的影响。那么该怎么解决这种问题呢?后面会继续分析,并给出相应的解决方案。

并发场景下引发的数据一致性问题

先来看下前提条件:使用“先更新数据库,再更新缓存”的方案,而且这两步更新操作都是成功的

在以上的前提下,如果存在并发的情况,又会是怎么样的呢?

先来看下面一个场景:

有两个线程,分别为 A 和 B,都需要更新同一条数据(假设更新数据 X),执行顺序如下:

  1. A 更新数据库,X = 1;
  2. B 更新数据库,X = 2;
  3. B 更新缓存,X = 2;
  4. A 更新缓存,X = 1;

根据执行的顺序,最后 X 在缓存中的值为 1,在数据库中的值为 2,可见,缓存和数据库中 X 的值是不一致的,是不符合预期的。

同样,使用“先更新缓存,再更新数据库”的方案,也会有类似的问题,就不再赘述了。

另外,如果每次修改数据,都要更新缓存的话,但是缓存中的数据又不一定会被马上来读取,还是上面提到的,会导致缓存中可能存放了很多不经常访问到的数据,占用了内存,缓存利用率也不高。

而且在一些情况下,写到缓存中的数据并不是从数据库中直接刷过来的,即不是一一对应的,有可能是查了数据库的数据,然后经过一些计算得出来的值,再更新到缓存中的。

由此可见,“更新数据库 + 更新缓存”的方案,不仅缓存利用率不高,还会降低性能,得不偿失。

所以,我们需要考虑使用另外一种方案:删除缓存

删除缓存能否做到一致性?

同样,删除缓存也会有对应以下的两种方案:

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

由上面的分析可以知道,如果第二步操作失败,都会导致数据不一致的情况。

这里不再赘述。

我们还是重点关注并发带来的问题,以及该如何处理。

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

同样以上面并发举例的场景(稍作修改):

假设 X 原值为 1,有线程 A 和 B。

  1. A 要更新数据(X = 2),先删除缓存;
  2. B 读缓存,发现不存在,从数据库中读取数据(X = 1);
  3. A 将新值(X = 2)写入数据库;
  4. B 将旧数据(X = 1)写入缓存;

根据以上执行顺序可知,最后缓存中 X 的值是 1(旧数据),在数据库中的值是 2(新值),数据不一致。

可见,“先删除缓存,后更新数据库”的方案,当发生「读+写」并发时,还是会存在数据不一致的情况。

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

同样有线程 A 和 B 并发操作:

  1. 缓存中不存在 X 数据,数据库中存在 X = 1;
  2. A 读取数据库,得到 X = 1;
  3. B 更新数据库 X = 2;
  4. B 删除缓存;
  5. A 将 X = 1(旧值) 写入缓存;

最后缓存中 X 的值是 1(旧值),数据库中的值是 2 (新值),数据不一致。

这种情况理论上来说是有可能发生的,但实际上其实发生的概率很低,因为这种情况是需要满足下面三个条件才会发生的:

  1. 首先缓存是已失效,即缓存不存在该条数据;
  2. 读和写请求该条数据一起并发过来;
  3. 更新数据库和删除缓存的时间(上面步骤 3 和 4),要比读数据库和写缓存的时间短(上面步骤 2 和 5);

根据多年的开发经验,条件 3 发生的概率其实是比较低的。

因为写数据库操作一般会先加锁,所以写数据库操作通常是要比读数据库操作的时间更长些。

综上所述,“先更新数据库,再删除缓存”的方案,在一定程度上是可以保证数据一致性的。

所以,在平时开发中,我们应该采用此种方案,来操作数据库和缓存。

到这里,并发带来的问题也就解决了,接下来我们继续看前面提到的问题: 第二步执行「失败」或异常,导致数据不一致的问题

如何确保更新数据库和删除缓存这两步操作都执行成功?

前面已经分析到:无论是更新缓存还是删除缓存,但凡第二步发生失败,就会导致数据库和缓存不一致的问题。

那么该如何解决此问题呢?

方案一:重试

首先想到的一个方案是:执行失败后,重试

重试的方案这里就不再赘述了。

方案二:异步重试

异步重试其实就是:把重试请求扔到「消息队列」中,然后由专门的消费者来重试,直到成功。

到这里,有些人可能又会注意到,写消息队列也有可能会失败的吧?而且,另外引入消息队列,这不仅增加系统的复杂性,而且增加了更多的维护成本,划得来吗?

这个问题问的好,只有不断的思考,提出问题,解决问题,才会有进步。

在回答上面那个问题前,我们先来思考这样的一个问题:如果不把重试的操作仍到消息队列,在执行失败的线程中一直重试,还没等重试执行成功,此时如果该进程「重启」了,那这次重试请求也就「丢失」了,那这条数据就确确实实的不一致了(再也没机会了)。

所以,这里我们把重试或第二步操作放到另一个服务中,这个服务用消息队列来进行重试操作。

再来复习下消息队列的特性:

  • 保证可靠性:写到队列中的消息,成功消费之前不会丢失,重启服务也没事;
  • 保证消息成功投递:下游从队列拉取消息消费,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合重试的场景);

至于写队列失败和消息队列的维护成本问题:

  • 写队列失败:操作缓存和写消息队列,同时失败的概率比较小;
  • 维护成本:消息队列组件比较成熟了,公司项目中一般也都会用到,谈不上维护成本。

如果确实不想在应用中去写消息队列,同时又可以保证一致性的方案还是有的:订阅数据库变更日志,再操作缓存

换句话来说,就是在服务中想要修改数据时,只需要修改数据库即可,无需再操作缓存。

操作缓存的操作就交给订阅数据库变更日志的中间件Canal

Canal 是阿里开源比较成熟的中间件,详细的我这里就不介绍了,有兴趣的可以自行谷歌。

架构模型如下:

读写数据库5.png

优点:

  • 不用考虑写消息队列失败情况:只要写 MySQL 数据库成功,Binlog 就会有;
  • 自动投递到下游队列:canal 会自动把数据库变更日志投递给下游的消息队列,只需配置好即可,可参考我之前写过的一篇文章:Canal 中间件同步 MySQL 数据到 ElasticSearch

当然 Canal 的高可用和稳定性还是需要维护的。

到这里,我们可以得出以下结论:

如果要保证数据库和缓存的一致性,推荐采用「先更新数据库,再删除缓存」方案,然后配合「消息队列」或「订阅变更日志」的方式来做

总结

根据上面讲的内容,可以总结如下几点:

  1. 在业务体量大的场景下,引入缓存可以提高性能;
  2. 加缓存后,要考虑缓存和数据库一致性的问题,参考方案:“更新数据库,再删除缓存”;
  3. 在“先更新数据库,再删除缓存”的方案下,为了保证两步都成功执行,可以配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式来保证数据一致性;

另外,分享一些心得:

  1. 很多时候性能和一致性不能同时满足,为了性能考虑,通常会采用「最终一致性」的方案;
  2. 缓存和数据库一致性问题重点关注:缓存利用率、并发、缓存 + 数据库一起成功问题;
  3. 在一些失败场景下如果要保证一致性,常见方法就是「重试」,同步重试会影响吞吐量,所以通常会采用异步重试的方案;

如果你还想看更多优质的技术文章,欢迎关注我的公众号「Go键盘侠」。

分类:
后端
标签:
分类:
后端
标签: