使用 Redis 做缓存时,我们通常不会希望一条缓存永久存在。
例如用户信息可以缓存 30 分钟:
|
1 2 |
SET user:1001 "..." EXPIRE user:1001 1800 |
30 分钟以后,这个 Key 就过期了。
但这里会产生一个很有意思的问题:
时间到了以后,Redis 是怎么知道这个 Key 过期了,又是什么时候真正把它删除的?
另外还有一个经常和过期删除混在一起的问题:
如果 Key 都没过期,但是 Redis 内存已经满了,又应该删除谁?
这实际上是两套完全不同的机制:
|
1 2 3 4 5 6 7 8 |
Key 到期 ↓ 过期删除策略
内存不足 ↓ 内存淘汰策略 |
TTL 全称是 Time To Live,也就是 Key 还能存活多长时间。
例如:
|
1 2 |
SET token:1001 "abc" EXPIRE token:1001 60 |
表示 token:1001 在 60 秒以后过期。
可以使用:
|
1 |
TTL token:1001 |
查看剩余时间。
如果返回 45,就表示还剩大约 45 秒。
TTL 还有两个比较特殊的返回值:-1 表示 Key 存在,但是没有设置过期时间;-2 表示 Key 已经不存在。
Redis 还支持直接在 SET 时设置 TTL:
|
1 |
SET token:1001 "abc" EX 60 |
也支持毫秒级的 PEXPIRE、PTTL。
不一定。
假设:
|
1 |
SET code:1001 "9527" EX 60 |
60 秒过去以后,从逻辑上 code:1001 已经过期,不应该再被用户读取。
但是这不意味着时间刚到第 60 秒的那一瞬间,Redis 一定立刻执行了一次删除操作。
如果 Redis 给每一个 Key 都设置一个定时器:
|
1 2 3 4 5 6 7 8 9 10 11 |
Key A 到时间 ↓ 触发定时器 ↓ 删除
Key B 到时间 ↓ 触发定时器 ↓ 删除 |
假设存在几百万个带 TTL 的 Key,就需要维护大量定时任务,成本很高。
因此 Redis 并没有采用“每一个 Key 一个定时器”的方式,而是把过期删除主要分成两种机制:
被动过期 + 主动过期。
我们平时也经常把被动过期称为 惰性删除。
假设:
|
1 |
SET user:1001 "张三" EX 60 |
60 秒已经过去。
但是 Redis 可能暂时还没有真正把这个 Key 从内存结构中移除。
这时候客户端执行:
|
1 |
GET user:1001 |
Redis 发现 user:1001 已经过期,于是:
|
1 2 3 4 5 6 7 8 9 |
客户端访问 Key ↓ 检查过期时间 ↓ 发现已经过期 ↓ 删除 Key ↓ 返回不存在 |
这就是惰性删除。
可以简单理解:
平时先不管,等有人访问这个 Key 时,如果发现已经过期,再把它删除。
不行。
假设一个 Key:
|
1 |
SET temp:data "xxx" EX 60 |
60 秒以后过期。
但是从此以后再也没有任何客户端访问它。
那么如果只有惰性删除:
|
1 2 3 4 5 6 7 |
Key 已经过期 ↓ 没人访问 ↓ 永远不会触发检查 ↓ 一直占用内存 |
大量这样的 Key 积累以后,就会产生严重的内存浪费。
所以 Redis 还需要第二种机制:主动过期。
Redis 会周期性检查一部分设置了过期时间的 Key。
可以简单理解:
|
1 2 3 4 5 6 7 |
Redis 周期性运行过期检查 ↓ 从带 TTL 的 Key 中抽取一部分 ↓ 检查是否已经过期 ↓ 过期 → 删除 |
注意这里不是每隔一段时间扫描 Redis 中所有 Key。
如果 Redis 有一亿个 Key,每次都全量扫描,开销会非常大。
所以 Redis 采用的是:
抽样检查 + 根据过期情况继续检查。
这样可以在释放过期 Key 和避免 CPU 消耗过大之间取得平衡。
因为两种策略刚好互相补充。
只有惰性删除的问题是:
没人访问的过期 Key 可能一直占内存。
如果主动过期每次都完整扫描所有 Key,又会导致 CPU 开销太大。
所以 Redis 采用:
|
1 2 3 4 5 6 7 8 9 |
惰性删除 ↓ 访问 Key 时顺手检查
+
主动过期 ↓ 后台周期性抽样检查 |
既不用给每个 Key 创建单独定时器,也不需要不断全量扫描整个数据库。
不是。
这是这一篇最重要的区别。
假设 Redis 中有:
|
1 2 3 4 |
A:还有 10 分钟过期 B:还有 1 小时过期 C:永不过期 D:永不过期 |
现在 Redis 内存已经达到上限。
虽然这些 Key 一个都没有真正到期,Redis 仍然可能必须删除一部分数据,为新数据腾出空间。
这时候发生的不是 Expiration,而是 Eviction,也就是内存淘汰。
两者区别可以概括成:
| 机制 | 为什么删除 Key |
|---|---|
| 过期删除 | Key 的 TTL 到期 |
| 内存淘汰 | Redis 达到 maxmemory,需要释放空间 |
所以一定要记住:
过期删除看时间,内存淘汰看内存压力。
Redis 可以通过 maxmemory 设置允许用于数据集的最大内存。
例如:
|
1 |
maxmemory 2gb |
表示 Redis 使用的数据内存达到这个限制以后,需要根据配置决定接下来怎么办。
可以查看:
|
1 |
CONFIG GET maxmemory |
也可以动态设置:
|
1 |
CONFIG SET maxmemory 2gb |
当 Redis 达到 maxmemory 限制,需要继续写入新数据时,就会根据 maxmemory-policy 决定是否以及如何淘汰 Key。
maxmemory-policy 决定:
内存满了以后,到底淘汰谁?
例如:
|
1 |
maxmemory-policy allkeys-lru |
表示 Redis 达到内存限制以后,从所有 Key 中选择近似最久没有使用的数据进行淘汰。
常见策略主要围绕 LRU、LFU、Random、TTL 等方式展开。
noeviction 的意思很直接:
谁也不淘汰。
当 Redis 达到 maxmemory 后,再执行需要增加数据的命令时,会返回错误。
可以理解:
|
1 2 3 4 5 |
Redis 内存达到上限 ↓ 不删除任何 Key ↓ 新的写入无法继续增加数据 |
这种策略适合数据不能随便丢失的场景。
如果 Redis 本身就是纯缓存,则通常会考虑允许淘汰旧缓存。
allkeys-lru 可以拆开理解。
allkeys 表示所有 Key 都可能成为淘汰候选。
LRU 是 Least Recently Used,也就是最近最少使用。
例如:
|
1 2 3 4 |
A:刚刚访问过 B:1 秒前访问 C:10 分钟没访问 D:1 小时没访问 |
内存满时,Redis 会倾向于淘汰类似 D 这样的 Key。
所以 allkeys-lru 很适合经典缓存场景:
热点数据经常访问,冷数据逐渐被淘汰。
不是。
如果想实现完全精确的 LRU,就需要维护严格的访问顺序:
|
1 2 3 4 5 6 7 8 9 |
最近访问 ↓ A B C D E ↓ 最久没访问 |
每一次读写都可能需要维护这个结构,会增加额外的内存和 CPU 开销。
Redis 使用的是近似 LRU。
Redis 会对部分 Key 进行采样,再从候选 Key 中选择更符合淘汰条件的对象。
所以不能理解成:
Redis 每次一定能够找到整个数据库中绝对最久没有访问的那个 Key。
它追求的是:
用较低的开销获得足够接近 LRU 的效果。
LFU 全称是 Least Frequently Used。
与 LRU 的区别在于:
LRU 关注最近多久没有访问。
LFU 关注访问频率高不高。
例如:
|
1 2 3 4 5 6 7 |
Key A: 刚刚访问过 但历史上一共只访问过 2 次
Key B: 稍早访问 但是一直被频繁访问 |
使用 LRU 时,A 看起来更加“新”。
使用 LFU 时,B 会被认为更加热门。
所以可以简单记:
LRU 看“最近什么时候用过”,LFU 看“经常不经常用”。
Redis 的 LFU 还会进行频率衰减,避免一个过去特别热门、现在已经无人访问的 Key 永远保持很高权重。
如果业务认为:
最近访问过的数据,更可能在接下来继续被访问。
可以考虑 LRU。
如果业务中的热点比较稳定,认为:
长期访问频率更能体现 Key 的价值。
可以考虑 LFU。
例如某条新闻上午突然爆火、下午已经无人访问,如果只记录历史访问总次数,它可能一直显得很热门。
所以 Redis 的 LFU 会随着时间对访问频率进行衰减。
allkeys-random 就是从所有 Key 中随机淘汰。
例如:
|
1 2 3 4 5 |
A B C D E |
Redis 可能随机选择 C 删除。
它不关心这个 Key 最近有没有访问,也不关心它的访问频率。
算法简单,但如果缓存存在明显冷热差异,通常没有 LRU/LFU 那么有针对性。
前面讲的是 allkeys-lru、allkeys-lfu、allkeys-random。
其中 allkeys 表示:
所有 Key 都可以成为候选。
而 volatile-* 只会从设置了 TTL 的 Key 中进行选择。
例如:
|
1 2 3 4 |
A:设置 TTL B:设置 TTL C:永久 Key D:永久 Key |
如果使用 volatile-lru,Redis 只会在 A 和 B 中按照 LRU 思路选择淘汰对象。
C 和 D 不参与。
所以可以直接记:
|
1 2 3 4 5 6 7 |
allkeys ↓ 所有 Key 都参与
volatile ↓ 只有设置 TTL 的 Key 参与 |
这三个其实很好理解。
volatile-lru:只在带 TTL 的 Key 中淘汰最近最少使用的。
volatile-lfu:只在带 TTL 的 Key 中淘汰访问频率较低的。
volatile-random:只在带 TTL 的 Key 中随机淘汰。
所以真正需要理解的是:
前半部分决定候选范围,后半部分决定具体怎么选。
volatile-ttl 同样只考虑设置了过期时间的 Key。
但是它不看 LRU,也不看访问频率,而是优先选择剩余 TTL 更短的 Key。
例如:
|
1 2 3 |
Key A:还剩 10 秒 Key B:还剩 5 分钟 Key C:还剩 1 小时 |
A 本来就快过期,因此更适合作为优先淘汰对象。
很多老的 Redis 面试资料会总结成经典的 8 种:
|
1 2 3 4 5 6 7 8 |
noeviction allkeys-lru allkeys-lfu allkeys-random volatile-lru volatile-lfu volatile-random volatile-ttl |
但是这个说法和 Redis 版本有关。
较新的 Redis 又增加了 LRM(Least Recently Modified),例如 allkeys-lrm 和 volatile-lrm。
LRM 关注的是 Key 最近一次修改时间,而不是最近一次访问时间。
所以学习时不要把:
Redis 永远只有 8 种淘汰策略
当成绝对结论。
更重要的是理解这些策略的分类方式。
假设 Redis 使用:
|
1 |
maxmemory-policy volatile-lru |
但是数据库中的所有 Key 都没有设置 TTL。
那么 volatile-lru 根本找不到任何候选对象。
最终效果会类似 noeviction:
Redis 无法通过淘汰 Key 继续释放空间,新的写入可能因为达到内存限制而失败。
所以使用 volatile-* 的前提是:
Redis 中确实存在足够的带 TTL Key。
如果 Redis 完全用于缓存,数据丢失后可以从数据库重新构建,那么通常可以考虑 allkeys-lru 或 allkeys-lfu。
如果 Redis 中同时存在:
|
1 2 3 |
可以淘汰的缓存 + 不能随便淘汰的数据 |
可以利用 TTL 与 volatile-* 进行一定程度的区分。
不过从架构设计上来说,缓存数据和重要数据如果淘汰要求完全不同,往往更适合拆分到不同 Redis 实例中。
假设:
|
1 |
SET user:1 "A" EX 3600 |
这个 Key 还有 30 分钟才过期。
但是 Redis 内存满了,并且使用 allkeys-lru。
如果这个 Key 很久没有访问,那么:
即使 TTL 还有 30 分钟,它也可能提前被淘汰。
所以:
|
1 2 3 4 5 6 7 8 |
TTL ↓ 最多允许存在多久
Eviction ↓ 内存不够时可能提前淘汰 |
因此设置 1 小时 TTL,并不代表这个 Key 一定会存在整整 1 小时。
Key 从逻辑上已经到期,不代表那一瞬间一定已经完成实际删除。
如果这个 Key 没有再次被访问,惰性删除就不会触发。
主动过期又采用抽样和渐进处理,并不是到某个时间点突然扫描所有 Key。
所以短时间内可能出现:
|
1 2 3 4 5 |
一些 Key 已经过期 ↓ 暂时还没有被清理 ↓ 仍占用一部分内存 |
随着主动过期继续执行,或者这些 Key 再次被访问,它们会逐渐被删除。
假设业务一次写入 100 万个缓存,并且全部设置:
TTL = 30 分钟
那么 30 分钟以后,大量 Key 会在非常接近的时间同时过期。
Redis 自身需要短时间处理大量过期数据。
同时业务发现缓存不存在,又可能大量请求数据库。
这就可能进一步引发后面要学习的 缓存雪崩。
因此大量同类缓存通常不会把 TTL 设置得完全一样,而是加入一定随机值。
例如:
|
1 |
TTL = 1800 + random(0, 300) |
把过期时间适当打散。
例如面试中可能问:
Redis 设置了 EXPIRE,过期以后是通过 LRU 删除吗?
不是。
EXPIRE 属于 TTL 过期机制,主要通过惰性删除和主动过期进行处理。
LRU、LFU 则属于内存淘汰机制,主要在 Redis 达到 maxmemory、需要释放内存时发挥作用。
所以:
|
1 2 3 4 5 6 7 8 |
EXPIRE ↓ 时间到了
LRU / LFU ↓ 内存不够了 |
两者触发条件完全不同。
假设:
|
1 |
SET user:1001 "..." EX 3600 |
这个 Key 之后可能经历:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
创建 Key ↓ 设置 TTL = 1 小时 ↓ 正常使用 │ ├──────────────────┐ ↓ ↓ TTL 到期 内存先满 ↓ ↓ 过期机制 maxmemory-policy ↓ ↓ 惰性删除 LRU / LFU / Random ... + 主动过期 ↓ ↓ 删除 Key 淘汰 Key |
所以一个 Key 消失,并不一定意味着 TTL 到期。
也可能是:
Redis 因为内存压力提前把它淘汰了。
第一,Key 过期并不代表到期瞬间一定完成删除,Redis 会通过惰性删除和主动过期配合清理。
第二,过期删除和内存淘汰不是一回事。前者由 TTL 触发,后者由 maxmemory 压力触发。
第三,设置了 TTL 的 Key 也可能在 TTL 到期以前就因为内存不足被淘汰。
第四,Redis 的 LRU 和 LFU 都是近似实现,不是为了追求绝对精确而维护高成本的全局结构。
第五,volatile-* 只会从带 TTL 的 Key 中选择;如果没有满足条件的 Key,就可能无法通过淘汰继续释放空间。
第六,“Redis 有 8 种淘汰策略”是经典版本中的常见总结,具体策略数量需要结合 Redis 版本来看。