Redis 提供了高性能的数据存取功能,广泛应用在缓存场景中,既能有效地提升业务应用的响应速度,还可以避免把高并发大压力的请求发送到数据库层。但是,如果 Redis 做缓存时出现了问题,比如说缓存失效,那么,大量请求就会直接积压到数据库层,必然会给数据库带来巨大的压力,很可能会导致数据库宕机或是故障,那么,业务应用就没有办法存取数据、响应用户请求了。
正因为 Redis 用作缓存的普遍性以及它在业务应用中的重要作用,所以,需要系统地掌握缓存的一系列内容,包括工作原理、替换策略、异常处理和扩展机制。具体需要解决四个关键问题:
-
Redis 缓存具体是怎么工作的?
-
Redis 缓存如果满了,该怎么办?
-
为什么会有缓存一致性、缓存穿透、缓存雪崩、缓存击穿等异常,该如何应对?
-
Redis 的内存毕竟有限,如果用快速的固态硬盘来保存数据,可以增加缓存的数据量,那么,Redis 缓存可以使用快速固态硬盘吗?
旁路缓存:Redis是如何工作的?
缓存的特征
一个系统中的不同层之间的访问速度不一样,缓存的作用就是把一些需要频繁访问的数据放在缓存中,以加快它们的访问速度。
计算机系统中,默认有两种缓存:
-
CPU 里面的末级缓存,即 LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
-
内存中的高速页缓存,即 page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。
Redis 缓存处理请求的两种情况
把 Redis 用作缓存时,会把 Redis 部署在数据库的前端,业务应用在访问数据时,会先查询 Redis 中是否保存了相应的数据。此时,根据数据是否存在缓存中,会有两种情况。
-
**缓存命中:**Redis 中有相应数据,就直接读取 Redis,性能非常快。
-
**缓存缺失:**Redis 中没有保存相应数据,就从后端数据库中读取数据,性能就会变慢。而且,一旦发生缓存缺失,为了让后续请求能从缓存中读取到数据,需要把缺失的数据写入 Redis,这个过程叫作缓存更新。缓存更新操作会涉及到保证缓存和数据库之间的数据一致性问题。
Redis 作为旁路缓存的使用操作
Redis 是一个独立的系统软件,和业务应用程序是两个软件,当部署了 Redis 实例后,它只会被动地等待客户端发送请求,然后再进行处理。所以,如果应用程序想要使用 Redis 缓存,就要在程序中增加相应的缓存操作代码。所以,也把 Redis 称为旁路缓存;读取缓存、读取数据库和更新缓存的操作都需要在应用程序中来完成。
**旁路型缓存:**业务自行负责与缓存以及数据库之间的交互
使用 Redis 缓存时,需要在应用程序中增加三方面的代码:当应用程序需要读取数据时,需要在代码中显式调用 Redis 的 GET 操作接口,进行查询;如果缓存缺失了,应用程序需要再和数据库连接,从数据库中读取数据;当缓存中的数据需要更新时,也需要在应用程序中显式地调用 SET 操作接口,把更新的数据写入缓存。
**注意:**因为需要新增程序代码来使用缓存,所以,Redis 并不适用于那些无法获得源码的应用,例如一些很早之前开发的应用程序,它们的源码已经没有再维护了,或者是第三方供应商开发的应用,没有提供源码,所以就没有办法在这些应用中进行缓存操作。
在使用旁路缓存时,需要在应用程序中增加操作代码,增加了使用 Redis 缓存的额外工作量,但是,也正因为 Redis 是旁路缓存,是一个独立的系统,可以单独对 Redis 缓存进行扩容或性能优化。而且,只要保持操作接口不变,在应用程序中增加的代码就不用再修改了。
不过,除了从 Redis 缓存中查询、读取数据以外,应用程序还可能会对数据进行修改,这时,既可以在缓存中修改,也可以在后端数据库中进行修改,该怎么选择呢?其实,这就涉及到了 **Redis 缓存的两种类型:只读缓存和读写缓存。**只读缓存能加速读请求,而读写缓存可以同时加速读写请求。而且,读写缓存又有两种数据写回策略,开发者可以根据业务需求,在保证性能和保证数据可靠性之间进行选择。
只读缓存
当 Redis 用作只读缓存时,应用要读取数据,会先调用 Redis GET 接口,查询数据是否存在。而所有的数据写请求,会直接发往后端的数据库,在数据库中增删改。对于删改的数据来说,如果 Redis 已经缓存了相应的数据,应用需要把这些缓存的数据删除,Redis 中就没有这些数据了。当应用再次读取这些数据时,会发生缓存缺失,应用会把这些数据从数据库中读出来,并写到缓存中。这样一来,这些数据后续再被读取时,就可以直接从缓存中获取了,能起到加速访问的效果。
只读缓存直接在数据库中更新数据的好处是,所有最新的数据都在数据库中,而数据库是提供数据可靠性保障的,这些数据不会有丢失的风险。当需要缓存图片、短视频这些用户只读的数据时,就可以使用只读缓存这个类型了。
读写缓存
读写缓存除了读请求会发送到缓存进行处理(直接在缓存中查询数据是否存在),所有的写请求也会发送到缓存,在缓存中直接对数据进行增删改操作。此时,得益于 Redis 的高性能访问特性,数据的增删改操作可以在缓存中快速完成,处理结果也会快速返回给业务应用,这就可以提升业务应用的响应速度。但是,和只读缓存不一样的是,在使用读写缓存时,最新的数据是在 Redis 中,而 Redis 是内存数据库,一旦出现掉电或宕机,内存中的数据就会丢失。这也就是说,应用的最新数据可能会丢失,给应用业务带来风险。所以,根据业务应用对数据可靠性和缓存性能的不同要求,有同步直写和异步写回两种策略。同步直写策略优先保证数据可靠性,而异步写回策略优先提供快速响应。
同步直写是指,写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都写完数据,才给客户端返回。这样,即使缓存宕机或发生故障,最新的数据仍然保存在数据库中,这就提供了数据可靠性保证。不过,同步直写会降低缓存的访问性能。这是因为缓存中处理写请求的速度是很快的,而数据库处理写请求的速度较慢。即使缓存很快地处理了写请求,也需要等待数据库处理完所有的写请求,才能给应用返回结果,这就增加了缓存的响应延迟。
**异步写回策略,**则是优先考虑了响应延迟。此时,所有写请求都先在缓存中处理。等到这些增改的数据要被从缓存中淘汰出来时,缓存将它们写回后端数据库。处理这些数据的操作是在缓存中进行的,很快就能完成。只不过,如果发生了掉电,而它们还没有被写回数据库,就会有丢失的风险。
关于是选择只读缓存,还是读写缓存,主要看对写请求是否有加速的需求。
-
如果需要对写请求进行加速,我们选择读写缓存;
-
如果写请求很少,或者是只需要提升读请求的响应速度的话,我们选择只读缓存。
eg:**在商品大促的场景中,商品的库存信息会一直被修改。如果每次修改都需到数据库中处理,就会拖慢整个应用,此时,通常会选择读写缓存的模式。**而在短视频 App 的场景中,虽然视频的属性有很多,但是,一般确定后,修改并不频繁,此时,在数据库中进行修改对缓存影响不大,所以只读缓存模式是一个合适的选择。
替换策略:缓存满了怎么办?
设置多大的缓存容量合适?
缓存容量设置得是否合理,会直接影响到使用缓存的性价比。 建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。
对于 Redis 来说,一旦确定了缓存最大容量,比如 4GB;使用下面这个命令来设定缓存的大小:
CONFIG SET maxmemory 4gb
缓存被写满是不可避免的。即使确定了缓存容量,还是要面对缓存写满时的替换操作。缓存替换需要解决两个问题:决定淘汰哪些数据,如何处理那些被淘汰的数据。
Redis 缓存有哪些淘汰策略?
Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略。不进行数据淘汰的策略,只有 noeviction 这一种。会进行淘汰的 7 种其他策略。
会进行淘汰的 7 种策略,可以再进一步根据淘汰候选数据集的范围把它们分成两类:
-
在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
-
在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。
在redis3.0之前,默认是volatile-lru;在redis3.0之后(包括3.0),默认淘汰策略则是noeviction。
**默认情况下,Redis 在使用的内存空间超过 maxmemory 值时,并不会淘汰数据,也就是设定的 noeviction 策略。**对应到 Redis 缓存,也就是指,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。Redis 用作缓存时,实际的数据集通常都是大于缓存容量的,总会有新的数据要写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,不把它用在 Redis 缓存中。
volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 这四种淘汰策略。它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。
eg: 使用 EXPIRE 命令对一批键值对设置了过期时间后,无论是这些键值对的过期时间是快到了,还是 Redis 的内存使用量达到了 maxmemory 阈值,Redis 都会进一步按照 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 这四种策略的具体筛选规则进行淘汰。
-
volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期(即将过期的)的越先被删除。
-
volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
-
volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。
-
volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。
相对于 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 这四种策略淘汰的是设置了过期时间的数据,allkeys-lru、allkeys-random、allkeys-lfu 这三种淘汰策略的备选淘汰数据范围,就扩大到了所有键值对,无论这些键值对是否设置了过期时间。它们筛选数据进行淘汰的规则是:
-
allkeys-random 策略,从所有键值对中随机选择并删除数据;
-
allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
-
allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。
如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。当然,如果它的过期时间到了但未被策略选中,同样也会被删除。
LRU淘汰策略
LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据。
LRU 算法在实际实现时,需要用链表管理所有的缓存数据,**会带来额外的空间开销。**而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
在 Redis 中,LRU 算法被做了简化,以减轻数据淘汰对缓存性能的影响。Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。
Redis 提供了一个配置参数 maxmemory-samples,这个参数就是 Redis 选出的数据个数 N。例如,执行如下命令,可以让 Redis 选出 100 个数据作为候选数据集:
CONFIG SET maxmemory-samples 100
当需要再次淘汰数据时,Redis 需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:**能进入候选集合的数据的 lru 字段值必须小于候选集合中最小的 lru 值。**当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了 maxmemory-samples,Redis 就把候选数据集中 lru 字段值最小的数据淘汰出去。这样Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。
LRU策略引入leetcode算法帮助理解学习:
Redis使用建议:
**优先使用 allkeys-lru 策略。**这样可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果业务数据中有明显的冷热数据区分,建议使用 allkeys-lru 策略。
如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据就行。
**如果业务中有置顶的需求,**比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。
如何处理被淘汰的数据?
一旦被淘汰的数据选定后,如果这个数据是干净数据,就直接删除;如果这个数据是脏数据,需要写回数据库,如下图所示:
干净数据和脏数据的区别就在于,和最初从后端数据库里读取时的值相比,有没有被修改过。干净数据一直没有被修改,所以后端数据库里的数据也是最新值。在替换时,它可以被直接删除。而脏数据就是曾经被修改过的,已经和后端数据库中保存的数据不一致了。此时,如果不把脏数据写回到数据库中,这个数据的最新值就丢失了,就会影响应用的正常使用。 这么一来,缓存替换既腾出了缓存空间,用来缓存新的数据,同时,将脏数据写回数据库,也保证了最新数据不会丢失。不过,对于 Redis 来说,它决定了被淘汰的数据后,会把它们删除。即使淘汰的数据是脏数据,Redis 也不会把它们写回数据库。所以,在使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。否则,这个脏数据被淘汰时,会被 Redis 删除,而数据库里也没有最新的数据了。在 Redis 中,被淘汰数据无论干净与否都会被删除;这是在使用 Redis 缓存时要特别注意的:当数据修改成为脏数据时,需要在数据库中也把数据修改过来。
缓存异常(上):如何解决缓存和数据库的数据不一致问题?
在实际应用 Redis 缓存时,经常会遇到一些异常问题,概括来说有 4 个方面:缓存中的数据和数据库中的不一致;缓存雪崩;缓存击穿和缓存穿透。
缓存和数据库的数据不一致是如何发生的?
首先,得清楚“数据的一致性”具体是啥意思。“一致性”包含了两种情况:
-
缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
-
缓存中本身没有数据,那么,数据库中的值必须是最新值。
不符合这两种情况的,就属于缓存和数据库的数据不一致问题了。不过,当缓存的读写模式不同时,缓存数据不一致的发生情况不一样,应对方法也会有所不同,所以先按照缓存读写模式,来分别了解下不同模式下的缓存不一致情况。
读写缓存
对于读写缓存来说,如果要对数据进行增删改,就需要在缓存中进行,同时还要根据采取的写回策略,决定是否同步写回到数据库中。
-
**同步直写策略:**写缓存时,也同步写数据库,缓存和数据库中的数据一致;
-
**异步写回策略:**写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。
**对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。**不过,需要注意的是,**如果采用这种策略,就需要同时更新缓存和数据库。所以要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,就无法实现同步直写。**当然,在有些场景下,对数据一致性的要求可能不是那么高(业务要对缓存数据的一致性有一定容忍度),比如缓存的是电商商品的非关键属性或者短视频的创建或修改时间等,可以使用异步写回策略。
异步写回策略中消息队列的使用
异步写回策略通常需要使用消息队列来实现。在异步写回策略中,当写缓存时,不立即同步写回数据库,而是将写操作发送到消息队列中,然后快速返回给客户端。后续,异步的从消息队列中取出写操作,并将数据写回数据库。
**使用消息队列的好处是可以实现解耦和异步处理。**当缓存写操作发送到消息队列后,即使缓存发生故障,数据丢失或不一致的风险会变小,因为写操作已经被持久化到消息队列中。一旦缓存恢复正常或数据库可用时,可以从消息队列中读取并执行写回数据库的操作,以确保数据库中最新的数据。
此外,**消息队列还可以实现削峰填谷、提高系统的吞吐量和并发性能。**通过将写操作发送到消息队列中,并异步地执行写回数据库的操作,可以有效地分摊负载和减少并发访问数据库的压力。
需要注意的是,**在异步写回策略中,由于存在缓存和数据库数据的不一致性,**有可能造成部分数据的丢失。因此,在采用异步写回策略时,需要进行风险评估,确保数据丢失的影响可以接受,并采取适当的容错和恢复机制来处理异常情况。
举个电商项目中常见的秒杀活动的例子:秒杀活动会有大量的扣减库存的操作,库存如果保存在数据库中,在这样的场景下,磁盘操作肯定是性能的瓶颈。
首先想到将扣减库存操作存到Redis里,扣减数据库是要有磁盘操作的,改为扣减内存就更快了。虽然扣减内存也是串行化的,但是扣减内存所产生的性能消耗几乎可以忽略不计。然后要做异步同步数据库的操作,因为数据库库存缓存化之后,缓存始终是不靠谱的:内存会丢。因此要将数据异步的方式同步到数据库内。最后,**要有数据库数据和缓存内数据最终一致性的保证。**并且在这样的业务中,为了追求高性能,业务上是允许短时间内数据不一致的,只要最终的数据一致性即可。
只读缓存
对于只读缓存来说,如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。
Tomcat 上运行的应用,无论是新增(Insert 操作)、修改(Update 操作)、还是删除(Delete 操作)数据 X,都会直接在数据库中增改删。如果应用执行的是修改或删除操作,还会删除缓存的数据 X。那么,这个过程中会不会出现数据不一致的情况呢?考虑到新增数据和删改数据的情况不一样,分开来看。
1. 新增数据
新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时,缓存中本身就没有新增数据,而数据库中是最新值,这种情况符合刚刚所说的一致性的第 2 种情况,所以,此时缓存和数据库的数据是一致的。
2. 删改数据
删改数据时,应用既要更新数据库,也要在缓存中删除数据。在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值。下面的表总结了刚刚所说的这两种情况。
一次数据更新中如何解决数据不一致问题?
重试机制。
具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka 消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
如果能够成功地删除或更新,把这些值从消息队列中去除,以免重复操作,此时,就可以保证数据库和缓存的数据一致。否则还需要再次进行重试。如果重试超过的一定次数,还是没有成功,就向业务层发送报错信息。
下图显示了先更新数据库,再删除缓存值时,如果缓存删除失败,再次重试后删除成功的情况:
重试机制详解
失败后立即重试的问题在于:
-
立即重试很大概率「还会失败」
-
「重试次数」设置多少才合理?
-
重试会一直「占用」这个线程资源,无法服务其它客户端请求
同步重试的方案是不严谨的,更好的方案应该异步重试
其实就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。
或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
还有一个问题:写消息队列也有可能会失败啊?而且,引入消息队列,这又增加了更多的维护成本,这样做值得吗?
思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目「重启」了,那这次重试请求也就「丢失」了,那这条数据就一直不一致了。
所以,这里必须把重试或第二步操作放到另一个「服务」中,这个服务用「消息队列」最为合适。这是因为消息队列的特性,正好符合需求:
-
消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
-
消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合重试场景)
至于写队列失败和消息队列的维护成本问题:
-
写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
-
维护成本:项目中一般都会用到消息队列,维护成本并没有新增很多
所以,引入消息队列来解决这个问题,是比较合适的。这时架构模型就变成了这样:
如果确实不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?
方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存。
具体来讲:业务应用在修改数据时,「只需」修改数据库,无需操作缓存。
那什么时候操作缓存呢?这就和数据库的「变更日志」有关了。
拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:
-
无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
-
自动投递到下游队列:canal 自动把数据库变更日志「投递」给下游的消息队列
当然,与此同时,需要投入精力去维护 canal 的高可用和稳定性。
如果留意观察很多数据库的特性,就会发现其实很多数据库都逐渐开始提供「订阅变更日志」的功能了,相信不远的将来,就不用通过中间件来拉取日志,自己写程序就可以订阅变更日志了,这样可以进一步简化流程。
至此,可以得出结论,想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。
高并发场景下的数据不一致如何解决?
**刚刚说的是在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作第一次执行时都没有失败,当有大量并发请求时,应用还是有可能读到不一致的数据。按照不同的删除和更新顺序,分成两种情况来看。**在这两种情况下,解决方法也有所不同。
情况一:先删除缓存,再更新数据库。
假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
线程 B 读取到了旧值;
线程 B 是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。
等到线程 B 从数据库读取完数据、更新了缓存后,线程 A 才开始更新数据库,此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。
**解决方案:**在线程 A 更新完数据库值以后,让它先 sleep 一小段时间,再进行一次缓存删除操作。
之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。所以,****线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。这个时间怎么确定呢?建议在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进行估算。这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以把它叫做“延迟双删”。 下面的这段伪代码就是“延迟双删”方案的示例:
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)
情况二:先更新数据库值,再删除缓存值。
如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,如果业务层要求必须读取一致的数据**,那么,就需要在更新数据库时,先在 Redis 缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。**
**解决方法:**等待缓存删除完成,期间会有不一致数据短暂存在
如何暂停并发读请求?
chatgpt:通过获取分布式锁可以保证在更新数据库和删除缓存期间没有其他并发读请求(分布式锁支持读/写锁)
小结
缓存和数据库的数据不一致一般是由两个原因导致的(删除缓存、更新DB),提供了相应的解决方案:
-
删除缓存值或更新数据库失败而导致数据不一致,可以使用重试机制确保删除或更新操作成功。
-
在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。
主从库延迟和延迟双删问题
前面说过「先删除缓存,再更新数据库」方案导致不一致的场景现在在深度思考一下。
2 个线程要并发「读写」数据,可能会发生以下场景:
-
线程 A 要更新 X = 2(原值 X = 1)
-
线程 A 先删除缓存
-
线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
-
线程 A 将新值写入数据库(X = 2)
-
线程 B 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
第二个问题:是关于「读写分离 + 主从复制延迟」情况下,缓存和数据库一致性的问题。
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:
-
线程 A 更新主库 X = 2(原值 X = 1)
-
线程 A 删除缓存
-
线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
-
从库「同步」完成(主从库 X = 2)
-
线程 B 将「旧值」写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。
看到了么?这 2 个问题的核心在于:缓存都被回种了「旧值」。
那怎么解决这类问题呢?
最有效的办法就是,把缓存删掉。
但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略。
按照延时双删策略,这 2 个问题的解决方案是这样的:
解决第一个问题:在线程 A 删除缓存、更新完数据库之后,先「休眠一会」,再「删除」一次缓存。
解决第二个问题:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。
这两个方案的目的,都是为了把缓存清掉,这样一来,下次就可以从数据库读取到最新值,写入缓存。
但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
-
问题1:延迟时间要大于「主从复制」的延迟时间
-
问题2:延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
但是,这个时间在分布式和高并发场景下,其实是很难评估的**。**
很多时候,都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。
所以采用「先删除缓存,再更新数据库」的方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,还是建议采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。
可以做到强一致吗?
上面这些方案还是不够完美,如果想让缓存和数据库「强一致」,能不能做到呢?
其实很难。
要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。
相反,这时换个角度思考一下,引入缓存的目的是什么?
没错,性能。
一旦决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。
而且,就拿前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到「中间状态」的数据。
所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有「任何请求」进来。
虽然可以通过加「分布锁」的方式来实现,但我们要付出的代价,很可能会超过引入缓存带来的性能提升。
所以,既然决定使用缓存,就必须容忍「一致性」问题,只能尽可能地去降低问题出现的概率。
同时也要知道,缓存都是有「失效时间」的,就算在这期间存在短期不一致,依旧有失效时间来兜底,这样也能达到最终一致。
**最终一致性:**能忍受一定时间内的数据不一致性的,只要求最后的数据是一致的即可。缓存一般是设有失效时间的,失效之后数据也会保证一致性,或者是下次修改时,没有并发,也会让数据回到一致性等等。
总结
使用 Redis 缓存时,最常遇见的问题就是缓存和数据库不一致的问题。针对这个问题可以分成读写缓存和只读缓存两种情况进行分析。对于读写缓存来说,如果采用同步写回策略,那么可以保证缓存和数据库中的数据一致。只读缓存的情况比较复杂,总结了一张表:
**在大多数业务场景下,会把 Redis 作为只读缓存使用。**针对只读缓存,既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。建议优先使用先更新数据库再删除缓存的方法,原因主要有两个:
-
先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
-
如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
实战心得分享
-
性能和一致性不能同时满足,为了性能考虑,通常会采用「最终一致性」的方案
-
掌握缓存和数据库一致性问题,核心问题有 3 点:缓存利用率、并发、缓存 + 数据库一起成功问题
-
失败场景下要保证一致性,常见手段就是「重试」,同步重试会影响吞吐量,所以通常会采用异步重试的方案
-
订阅变更日志的思想,本质是把权威数据源(例如 MySQL)当做 leader 副本,让其它异质系统(例如 Redis / Elasticsearch)成为它的 follower 副本,通过同步变更日志的方式,保证 leader 和 follower 之间保持一致
缓存和数据库一致性问题,评论区优秀学员博客(已整理):
缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?
缓存雪崩
缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。缓存雪崩一般是由两个原因导致的,应对方案也有所不同。
**第一个原因是:**缓存中有大量数据同时过期,导致大量请求无法得到处理。 具体来说,当数据保存在缓存中,并且设置了过期时间时,如果在某一个时刻,大量数据同时过期,此时,应用再访问这些数据的话,就会发生缓存缺失。紧接着,应用就会把请求发送给数据库,从数据库中读取数据。如果应用的并发请求量很大,那么数据库的压力也就很大,这会进一步影响到数据库的其他正常业务请求处理。
针对大量数据同时失效带来的缓存雪崩问题,提供两种解决方案。 首先,可以避免给大量的数据设置相同的过期时间。如果业务层的确要求有些数据同时失效,可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟),这样不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。
除了微调过期时间,还可以通过服务降级,来应对缓存雪崩。所谓的服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式。
-
当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
-
当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
这样一来,只有部分过期数据的请求会发送到数据库,数据库的压力就没有那么大了。
如果某个独立缓存真的出现了缓存雪崩,业务层面应该如何将受损范围控制在仅自身模块、避免殃及数据库以及下游公共服务模块,进而避免业务出现系统性瘫痪呢?这个就需要结合服务治理中的一些手段来综合防范了,比如服务降级、服务熔断、以及接口限流等策略。
除了大量数据同时失效会导致缓存雪崩,还有一种情况也会发生缓存雪崩,那就是,Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。 一般来说,一个 Redis 实例可以支持数万级别的请求处理吞吐量,而单个数据库可能只能支持数千级别的请求处理吞吐量,它们两个的处理能力可能相差了近十倍。由于缓存雪崩,Redis 缓存失效,所以,数据库就可能要承受近十倍的请求压力,从而因为压力过大而崩溃。此时,因为 Redis 实例发生了宕机,需要通过其他方法来应对缓存雪崩。两个建议:
第一个建议,在业务系统中实现服务熔断或请求限流机制。
所谓的服务熔断,是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,暂停业务应用对缓存系统的接口访问。再具体点说,就是业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。这样避免了大量请求因缓存缺失,而积压到数据库系统,保证了数据库系统的正常运行。在业务系统运行时,监测 Redis 缓存所在机器和数据库所在机器的负载指标,例如每秒请求数、CPU 利用率、内存利用率等。如果发现 Redis 缓存实例宕机了,而数据库所在机器的负载压力突然增加(例如每秒请求数激增),此时,就发生缓存雪崩了。大量请求被发送到数据库进行处理。可以启动服务熔断机制,暂停业务应用对缓存服务的访问,从而降低对数据库的访问压力,如下图所示:
服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围大。为了尽可能减少这种影响,也可以进行请求限流。请求限流指在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请求被发送到数据库。eg: 假设业务系统正常运行时,请求入口前端允许每秒进入系统的请求是 1 万个,其中,9000 个请求都能在缓存系统中进行处理,只有 1000 个请求会被应用发送到数据库进行处理。一旦发生了缓存雪崩,数据库的每秒请求数突然增加到每秒 1 万个,此时,就可以启动请求限流机制,在请求入口前端只允许每秒进入系统的请求数为 1000 个,再多的请求就会在入口前端被直接拒绝服务。所以,使用了请求限流,就可以避免大量并发请求压力传递到数据库层。
**使用服务熔断或是请求限流机制,来应对 Redis 实例宕机导致的缓存雪崩问题,是属于“事后诸葛亮”,也就是已经发生缓存雪崩了,**使用这两个机制,来降低雪崩对数据库和整个业务系统的影响。
第二个建议就是事前预防。
通过主从节点的方式构建 Redis 缓存高可靠集群。如果 Redis 缓存的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。
缓存击穿
缓存击穿,是发生在某个热点数据失效的场景下。和缓存雪崩相比,缓存击穿失效的数据数量要小很多, 为了避免缓存击穿给数据库带来的激增压力,对于访问特别频繁的热点数据,就不设置过期时间了。这样对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。
针对这种情况,我们可以为热点数据设置一个过期时间续期的操作,比如每次请求的时候自动将过期时间续期一下。此外,也可以在数据库记录访问的时候借助分布式锁来防止缓存击穿问题的出现。当缓存不可用时,仅持锁的线程负责从数据库中查询数据并写入缓存中,其余请求重试时先尝试从缓存中获取数据,避免所有的并发请求全部同时打到数据库上。如下图所示:
对上面的出处理过程描述说明如下:
-
没有命中缓存的时候,先请求获取分布式锁,获取到分布式锁的线程,执行
DB查询操作,然后将查询结果写入到缓存中; -
没有抢到分布式锁的请求,原地
自旋等待一定时间后进行再次重试; -
未抢到锁的线程,再次重试的时候,先尝试去缓存中获取下是否能获取到数据,如果可以获取到数据,则
直接取缓存已有的数据并返回;否则重复上述1、2、3步骤。
对于业务中最常使用的旁路型缓存而言,通常会先读取缓存,如果不存在则去数据库查询,并将查询到的数据添加到缓存中,这样就可以使得后面的请求继续命中缓存。
但是这种常规操作存在个“漏洞”,因为大部分缓存容量有限制,且很多场景会基于LRU策略进行内存中热点数据的淘汰,假如有个恶意程序(比如爬虫)一直在刷历史数据,容易将内存中的热点数据变为历史数据,导致真正的用户请求被打到数据库层。因而又出现了一些业务场景,会使用类似上面所举的例子的策略,缓存指定时间段内的数据(比如最近1年),且数据不存在时从DB获取内容之后也不会回写到缓存中。针对这种场景,在缓存的设计时,需要考虑到对这种冷数据的加热机制进行一些额外处理,如设定一个门槛,如果指定时间段内对一个冷数据的访问次数达到阈值,则将冷数据加热,添加到热点数据缓存中,并设定一个独立的过期时间,来解决此类问题。
比如上面的例子中,我们可以约定同一秒内对某条冷数据的请求超过10次,则将此条冷数据加热作为临时热点数据存入缓存,设定缓存过期时间为30天(一般一个陈年八卦一个月足够消停下去了)。通过这样的机制,来解决冷数据的突然窜热对系统带来的不稳定影响。
缓存穿透
缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求,这样一来,缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力。
缓存穿透会发生在什么时候呢?一般来说,有两种情况。
-
业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
-
恶意攻击:专门访问数据库中没有的数据。
为了避免缓存穿透的影响,提供三种应对方案:
第一种方案,缓存空值或缺省值。
一旦发生缓存穿透,我们就可以针对查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为 0)。紧接着,应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。
第二种方案,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样即使发生缓存穿透了,大量请求只会查询 Redis 和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用 Redis 实现,本身就能承担较大的并发访问压力。
最后一种方案,在请求入口的前端进行请求检测。缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以,一个有效的应对方案是在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。
总结
**跟缓存雪崩、缓存击穿这两类问题相比,缓存穿透的影响更大一些,**希望能重点关注一下。从预防的角度来说,需要避免误删除数据库和缓存中的数据;从应对角度来说,可以在业务系统中使用缓存空值或缺省值、使用布隆过滤器,以及进行恶意请求检测等方法。
小结
从问题成因来看,缓存雪崩和击穿主要是因为数据不在缓存中了,而缓存穿透则是因为数据既不在缓存中,也不在数据库中。所以,缓存雪崩或击穿时,一旦数据库中的数据被再次写入到缓存后,应用又可以在缓存中快速访问数据了,数据库的压力也会相应地降低下来,而缓存穿透发生时,Redis 缓存和数据库会同时持续承受请求压力。
最后,强调一下,服务熔断、服务降级、请求限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响。例如使用服务降级时,有部分数据的请求就只能得到错误返回信息,无法正常处理。如果使用了服务熔断,那么,整个缓存系统的服务都被暂停了,影响的业务范围更大。而使用了请求限流机制后,整个业务系统的吞吐率会降低,能并发处理的用户请求会减少,会影响到用户体验。
建议尽量使用预防式方案:(秒杀项目要点)
针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。
优秀学员问题回答: 关于文章所讲的由于“Redis缓存实例发生故障宕机”导致缓存雪崩的问题, 一个可以优化的方案是,当Redis实例故障宕机后,业务请求可以直接返回错误,没必要再去请求数据库了,这样就不会导致数据库层压力变大。当然,最好的方式还是Redis部署主从集群+哨兵,主节点宕机后,哨兵可以及时把从节点提升为主,继续提供服务。