楔子
在使用 Redis 做缓存时,我们经常会遇到一些问题,比如:
- 缓存中的数据和数据库中的数据不一致;
- 缓存雪崩;
- 缓存穿透;
- 缓存击穿;
而且这些问题在面试的时候也几乎是必问,本文我们来探讨第一个问题,当缓存和数据库中的数据不一致的时候,该怎么办?因为数据不一致,那么从缓存当中就会获取到旧数据,从而可能导致严重的错误,所以这个问题是一定要解决的。
但是在探讨这个问题之前,我们需要了解一些前置的知识。
缓存处理请求的两种情况
Redis 用作缓存时,我们会把 Redis 部署在数据库的前端,业务应用在访问数据时,会先查询 Redis 中是否保存了相应的数据。此时根据数据是否存在缓存中,会有两种情况。
- 缓存命中:Redis 中有相应数据,直接读取 Redis,性能非常高;
- 缓存缺失:Redis 中没有相应数据,此时会从数据库中读取,性能会变慢。而一旦缓存缺失,为了后续请求能够从缓存中读取,我们会把从数据库中读到的数据写入缓存中,这个过程就叫做缓存更新。而缓存更新这一步,就会涉及到数据不一致的问题,后续分析;
所以在使用 Redis 的时候,有以下三个操作:1)应用读数据时,先读 Redis;2)当发生缓存缺失时,读取数据库;3)发生缓存缺失时,还要更新缓存。
这三步都由业务方来完成。
缓存的类型
按照 Redis 是否接受写请求,我们可以把它分为只读缓存和读写缓存。
只读缓存
当 Redis 用作只读缓存时,应用要读取数据的话,会先调用 Redis GET 接口,查询数据是否存在。而所有数据的写请求,会直接发往后端的数据库,在数据库中执行。对于修改的数据来说,如果 Redis 也缓存了相应的数据,应用需要把这些缓存的数据删除。
当应用再次读取这些数据时,会发生缓存缺失,应用会把这些数据从数据库中读出来,并写到缓存中。这样一来,这些数据后续再被读取时,就可以直接从缓存中获取了,能起到加速访问的效果。
假设业务应用要修改数据 A,那么应用会先直接在数据库里修改,但如果该数据在 Redis 里面也存在,那么还要将它从 Redis 里面删除。等到应用需要读取数据 A 时,会发生缓存缺失,此时应用就会从数据库中读取 A,并写入 Redis,以便后续请求能从缓存中获取。
只读缓存直接在数据库中更新数据的好处是,所有最新的数据都在数据库中,而数据库是提供数据可靠性保障的,这些数据不会有丢失的风险。当我们需要缓存图片、短视频这些用户只读的数据时,就可以使用只读缓存这个类型了。
读写缓存
对于读写缓存来说,除了读请求会发送到缓存中进行处理(在缓存中查询数据是否存在),所有的写请求也会发送到缓存,在缓存中直接对数据执行增删改操作。此时得益于 Redis 的高性能访问特性,数据的增删改操作可以在缓存中快速完成,处理结果也会快速返回给业务应用,这就可以提升业务应用的响应速度。
但和只读缓存不一样,使用读写缓存时,最新的数据在 Redis 中,一旦宕机或断电,就会造成数据丢失,给业务带来风险。所以数据在 Redis 中更新完毕之后,还要写到数据库里面,而根据业务对数据可靠性和缓存性能的不同要求,我们有两种写回方式,分别是同步直写和异步写回。
同步直写
写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都写完数据,才给客户端返回。这样即使缓存宕机或发生故障,最新的数据仍然保存在数据库中,这就提供了数据可靠性保证。
但我们如何保证这两步同时成功呢?要是 Redis 写入成功,数据库写入失败怎么办?这个问题我们后续探讨。
但同步直写会降低缓存的访问性能,这是因为缓存中处理写请求的速度是很快的,而数据库处理写请求的速度较慢。但缓存必须等待数据库处理完之后,才能给应用返回结果,这就增加了缓存的响应延迟。
异步写回
该方式优先考虑了响应延迟,此时所有写请求都先在缓存中处理,等到这些增改的数据要被从缓存中淘汰出来时,缓存将它们写回后端数据库。这样一来,处理这些数据的操作是在缓存中进行的,很快就能完成。只不过当发生掉电时,由于数据还没有被写回数据库,就会有丢失的风险。
选择哪一种
那么问题来了,只读缓存和读写缓存选择哪一种呢?其实主要判断依据还是看我们是否对写请求有加速的需求。
- 如果需要加速写请求,我们选择读写缓存;
- 如果写请求很少,或者只需要对读请求加速的话,我们选择只读缓存;
举个例子,在商品大促销的场景中,商品的库存信息会一直被修改。如果每次修改都需到数据库中处理,就会拖慢整个应用,此时我们通常会选择读写缓存的模式。而在短视频 App 的场景中,虽然视频的属性有很多,但是确定之后,修改并不频繁,此时在数据库中进行修改对缓存影响不大,所以只读缓存模式是一个合适的选择。
数据一致性问题是如何产生的
了解完缓存的请求处理模式、以及缓存的种类之后,我们就可以探讨数据的一致性问题了,首先来看看数据一致性是如何定义的。
- 缓存中如果有数据,那么必须和数据库中的数据保持一致;
- 缓存中如果没有数据,那么数据库中的数据必须是最新的;
如果不符合以上两种情况,那么缓存和数据库之间就出现了数据不一致的问题。而缓存类型的不同,解决方式也不同,我们分别看一下。
读写缓存解决数据一致性
当数据发生更新时,不仅更新数据库,还要连带缓存一起更新(此时写入方式只能是同步直写)。但这里面存在一个严重的问题,我们无法保证这两者都能成功执行。如果这两者有一个更新失败了,会有什么影响呢?我们来看一下。
先更新缓存成功、后更新数据库失败
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是新值,数据库中是旧值。虽然此时读请求可以命中缓存,拿到正确的值,然一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。
于是用户会发现自己之前修改的数据又变回去了,会对业务造成影响。
先更新数据库成功、后更新缓存失败
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是新值,缓存中是旧值。而之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,对业务也会有影响。
可以看到,只要有一方更新失败,都会对业务造成影响。
所以这种办法的难点就在于,我们如何才能保证这两者作为一个事务同时成功和失败呢?可能有人想到使用分布式事务,让两者同时成功才算成功,只要有一方失败就回滚。这是一个解决办法,但却会带来几个问题:
- Redis 和数据库是两个不同的存储介质,两者的写操作不应该被绑定在一起。
- 分布式事务比较复杂,性能较差,还要考虑各种容错问题。
- 分布式事务是以牺牲系统的可用性为代价,即 CAP 中的 A,它和我们引入 Redis 的目的相违背了。
所以性能和一致性就像天平的两端,无法做到都满足要求。如果非要追求强一致,那必须要求在所有的更新操作完成之前,不能有任何请求进来。虽然我们可以通过加分布锁的方式来实现,但要付出的代价,很可能会超过引入缓存带来的性能提升。
而且除了一方更新失败时,会产生数据不一致的问题之外,多个线程并发执行时,即使全部更新成功,也可能会产生数据不一致。
并发更新引起的数据不一致
假设我们采用先更新数据库,再更新缓存的方案,并且两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?假设有线程 A 和线程 B 两个线程,需要更新同一条数据,那么可能会发生这样的场景:
- 1. 线程 A 更新数据库(value = 1)
- 2. 线程 B 更新数据库(value = 2)
- 3. 线程 B 更新缓存(value = 2)
- 4. 线程 A 更新缓存(value = 1)
最终 value 的值在数据库中是 2,但在缓存中是 1。也就是说,虽然 A 先于 B 发生,但操作数据库加缓存的整个过程,B 却比 A 先完成。
可能有人觉得,这是不是不太可能啊,事实上这完全是有可能的,我们无法保证 happens before。有可能 A 在更新完数据库,碰巧来了一次 GC,STW 导致在更新缓存之前,线程 B 将两步都完成了。虽然概率比较低,但绝对是有可能发生的。
同样地,采用 "先更新缓存,再更新数据库" 的方案,也会有类似问题。
除此之外,我们从缓存利用率的角度来评估这个方案,也是不太推荐的。这是因为每次数据发生变更,都无脑更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
更重要的是,很多情况下写到缓存中的值,并不是与数据库中的值一一对应的。很有可能是先查询数据库,再经过一系列计算得出一个值,最后把计算好的值写到缓存中。
所以对于读写缓存来说,可以通过分布式事务保证一致性,但我们不推荐这种做法。而如果不使用分布式事务,那么无论先更新哪个,都会造成数据不一致的问题,进而对业务产生影响。
只读缓存解决数据一致性
经过分析我们知道,同时更新数据库和缓存会有两种可能导致数据不一致:一种是因为异常原因导致其中一方更新失败;另一种是由于并发带来的资源竞争,引起的数据错误更新。
并且这种做法还会带来资源上的浪费,所以我们不建议同时更新数据库和缓存,而是只更新数据库。因为一旦对数据进行修改,那么这个操作就已经发生了,一定要落盘,这样就至少能保证在任何时刻都能从数据库中读取到正确的数据。
但问题是如果缓存中有旧数据该怎么办?缓存如果不更新,就会读出旧数据。因此我们可以考虑从缓存中删除该数据,也就是将缓存用作只读缓存(当数据发生变更,只修改数据库,缓存里的数据不会修改,而是直接删掉,等到下一次读数据的时候再进行缓存更新)。
那么可能有人问,难道将更新缓存换成删除缓存就能保证一致性了吗。首先我们说同时更新数据库和缓存会有两种原因导致数据不一致,而将更新缓存换成删除缓存,至少可以解决并发导致的数据不一致。
那么问题来了,更新数据库和删除缓存到底先执行哪一步才能解决并发带来的数据不一致问题呢?下面分析一下。注意:这里我们先假设两步都成功,因为目前只考虑并发。
先删除缓存、后更新数据库
如果有两个线程要并发读写数据,可能会发生以下场景:
- 1. 线程 A 要将 value 更新为 2(之前 value = 1),但是更新之前先删除缓存;
- 2. 线程 B 读缓存,发现不存在,因为 A 已经删掉了,所以会从数据库中读取到旧值(value = 1);
- 3. 线程 A 将新值写入数据库(value = 2);
- 4. 线程 B 在读缓存的时候发现缓存缺失,于是将从数据库中读取的值写入缓存(value = 1);
最终 value 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。可见先删除缓存,后更新数据库,当发生读写并发时,还是存在数据不一致的情况。
先更新数据库、后删除缓存
依旧是两个线程并发「读写」数据:
- 1. 线程 A 读缓存,发现不存在;
- 2. 线程 A 读取数据库,得到值(value = 1);
- 3. 线程 B 更新数据库(value = 2);
- 4. 线程 B 删除缓存;
- 5. 线程 A 将旧值写入缓存(value = 1);
最终 value 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。咦,不是说可以解决并发带来的不一致吗?为啥两种方式都会导致数据不一致呢?
我们不妨再仔细看一下这种方式,它所造成的数据不一致真的有可能发生吗?首先它如果想发生,必须满足 3 个条件:
- 缓存刚好已失效;
- 读请求 + 写请求并发;
- 更新数据库 + 删除缓存的时间(步骤 3、4),要比读数据库 + 写缓存时间短(步骤 2、5);
首先条件 1 和 2 的概率虽然低,但也有可能发生,但条件 3 发生的概率可以说是微乎其微的。因为写数据库一般会先加锁,所以它通常是要比读数据库的时间更长的。所以「步骤5」不可能发生在「步骤3+步骤4」之后。
因此先更新数据库,后删除缓存在并发层面是可以保证数据一致性的,那么接下来的问题就是当第二个操作(也就是删除缓存)执行失败时,该怎么办?
如何保证两步都成功?
前面我们分析到,无论是更新缓存(对应读写缓存)还是删除缓存(对应只读缓存),只要第二步发生失败,那么就会导致数据库和缓存不一致。只不过更新缓存这种做法即使在两步都成功的前提下也会出现数据不一致,而删除缓存不会,所以我们最终决定采用更新数据库+删除缓存这一策略。所以剩下的问题就是如何保证第二步的成功,这是问题的关键。
而根据操作的不同,应对策略也不同。
新增数据
如果是新增数据,那么会直接写到数据库中,不对缓存做任何操作。此时缓存中没有新数据,而数据库中是最新值,符合我们说的一致性的第二种情况。
等到该数据被访问时,由于出现缓存缺失(cache miss),那么会读取数据库,然后进行缓存更新。所以使用只读缓存,在新增数据之后,从第二次被访问的时候才可以使用缓存。
删改数据
如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,就会出现数据不一致问题了。
我们假设应用先删除缓存,再更新数据库(实际不会采用这种做法),如果缓存删除成功,但是数据库更新失败。那么应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后应用会访问数据库,但数据库中的值为旧值,所以应用就访问到旧值了。
由于上面这种做法解决不了并发的问题,所以我们不会这么做,而是采用先更新数据库后删除缓存。但这么做就完美了吗?如果更新数据库成功,但是删除缓存失败,后续请求的时候如果命中缓存,就会读到旧数据。
所以无论是只读缓存还是读写缓存,无论操作数据库和操作缓存谁先谁后,在并发请求或者第二步执行失败时,都会产生数据不一致的问题。但至少先更新数据库后删除缓存能保证在并发时不出问题,正所谓「矬子里面拔将军」,我们就选它了,下面的问题就是如何借助于其它手段来解决第二步(删除缓存)执行失败的问题。
想一下程序在执行过程中发生异常,最简单的解决办法是什么?没错,就是重试。这里我们也是同样的做法,如果后者执行失败了,就发起重试,尽可能地去做补偿。但这仍然会带来几个问题:
- 立即重试很大概率还会失败;
- 重试次数设置多少才合理;
- 重试会一直占用这个线程资源,无法服务其它客户端请求;
虽然可以通过重试的方式解决问题,但同步重试的方案不够严谨,因此最正确的做法是采用异步重试。
其实就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。或者更直接的做法,为了避免第二步执行失败,我们可以把删除缓存这一步,直接放到消息队列中,由消费者来删除缓存。
到这里你可能会问,写消息队列也有可能会失败啊?而且引入消息队列,这又增加了更多的维护成本,这样做值得吗?这个问题很好,但我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目重启了,那这次重试请求也就丢失了,那这条数据就一直不一致了。
所以这里我们可以把重试或第二步操作放到另一个服务中,这个服务用消息队列最为合适,因为消息队列的特性,正好符合我们的需求:
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心);
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景);
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,同时失败的概率其实是很小的;
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多;
因此引入消息队列来解决这个问题,是比较合适的。此时架构模型就变成了这样:
如果你实在不想引入消息队列,还可以有另一种方式:订阅变更日志。以 MySQL 为例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,比如 maxwell, canal,当然与此同时,我们需要投入精力去维护该中间件的高可用和稳定性。
因此想要保证数据库和缓存的一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志来实现。所以对于业务调用方而言,如果数据库更新成功,那么直接返回成功即可,删除缓存这一步异步实现;如果数据库更新失败,那么直接返回失败,删除缓存也无需再进行了。
主从库延迟和延迟删除
目前还没有万事大吉,这里还有一个问题,我们说更新数据库 + 删除缓存可以解决数据不一致,但如果遇到了读写分离 + 主从复制延迟,那么还是会导致数据不一致的。举个例子:
- 1. 线程 A 更新主库 value = 2(旧值value = 1);
- 2. 线程 A 删除缓存;
- 3. 线程 B 查询缓存没有命中,于是查询从库得到旧值(从库 value = 1);
- 4. 从库同步完成(主从库 value = 2);
- 5. 线程 B 将旧值写入缓存(value = 1);
最终 value 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。所以我们在删除缓存的时候不能立即删,而是需要延迟删。
具体做法就是:线程 A 可以生成一条延时消息,写到消息队列中,消费者延时删除缓存。但问题来了,这个延迟删除缓存,延迟时间到底设置要多久呢?
- 1. 延迟时间要大于主从复制的延迟时间;
- 2. 延迟时间要大于线程 B 读取数据库 + 写入缓存的时间;
而一旦涉及到时间,就意味着不精确,因为谁也说不清这个时间到底应该设置多长,尤其是在分布式和高并发场景下就变得更加难评估。很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1 到 5 秒,只能尽可能地降低不一致的概率,这个过程当中如果有请求过来,还是可能会读到旧数据的。
但通过消息队列或订阅变更日志,我们是可以实现最终一致性的。所以实际使用中,建议采用先更新数据库,再删除缓存的方案,同时要尽可能地保证主从复制不要有太大延迟,降低出问题的概率。
以上就是删除缓存所采用的策略,但其实这背后还有一个问题,那就是如果删除的数据是一个热点数据,是有可能造成缓存击穿的。针对这个问题,国外的 Facebook 给出了一个解决方案,就是在删除的时候,如果判定这是一个热门数据,那么不直接删,而是给它设置一个很短的生命周期,比如 10 到 30 秒。然后业务方在调用的时候会表明这是一个脏数据,至于你要不要用,则交给业务方进行判断。
总结
为了提交服务的响应速度,我们一般会引入缓存。然而一旦缓存引入了,就必然涉及到数据不一致的问题,而解决问题的方式我们推荐使用先更新数据库、再删除缓存的方式。
但删除缓存有可能会失败,于是我们建议这一步配置消息队列或订阅变更日志来做,也就是不断地重试,来保证数据一致性。并且考虑到读写分离+主从复制也会有延迟,所以在往队列里面发消息时,会带有一个延时。而这个时间不好把握,需要参考当前整个系统的执行状态进行判断。
如果删除的是热门数据,可能造成缓存击穿,于是建议不直接删除,而是设置一个较短的生命周期。并且在业务方获取数据的时候,提示这是一个旧数据,是否使用由业务方来决定。
由此可见,当一致性和性能遇见冲突时,我们一般都会选择性能,并实现最终一致性。
本文转载自:
- 极客时间蒋德钧:《Redis 源码剖析与实战》
- 公众号《水滴与银弹》