Redis 缓存

一、淘汰策略

1.1 基本介绍

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 后新增)三种

默认情况下,Redis 在使用的内存空间超过 maxmemory 值时,并不会淘汰数据,也就是设定的 noeviction 策略。对应到 Redis 缓存,也就是指,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。Redis 用作缓存时,实际的数据集通常都是大于缓存容量的,总会有新的数据要写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,我们不把它用在 Redis 缓存中。

1.2 使用建议

到这里,我们就学完了除了使用 LFU 算法以外的 5 种缓存淘汰策略,我再给你三个使用建议:

建议一:优先使用 allkeys-lru 策略

这样,可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。

建议二:根据数据访问特征选择策略

  • 如果你的业务数据中有明显的冷热数据区分,我建议你使用 allkeys-lru 策略
  • 如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据就行

建议三:置顶需求使用 volatile-lru

如果你的业务中有置顶的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。


1.3 设置过期时间的淘汰策略

我们再分析下 volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 这四种淘汰策略。它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。

例如,我们使用 EXPIRE 命令对一批键值对设置了过期时间后,无论是这些键值对的过期时间是快到了,还是 Redis 的内存使用量达到了 maxmemory 阈值,Redis 都会进一步按照 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 这四种策略的具体筛选规则进行淘汰。

1.3.1 volatile-ttl

在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。

1.3.2 volatile-random

在设置了过期时间的键值对中,进行随机删除。

1.3.3 volatile-lru

会使用 LRU 算法筛选设置了过期时间的键值对。

1.3.4 volatile-lfu

会使用 LFU 算法选择设置了过期时间的键值对。

可以看到,volatile-ttl 和 volatile-random 筛选规则比较简单,而 volatile-lru 因为涉及了 LRU 算法,所以我会在分析 allkeys-lru 策略时再详细解释。volatile-lfu 使用了 LFU 算法,现在你只需要知道,它是在 LRU 算法的基础上,同时考虑了数据的访问时效性和数据的访问次数,可以看作是对淘汰策略的优化。


1.4 所有数据范围的淘汰策略

相对于 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 这四种策略淘汰的是设置了过期时间的数据,allkeys-lru、allkeys-random、allkeys-lfu 这三种淘汰策略的备选淘汰数据范围,就扩大到了所有键值对,无论这些键值对是否设置了过期时间。

它们筛选数据进行淘汰的规则是:

  • allkeys-random 策略:从所有键值对中随机选择并删除数据
  • allkeys-lru 策略:使用 LRU 算法在所有数据中进行筛选
  • allkeys-lfu 策略:使用 LFU 算法在所有数据中进行筛选

这也就是说,如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。当然,如果它的过期时间到了但未被策略选中,同样也会被删除。

1.4.1 LRU 算法原理

接下来,我们就看看 volatile-lru 和 allkeys-lru 策略都用到的 LRU 算法吧。LRU 算法工作机制并不复杂,我们一起学习下。

LRU 算法的全称是 Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。

LRU 算法的工作机制:

LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据。

我们看一个例子。我们现在有数据 6、3、9、20、5。如果数据 20 和 3 被先后访问,它们都会从现有的链表位置移到 MRU 端,而链表中在它们之前的数据则相应地往后移一位。因为,LRU 算法选择删除数据时,都是从 LRU 端开始,所以把刚刚被访问的数据移到 MRU 端,就可以让它们尽可能地留在缓存中。

其实,LRU 算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU 端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它。

1.4.2 Redis 中的 LRU 实现

不过,LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销。而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

所以,在 Redis 中,LRU 算法被做了简化,以减轻数据淘汰对缓存性能的影响。具体来说,Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。

然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。

注意: 不是链表就行了,要是以为底层是链表就完了。

当需要再次淘汰数据时,Redis 需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:能进入候选集合的数据的 lru 字段值必须小于候选集合中最小的 lru 值。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了 maxmemory-samples,Redis 就把候选数据集中 lru 字段值最小的数据淘汰出去。(有点小顶堆的味道了)

这样一来,Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。


1.5 LFU 算法介绍

1.5.1 LFU 算法原理

LFU 算法全称 Least Frequently Used(最近最不常用),根据数据的访问次数来淘汰数据。所以,LFU 算法会记录每个数据的访问次数,当一个数据被再次访问的时候,就会增加该数据的访问次数,这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相较于 LRU 算法更合理。

1.5.2 LFU 在 Redis 中的实现

LFU 算法相比于 LRU 算法的实现,多记录了数据的访问频次的信息。Redis 对象头中的 lru 字段,在 LRU 算法和 LFU 算法下使用方式并不相同:

在 LRU 算法中:

Redis 对象头的 24bits 的 lru 字段是用来记录 key 的访问时间戳。

在 LFU 算法中:

Redis 对象头的 24bits 的 lru 字段分成两段来存储:

  • 高 16bit 存储 ldt(Last Decrement Time):用来记录 key 的访问时间戳
  • 低 8bits 存储 logc(Logistic Counter):用来记录 key 的访问频次

1.5.3 带时间衰减的近似 LFU

Redis 实现的是”带时间衰减的近似 LFU(Approximate LFU)”,访问频次 logc 不是每次访问都 +1,而是基于概率的递增(Logistic Counter)。

Redis 的 LFU 实现并不是简单地记录访问次数,而是通过在对象头中保存访问频次和上次衰减时间,对访问频次进行时间衰减,主要是”冷却”历史热度。

类似 LRU 淘汰方式,LFU 也是选 key 来淘汰,在淘汰时,Redis 会对候选 key 先进行衰减计算,再根据当前有效访问频次做比较,最终淘汰频次最低的 key,从而避免历史热点长期占用缓存。


二、更新策略

2.1 常见的缓存更新策略

常见的缓存更新策略共有 3 种:

  • Cache Aside(旁路缓存策略)
  • Read / Write Through(读穿/写穿策略)
  • Write Back(写回策略)

实际开发过程中,Redis 和 MySQL 的更新策略使用的是 Cache Aside,另外两种用不了。


2.2 Cache Aside 旁路缓存策略

Cache Aside 旁路缓存策略是最常用的,就是只读缓存模式(还有一个是读写模式),应用程序直接与数据库、缓存交互,并负责对缓存的维护,该策略细分的话还可以分为读策略和写策略。

注意: 写策略的步骤不能倒过来,即不能先删除缓存再更新数据库,因为会造成缓存和数据库的数据不一致的问题。

Cache Aside 策略适合读多写少的场景,不适合写多的场景。先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力。


2.3 Read/Write Through 策略

Read/Write Through(读穿/写穿)策略的原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作让缓存来代理了。


2.4 Write Back 策略

Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新会采取批量异步更新的方式进行。

实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 没有异步更新数据库的功能。

这种策略是计算机体系结构中的设计,比如 CPU 缓存,操作系统的文件系统的缓存都采用了这种策略。

该策略适合写多的场景,因为实际上发生写操作的时候,只需要更新缓存,操作系统中就是 page cache,就立马返回了。比如我们写文件的时候,实际上是写入到文件系统的 page cache 缓存就返回了,并不会写入磁盘。

缺点:

  • 不过会导致数据不是强一致的,而且会有数据丢失的风险
  • 因为一旦缓存机器掉电,会造成原本缓存的脏数据没有及时刷盘,会导致部分数据丢失

三、经典问题

3.1 缓存雪崩

3.1.1 问题描述

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

缓存雪崩一般是由两个原因导致的,应对方案也有所不同,我们一个个来看。

3.1.2 原因一:大量数据同时过期

问题: 缓存中有大量数据同时过期,导致大量请求无法得到处理。

解决方案一:避免同时过期

首先,我们可以避免给大量的数据设置相同的过期时间。如果业务层的确要求有些数据同时失效,你可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟),这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。

解决方案二:服务降级

除了微调过期时间,我们还可以通过服务降级,来应对缓存雪崩。所谓的服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式:

  • 当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息
  • 当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取

这样一来,只有部分过期数据的请求会发送到数据库,数据库的压力就没有那么大了。

3.1.3 原因二:Redis 实例故障宕机

除了大量数据同时失效会导致缓存雪崩,还有一种情况也会发生缓存雪崩,那就是,Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。

解决方案一:服务熔断或请求限流

第一个建议,是在业务系统中实现服务熔断或请求限流机制。所谓的服务熔断,是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问。

再具体点说,就是业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。

解决方案二:构建高可靠集群

我给你的第二个建议就是事前预防。通过主从节点的方式构建 Redis 缓存高可靠集群。如果 Redis 缓存的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。


3.2 缓存击穿

3.2.1 问题描述

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。

缓存击穿的情况,经常发生在热点数据过期失效时。

3.2.2 解决方案

为了避免缓存击穿给数据库带来的激增压力,我们的解决方法也比较直接,对于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。


3.3 缓存穿透

3.3.1 问题描述

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求,这样一来,缓存也就成了”摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力。

那么,缓存穿透会发生在什么时候呢?一般来说,有两种情况:

  • 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据
  • 恶意攻击:专门访问数据库中没有的数据

3.3.2 解决方案

为了避免缓存穿透的影响,我来给你提供三种应对方案。

方案一:缓存空值或缺省值

第一种方案是,缓存空值或缺省值。一旦发生缓存穿透,我们就可以针对查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为 0)。紧接着,应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。

方案二:使用布隆过滤器

第二种方案是,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。

我们先来看下,布隆过滤器是如何工作的。布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。

当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

  1. 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值
  2. 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置
  3. 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作

如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。

当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。

图中布隆过滤器是一个包含 10 个 bit 位的数组,使用了 3 个哈希函数,当在布隆过滤器中标记数据 X 时,X 会被计算 3 次哈希值,并对 10 取模,取模结果分别是 1、3、7。所以,bit 数组的第 1、3、7 位被设置为 1。当应用想要查询 X 时,只要查看数组的第 1、3、7 位是否为 1,只要有一个为 0,那么,X 就肯定不在数据库中。

正是基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样一来,即使发生缓存穿透了,大量请求只会查询 Redis 和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用 Redis 实现,本身就能承担较大的并发访问压力。

方案三:请求入口检测

最后一种方案是,在请求入口的前端进行请求检测。缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以,一个有效的应对方案是在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。


3.4 布谷鸟过滤器

3.4.1 布谷鸟过滤器简介

布谷鸟过滤器(Cuckoo Filter)是对布隆过滤器的一种增强,支持删除,通过存储元素的”指纹”(指纹指的是使用一个哈希函数生成的 n 位比特位,n 的具体大小由所能接受的误判率来设置,论文中的例子使用的是 8bits 的指纹大小)和两个候选桶结构(桶的实现一般是二维数组,但逻辑上也可以看作一维数组,二维数组 = 桶数 × 每桶槽数,候选桶两个是对于一个元素说的,实际上就是一个二维数组),在同等误判率下空间更经济、查询更快,并且提供比布隆更低的 false positive 率/误判率。

3.4.2 插入机制

在布谷鸟过滤器中,插入元素需要计算两个候选桶 i1 和 i2:

  • i1:用 key 哈希得到,i1 = hash(key) % N
  • i2:为了避免再使用第二个完全独立的 hash 函数,Cuckoo Filter 用异或,f = fingerprint(key)(fingerprint 是 key 的压缩表示,只占几个比特,但能唯一(或高概率唯一)标识 key),i2 = (i1 ⊕ hash(f)) % N,不需要额外维护第二个 hash 函数,而且可逆

此时如果两个位置都为空则将元素指纹随机存入其中一个位置,如果只有一个位置为空则存入为空的位置,如果都不为空,则随机踢出一个元素,踢出的元素再异或哈希找到相应的桶的位置。

当然假如存在绝对的空间不足,那老是踢出也不是办法,所以一般会设置一个踢出阈值,如果在某次插入行为过程中连续踢出超过阈值,则进行扩容(其实就是击鼓传花,我来了就直接坐着,让别人滚)。

3.4.3 复杂度分析

插入复杂度比较高。随着插入元素的增多,复杂度会越来越高,因为存在桶满,踢出的操作,所以需要重新计算,但综合来讲复杂度还是常数级别。查找和删除都是在两个候选桶里遍历槽。

我帮你梳理清楚整个逻辑和底层实现:逻辑上只有一个 key hash,避免了双 hash(key),但实现上还会对指纹再做一次 hash,用于桶索引扰动。

3.4.4 数据结构

桶(Bucket):

存储若干个指纹(fingerprint slots),桶数 N 由设计容量和每桶槽数决定,同时考虑负载因子和误判率目标;通常取 2 的幂方便哈希取模。

每个桶有 b 个槽(slot),比如 4 个或 8 个。桶的数量 = N(过滤器大小),每个桶里有固定槽数。

1
2
3
4
bucket[0]   [f1, f2, f3, f4]
bucket[1] [f5, f6, f7, f8]
...
bucket[N-1] [fN-3, fN-2, fN-1, fN]

四、缓存策略总结

4.1 淘汰策略对比

策略类型 策略名称 适用场景 特点
不淘汰 noeviction 不适合缓存场景 内存满时返回错误
过期数据淘汰 volatile-random 数据有明确过期时间 随机删除过期数据
volatile-ttl 数据有明确过期时间 优先删除即将过期的数据
volatile-lru 有冷热数据区分 LRU 算法删除过期数据
volatile-lfu 访问频次差异大 LFU 算法删除过期数据
全部数据淘汰 allkeys-random 访问频率相近 随机删除任意数据
allkeys-lru 有明显冷热数据 LRU 算法删除任意数据
allkeys-lfu 访问频次差异大 LFU 算法删除任意数据

4.2 更新策略对比

策略名称 应用层交互 数据一致性 适用场景
Cache Aside 应用直接操作缓存和数据库 最终一致 读多写少,Redis+MySQL
Read/Write Through 应用只操作缓存 强一致 缓存代理数据库
Write Back 应用只操作缓存 弱一致 写多场景,CPU 缓存

4.3 经典问题对比

问题类型 问题描述 主要原因 解决方案
缓存雪崩 大量请求打到数据库 大量数据同时过期或实例宕机 随机过期时间、服务降级、高可用集群
缓存击穿 热点数据失效导致数据库压力 热点数据过期 热点数据不过期
缓存穿透 查询不存在的数据 恶意攻击或误操作 缓存空值、布隆过滤器、请求检测

4.4 实践建议

  1. 淘汰策略选择

    • 优先使用 allkeys-lru,适合大多数场景
    • 有置顶需求使用 volatile-lru
    • 访问频率相近使用 allkeys-random
  2. 更新策略选择

    • Redis+MySQL 场景使用 Cache Aside
    • 先更新数据库,再删除缓存
    • 避免先删除缓存再更新数据库
  3. 问题预防

    • 设置随机过期时间避免雪崩
    • 热点数据不设置过期时间避免击穿
    • 使用布隆过滤器避免穿透
    • 构建高可用集群提升可靠性
  4. 监控告警

    • 监控缓存命中率
    • 监控数据库压力
    • 设置合理的告警阈值
    • 定期检查缓存健康状态

五、总结

Redis 缓存的核心在于合理的淘汰策略、更新策略和问题预防:

  1. 淘汰策略:根据业务特点选择合适的策略,LRU 和 LFU 是最常用的算法
  2. 更新策略:Cache Aside 是最实用的策略,注意更新顺序避免数据不一致
  3. 经典问题:雪崩、击穿、穿透各有特点,需要针对性地预防和解决
  4. 布隆过滤器:是解决缓存穿透的利器,布谷鸟过滤器提供了更好的性能

理解这些核心概念和最佳实践,有助于我们构建高性能、高可用的缓存系统。