在实际应用 Redis 缓存时,我们经常会遇到一些异常问题,
概括来说有 4 个方面:缓存和数据库的数据不一致;缓存雪崩;缓存击穿和缓存穿透。
只要我们使用 Redis 缓存,就必然会面对缓存和数据库间的一致性保证问题,
这也算是 Redis 缓存应用中的“必答题”了。
最重要的是,如果数据不一致,那么业务应用从缓存中读取的数据就不是最新数据,这会导致严重的错误。
比如:我们把电商商品的库存信息缓存在 Redis 中,
如果库存信息不对,那么业务层下单操作就可能出错,这当然是不能接受的。
所以,这节课我就重点和你聊聊这个问题。
关于缓存雪崩、穿透和击穿等问题,我会在下一节课向你介绍。
缓存和数据库的数据不一致是如何发生的
接下来,我们就来看看,缓存和数据库之间的数据不一致是怎么引起的。
首先,我们得清楚“数据的一致性”具体是什么意思。
其实,这里的“一致性”包含了两种情况:
- 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
- 缓存中本身没有数据,那么,数据库中的值必须是最新值。
不符合这两种情况的,就属于缓存和数据库的数据不一致问题了。
不过,当缓存的读写模式不同时,缓存数据不一致的发生情况不一样,我们的应对方法也会有所不同,
所以,我们先按照缓存读写模式,来分别了解下不同模式下的缓存不一致情况。
我在第23讲中讲过,根据是否接收写请求,我们可以把缓存分成读写缓存和只读缓存。
对于读写缓存来说
对于读写缓存来说,如果要对数据进行增删改,就需要在缓存中进行,
同时还要根据采取的写回策略,决定是否同步写回到数据库中。
- 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;
- 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。
使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,
那么,数据库就没有最新的数据了。
所以,对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。
不过,需要注意的是,如果采用同步直写策略,就需要同时更新缓存和数据库。
所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性。
否则,我们就无法实现同步直写。
当然,在有些场景下,我们对数据一致性的要求可能不是那么高,
比如:缓存的是电商商品的非关键属性或者短视频的创建或修改时间等,那么,我们可以使用异步写回策略。
对于只读缓存来说
下面我们再来说说只读缓存。
对于只读缓存来说,如果有数据新增,会直接写入数据库;
而有数据删改时,就需要把只读缓存中的数据标记为无效。
这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。
此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。
那么,这个过程中会不会出现数据不一致的情况呢?
如果发生修改操作,应用既要更新数据库,也要在缓存中删除数据。
这两个操作如果无法保证原子性,就会出现数据不一致问题了。
这个问题比较复杂,我们来分析一下。
我们假设应用先删除缓存,再更新数据库,
如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。
然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。
或者说, 先删除缓存之后,如果刚好此时有对该数据的查询请求,会发现缓存缺失,将会到数据库读取,并缓存到 Redis,这样缓存先被删除,又有了缓存数据。
如果我们先更新数据库,再删除缓存中的值,是否会出现数据不一致的情况呢?
如果应用先完成了数据库的更新,但是,在删除缓存时失败了,
那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。
这个时候,如果有对该数据的查询,按照正常的缓存访问流程,就会先在缓存中查询,就会读到旧值了。
到这里,我们可以看到,在更新数据库和删除缓存值的过程中,
无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值。
如何解决数据不一致问题
重试机制
首先,我给你介绍一种方法:重试机制。
具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中。
当应用没有能够成功地删除缓存值或者是更新数据库值时,
可以从消息队列中重新读取这些值,然后再次进行删除或更新。
如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,
此时,我们也可以保证数据库和缓存的数据一致了。
否则的话,我们还需要再次进行重试。
如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
延迟双删
刚刚说的是在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,
实际上,即使这两个操作第一次执行时都没有失败,当有大量并发请求时,应用还是有可能读到不一致的数据。
同样,我们按照不同的删除和更新顺序,分成两种情况来看。
在这两种情况下,我们的解决方法也有所不同。
情况一:先删除缓存,再更新数据库。
假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),
线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
- 线程 B 读取到了旧值;
- 线程 B 是在缓存缺失的情况下读取的数据库,
所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。
等到线程 B 从数据库读取完数据、更新了缓存后,线程A才开始更新数据库,此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。
这该怎么办呢?我来给你提供一种解决方案。
在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。
这样就会把缓存中的旧值删除。
所以,线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。这个时间怎么确定呢?
建议你在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进行估算。
这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。
因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。
下面的这段伪代码就是“延迟双删”方案的示例。
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)
情况二:先更新数据库值,再删除缓存值。
如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。
不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。
而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
到这里,我们了解到了,缓存和数据库的数据不一致一般是由两个原因导致的,我给你提供了相应的解决方案。
- 删除缓存值或更新数据库失败 而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
- 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。
小结
在这节课,我们学习了在使用 Redis 缓存时,最常遇见的一个问题,也就是缓存和数据库不一致的问题。
针对这个问题,我们可以分成读写缓存和只读缓存两种情况进行分析。
对于读写缓存来说,如果我们采用同步写回策略,那么可以保证缓存和数据库中的数据一致。
只读缓存的情况比较复杂,我总结了一张表,以便于你更加清晰地了解数据不一致的问题原因、现象和应对方案。
操作顺序 | 是否有 并发请求 |
潜在问题 | 现象 | 应对方案 |
---|---|---|---|---|
先删除缓存, 再更新数据库 |
无 | 缓存删除成功,但数据库更新失败 | 应用从数据库读到旧值 | 重试机制 |
有 | 删除缓存,尚未更新数据库, 此时有新的读请求 |
请求从数据库读到旧值,并把旧值更新到缓存 | 延迟双删 | |
先更新数据库, 再删除缓存 |
无 | 数据库更新成功,但缓存删除失败 | 应用从缓存读到旧值 | 重试机制 |
有 | 数据库更新成功,缓存尚未删除, 此时有新的读请求 |
缓存尚未删除期间, 应用从缓存中读到旧值 |
等待缓存删除完成 |
在大多数业务场景下,我们会把 Redis 作为只读缓存使用。
针对只读缓存来说,我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。
我的建议是,优先使用先更新数据库再删除缓存的方法,原因主要有两个:
- 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
- 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,
如果业务层要求必须读取一致的数据,那么,我们就需要在更新数据库时,先在 Redis 缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。
每课一问
这节课,我提到,在只读缓存中进行数据的删改操作时,需要在缓存中删除相应的缓存值。
我想请你思考一下,如果在这个过程中,我们不是删除缓存值,而是直接更新缓存的值,你觉得和删除缓存值相比,有什么好处和不足吗?
如果我们直接在缓存中更新缓存值,等到下次数据再被访问时,
业务应用可以直接从缓存中读取数据,这是它的一大好处。
不足之处在于,当有数据更新操作时,我们要保证缓存和数据库中的数据是一致的,
这就可以采用我在第 25 讲中介绍的重试或延时双删方法。
不过,这样就需要在业务应用中增加额外代码,有一定的开销。