You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

153 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 25 | 缓存异常(上):如何解决缓存和数据库的数据不一致问题?
你好,我是蒋德钧。
在实际应用Redis缓存时我们经常会遇到一些异常问题概括来说有4个方面缓存中的数据和数据库中的不一致缓存雪崩缓存击穿和缓存穿透。
只要我们使用Redis缓存就必然会面对缓存和数据库间的一致性保证问题这也算是Redis缓存应用中的“必答题”了。最重要的是如果数据不一致那么业务应用从缓存中读取的数据就不是最新数据这会导致严重的错误。比如说我们把电商商品的库存信息缓存在Redis中如果库存信息不对那么业务层下单操作就可能出错这当然是不能接受的。所以这节课我就重点和你聊聊这个问题。关于缓存雪崩、穿透和击穿等问题我会在下一节课向你介绍。
接下来,我们就来看看,缓存和数据库之间的数据不一致是怎么引起的。
## 缓存和数据库的数据不一致是如何发生的?
首先,我们得清楚“数据的一致性”具体是啥意思。其实,这里的“一致性”包含了两种情况:
* 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
* 缓存中本身没有数据,那么,数据库中的值必须是最新值。
不符合这两种情况的,就属于缓存和数据库的数据不一致问题了。不过,当缓存的读写模式不同时,缓存数据不一致的发生情况不一样,我们的应对方法也会有所不同,所以,我们先按照缓存读写模式,来分别了解下不同模式下的缓存不一致情况。我在[第23讲](https://time.geekbang.org/column/article/293929)中讲过,根据是否接收写请求,我们可以把缓存分成读写缓存和只读缓存。
对于读写缓存来说,如果要对数据进行增删改,就需要在缓存中进行,同时还要根据采取的写回策略,决定是否同步写回到数据库中。
* 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;
* 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。
所以,对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。不过,需要注意的是,如果采用这种策略,就需要同时更新缓存和数据库。所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,我们就无法实现同步直写。
当然,在有些场景下,我们对数据一致性的要求可能不是那么高,比如说缓存的是电商商品的非关键属性或者短视频的创建或修改时间等,那么,我们可以使用异步写回策略。
下面我们再来说说只读缓存。对于只读缓存来说,如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。
接下来我以Tomcat向MySQL中写入和删改数据为例来给你解释一下数据的增删改操作具体是如何进行的如下图所示
![](https://static001.geekbang.org/resource/image/15/dc/15ae0147459ecc46436f35a0f3e5yydc.jpg)
从图中可以看到Tomcat上运行的应用无论是新增Insert操作、修改Update操作、还是删除Delete操作数据X都会直接在数据库中增改删。当然如果应用执行的是修改或删除操作还会删除缓存的数据X。
那么,这个过程中会不会出现数据不一致的情况呢?考虑到新增数据和删改数据的情况不一样,所以我们分开来看。
**1.新增数据**
如果是新增数据数据会直接写到数据库中不用对缓存做任何操作此时缓存中本身就没有新增数据而数据库中是最新值这种情况符合我们刚刚所说的一致性的第2种情况所以此时缓存和数据库的数据是一致的。
**2.删改数据**
如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,也就是说,要不都完成,要不都没完成,此时,就会出现数据不一致问题了。这个问题比较复杂,我们来分析一下。
我们假设应用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。
我来举个例子说明一下,可以先看看下面的图片。
![](https://static001.geekbang.org/resource/image/b3/ae/b305a6355c9da145e4d1f86d23f4f0ae.jpg)
应用要把数据X的值从10更新为3先在Redis缓存中删除了X的缓存值但是更新数据库却失败了。如果此时有其他并发的请求访问X会发现Redis中缓存缺失紧接着请求就会访问数据库读到的却是旧值10。
你可能会问,如果我们先更新数据库,再删除缓存中的值,是不是就可以解决这个问题呢?我们再来分析下。
如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。
我还是借助一个例子来说明一下。
![](https://static001.geekbang.org/resource/image/76/77/767b4b2b1bafffd9a4b6368f05930a77.jpg)
应用要把数据X的值从10更新为3先成功更新了数据库然后在Redis缓存中删除X的缓存但是这个操作却失败了这个时候数据库中X的新值为3Redis中的X的缓存值为10这肯定是不一致的。如果刚好此时有其他客户端也发送请求访问X会先在Redis中查询该客户端会发现缓存命中但是读到的却是旧值10。
好了,到这里,我们可以看到,在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值。我画了下面这张表,总结了刚刚所说的这两种情况。
![](https://static001.geekbang.org/resource/image/2c/ac/2c376b536aff9d14d8606499f401cdac.jpg)
问题发生的原因我们知道了,那该怎么解决呢?
## 如何解决数据不一致问题?
首先,我给你介绍一种方法:重试机制。
具体来说可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中例如使用Kafka消息队列。当应用没有能够成功地删除缓存值或者是更新数据库值时可以从消息队列中重新读取这些值然后再次进行删除或更新。
如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
下图显示了先更新数据库,再删除缓存值时,如果缓存删除失败,再次重试后删除成功的情况,你可以看下。
![](https://static001.geekbang.org/resource/image/74/ab/74a66b9ce185d7c5b53986fc522dfcab.jpg)
刚刚说的是在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作第一次执行时都没有失败,当有大量并发请求时,应用还是有可能读到不一致的数据。
同样,我们按照不同的删除和更新顺序,分成两种情况来看。在这两种情况下,我们的解决方法也有所不同。
**情况一:先删除缓存,再更新数据库。**
假设线程A删除缓存值后还没有来得及更新数据库比如说有网络延迟线程B就开始读取数据了那么这个时候线程B会发现缓存缺失就只能去数据库读取。这会带来两个问题
1. 线程B读取到了旧值
2. 线程B是在缓存缺失的情况下读取的数据库所以它还会把旧值写入缓存这可能会导致其他线程从缓存中读到旧值。
等到线程B从数据库读取完数据、更新了缓存后线程A才开始更新数据库此时缓存中的数据是旧值而数据库中的是最新值两者就不一致了。
我用一张表来汇总下这种情况。
![](https://static001.geekbang.org/resource/image/85/12/857c2b5449d9a04de6fe93yy1e355c12.jpg)
这该怎么办呢?我来给你提供一种解决方案。
**在线程A更新完数据库值以后我们可以让它先sleep一小段时间再进行一次缓存删除操作。**
之所以要加上sleep的这段时间就是为了让线程B能够先从数据库读取数据再把缺失的数据写入缓存然后线程A再进行删除。所以线程A sleep的时间就需要大于线程B读取数据再写入缓存的时间。这个时间怎么确定呢建议你在业务程序运行的时候统计下线程读数据和写缓存的操作时间以此为基础来进行估算。
这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。
下面的这段伪代码就是“延迟双删”方案的示例,你可以看下。
```
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)
```
**情况二:先更新数据库值,再删除缓存值。**
如果线程A删除了数据库中的值但还没来得及删除缓存值线程B就开始读取数据了那么此时线程B查询缓存时发现缓存命中就会直接从缓存中读取旧值。不过在这种情况下如果其他线程并发读缓存的请求不多那么就不会有很多请求读取到旧值。而且线程A一般也会很快删除缓存值这样一来其他线程再次读取时就会发生缓存缺失进而从数据库中读取最新值。所以这种情况对业务的影响较小。
我再画一张表,带你总结下先更新数据库、再删除缓存值的情况。
![](https://static001.geekbang.org/resource/image/a1/0b/a1c66ee114yyc9f37f2a35f21b46010b.jpg)
好了,到这里,我们了解到了,缓存和数据库的数据不一致一般是由两个原因导致的,我给你提供了相应的解决方案。
* 删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
* 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。
## 小结
在这节课我们学习了在使用Redis缓存时最常遇见的一个问题也就是缓存和数据库不一致的问题。针对这个问题我们可以分成读写缓存和只读缓存两种情况进行分析。
对于读写缓存来说,如果我们采用同步写回策略,那么可以保证缓存和数据库中的数据一致。只读缓存的情况比较复杂,我总结了一张表,以便于你更加清晰地了解数据不一致的问题原因、现象和应对方案。
![](https://static001.geekbang.org/resource/image/11/6f/11ae5e620c63de76448bc658fe6a496f.jpg)
希望你能把我总结的这张表格放入到你的学习笔记中,时不时复习一下。
最后我还想再多说几句。在大多数业务场景下我们会把Redis作为只读缓存使用。针对只读缓存来说我们既可以先删除缓存值再更新数据库也可以先更新数据库再删除缓存。我的建议是优先使用先更新数据库再删除缓存的方法原因主要有两个
1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
不过当使用先更新数据库再删除缓存时也有个地方需要注意如果业务层要求必须读取一致的数据那么我们就需要在更新数据库时先在Redis缓存客户端暂存并发读请求等数据库更新完、缓存值删除后再读取数据从而保证数据一致性。
## 每课一问
按照惯例,我给你提个小问题。这节课,我提到,在只读缓存中进行数据的删改操作时,需要在缓存中删除相应的缓存值。我想请你思考一下,如果在这个过程中,我们不是删除缓存值,而是直接更新缓存的值,你觉得和删除缓存值相比,有什么好处和不足吗?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。