Redis 缓存应用深度解析
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 个哈希函数组成,可以用来快速判断某个数据是否存在。
当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
- 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值
- 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置
- 最后,我们把对应位置的 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 | bucket[0] [f1, f2, f3, f4] |
四、缓存策略总结
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 实践建议
淘汰策略选择:
- 优先使用 allkeys-lru,适合大多数场景
- 有置顶需求使用 volatile-lru
- 访问频率相近使用 allkeys-random
更新策略选择:
- Redis+MySQL 场景使用 Cache Aside
- 先更新数据库,再删除缓存
- 避免先删除缓存再更新数据库
问题预防:
- 设置随机过期时间避免雪崩
- 热点数据不设置过期时间避免击穿
- 使用布隆过滤器避免穿透
- 构建高可用集群提升可靠性
监控告警:
- 监控缓存命中率
- 监控数据库压力
- 设置合理的告警阈值
- 定期检查缓存健康状态
五、总结
Redis 缓存的核心在于合理的淘汰策略、更新策略和问题预防:
- 淘汰策略:根据业务特点选择合适的策略,LRU 和 LFU 是最常用的算法
- 更新策略:Cache Aside 是最实用的策略,注意更新顺序避免数据不一致
- 经典问题:雪崩、击穿、穿透各有特点,需要针对性地预防和解决
- 布隆过滤器:是解决缓存穿透的利器,布谷鸟过滤器提供了更好的性能
理解这些核心概念和最佳实践,有助于我们构建高性能、高可用的缓存系统。