ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis过期删除与内存淘汰:从LRU到LFU的完整机制解析

Redis过期删除与内存淘汰:从LRU到LFU的完整机制解析

1. 先从面试题说起:这两件事为什么总被放在一起问

Redis 的过期删除和内存淘汰,是面试里出现频率相当高的一组问题。不少同学背过八股文,能说出“惰性删除”“定期删除”“LRU”这些词,但一旦被追问“过期键到底什么时候被删?”“Redis 为什么不用定时删除?”“淘汰策略里 volatile-lru 和 allkeys-lru 区别在哪?”就卡住了。实际上这两个机制解决的是完全不同的两类问题:过期删除管的是“到了过期时间的 key 怎么处理”,内存淘汰管的是“内存满了以后怎么腾空间”。前者是时间维度上的清理,后者是容量维度上的取舍。面试官把两者放在一起问,本质上是在考察你对 Redis 内存管理和数据生命周期的整体理解,而不只是背两个名词。

我多年前带过一个刚入行的同学,简历上写着“熟悉 Redis”,第一次模拟面试时我把这两个概念放在同一个问题里问,他立刻就把“过期删除”和“内存淘汰”混为一谈,说“内存满了就把过期的删除掉”。这就是典型的只背答案、没理解机制。这类问题面试官只要换个角度问就露馅。所以这篇文章我会把两条链路拆开讲,再把它们交汇的地方讲清楚,看完之后你不仅能答上来,还能给出让面试官眼前一亮的延伸答案。

2. 过期删除:Redis 是怎么让“到期的 key”消失的

2.1 被动删除:只有被访问到,才想起来清理

Redis 对过期 key 的第一层处理叫惰性删除。当客户端尝试访问一个 key 时,Redis 会先检查这个 key 是否设置了过期时间,如果已经过期,就直接返回 nil,同时把这个 key 从内存里删掉。注意,这里有个容易被忽略的细节:对于 String 类型的 key,直接读的时候会触发检查;对于 Hash、Set、List 这类容器类型,过期检查是在整个 key 级别做的,而不是在元素级别。也就是说,一个 Hash key 过期之后,你在上面执行 hget、hgetall、hlen 都会先触发 key 的过期检查,返回空结果,而不是说某些 field 还在。

这种策略的好处是省 CPU,不浪费任何一次扫描去处理没人访问的 key;坏处也显而易见:如果一个 key 过期后一直没人访问,它就会一直占着内存不释放。举个极端例子,一个几 GB 的缓存 key 设置了 1 分钟过期,但之后没有任何请求再碰到它,那这块内存就一直是“僵尸状态”。所以惰性删除必须搭配第二层机制来兜底,不能靠它单打独斗。

注意:惰性删除不只是读操作触发,写操作同样会触发。比如对一个已过期的 key 执行 set,Redis 会先删掉旧 key 再写入新值。这一点在面试里可以主动提出来,能体现你对源码行为的熟悉。

2.2 主动删除:定时抽样的“碎片化治理”

第二层机制叫定期删除。Redis 的每个数据库实例(默认是 16 个)里都维护着一个持有过期时间的 key 集合,内部结构是 dict。Redis 后台会周期性地对这些 key 做抽样检查,默认情况下每秒执行 10 次,每次抽查一批 key,把所有已经过期的 key 删掉。

具体的执行逻辑是:从过期 key 集合里随机取出一批(默认 20 个),遍历删除其中已经过期的;如果本轮删除的比例超过四分之一,说明过期 key 密度还很高,就继续重复这一轮,直到删除比例降到四分之一以下或者本次执行总时长超过了某个时间预算。这里为什么用“抽样”而不是“全量扫描”?道理很简单:如果一个实例里存了几千万个带过期时间的 key,全量扫一遍会阻塞主线程,对线上服务是灾难性的。抽样 + 比例控制,就是典型的“用概率换性能”做法,让 Redis 在清理垃圾的同时不至于把自己卡死。

这里的“时间预算”也很关键。定期删除不是一次性把所有过期 key 删光,而是每次只干一小段“碎片化”的活。比如之前提到的默认 10 次/秒的执行频率,它内部把 1 秒拆成 10 份,每次执行最多占用一部分时间片,宁可这轮没删完,也不能影响主线程的正常请求处理。

2.3 一个容易被问翻车的点:持久化时过期 key 如何处理

很多人在聊完惰性删除和定期删除之后就觉得自己过关了,结果面试官追加一句:“那 RDB 持久化和 AOF 持久化的时候,过期 key 怎么处理?”这里我给你一个极其清晰的回答框架:

  • 生成 RDB 文件时,已经过期的 key 不会写入 RDB 文件。也就是说,持久化快照天然就把过期数据过滤掉了。
  • 从 RDB 文件加载数据时,即使文件中存在过期 key(比如文件是旧版本生成的,某些 key 在写文件之后才过期),Redis 也不会把它们加载进内存,加载时会再检查一次过期时间。
  • AOF 文件的情况稍微复杂。如果用的是 AOF 追加写模式,Redis 在某个 key 被惰性删除或定期删除之后,会额外记录一条 DEL 命令到 AOF 文件里,确保 AOF 重放时也不会残留这个 key。
  • 当开启 AOF 重写时,Redis 会扫描当前数据,已经过期的 key 不会被写进重写后的 AOF 文件。

这套回答展示的不只是“知识点记忆”,而是你对 Redis 数据落盘链路里“时间戳”和“数据有效性”的处理逻辑有整体认识,面试官对你的评价会明显不一样。

2.4 主从复制模式下,过期删除是怎么传的

这个问题再追问一层就到了主从。Redis 的主从节点之间不会把“逻辑删除”这个动作传成“这个 key 还剩多长时间过期”这种消息,而是由主节点负责实际触发过期删除,然后向从节点发送 DEL 命令。这里有一个容易被误解的关键点:从节点遇到客户端读取一个已过期的 key 时,在未收到主节点 DEL 命令之前,它依然会返回这个 key 的值。

从节点不应该自己执行过期删除来阻止读请求,因为主从之间靠复制流对齐数据状态,从节点的数据必须严格跟随主节点。也就是说,从节点的过期 key 只有在主节点真正删除了“事实数据”并同步 DEL 命令之后,才会从从节点的内存里消失。这个机制保证了主从数据的一致性,但也带来一个实际影响:如果主节点长时间不访问某个过期 key,它就一直存在,从节点上也会一直能看到该过期数据。因此,要求“绝对一致的过期时间”的业务场景,不能依赖 Redis 过期机制,需要应用层做校验。

3. 内存淘汰:内存不够了,谁该“牺牲”掉

3.1 先搞清楚 maxmemory 这个“天花板”

内存淘汰的前置条件是 Redis 配置了内存上限maxmemory。没有设置这个值,64 位系统下默认是 0,表示不限制内存使用,Redis 就会一直往上吃内存直到操作系统把它 OOM 掉。生产环境一般都会设置,比如maxmemory 4gb代表最多使用 4GB。

一旦内存达到这个上限,新写入操作就会触发内存淘汰策略,Redis 会按照你配置的策略删除某些 key,腾出空间来接受新数据。注意这里的“写入操作”包含 set、lpush、sadd、zadd 等,而读操作不会触发淘汰。因为读操作不会让内存继续增长,不需要腾空间。

另一个容易忽略的点:内存淘汰并不是“把所有 key 一次性清理到低于 maxmemory”,而是“在每次写入时检查,如果当前内存超过上限,就执行淘汰,直到内存降到上限以下,再继续执行当前写入”。所以实际运行中,内存会围绕 maxmemory 值在一个小范围内波动,而不是一条严格水平线。

3.2 八种淘汰策略,一张表看懂

Redis 的maxmemory-policy配置项一共支持 8 种策略,名字相近、容易搞混,我用一个表格帮你在几分钟内建立清晰的区分框架。

策略作用范围淘汰依据使用场景
noeviction全部 key不淘汰,写入直接报错只读缓存、不允许丢数据的场景
allkeys-lru全部 keyLRU(最近最少使用)缓存业务通用默认项
allkeys-lfu全部 keyLFU(最不经常使用)访问热度差异明显的缓存场景
allkeys-random全部 key随机淘汰访问均匀、无热点概念的场景
volatile-lru仅限设置了过期时间的 keyLRU只淘汰“本身就想让它消失”的 key
volatile-lfu仅限设置了过期时间的 keyLFU带过期时间的缓存且访问差异明显
volatile-random仅限设置了过期时间的 key随机淘汰有过期时间的 key 且无明显热点
volatile-ttl仅限设置了过期时间的 key剩余 TTL 最短优先淘汰快过期的 key

面试时你不需要把 8 个策略背得一字不差,更重要的是讲清楚两组关键区别:第一组是allkeys 和 volatile 的区别,前者在所有 key 里挑牺牲品,后者只在设置过过期时间的那部分 key 里挑;第二组是lru / lfu / random / ttl 的区别,即到底凭什么决定“谁先死”。把这两条线串起来,策略表就变成了一张可以推导的规则表。

3.3 近似 LRU:Redis 为什么不用真正的 LRU

纯正的 LRU(Least Recently Used)算法要求在每次访问时记录 key 的访问时间,内存淘汰时找出最久没被访问的那个。理论上这需要维护一个按访问时间排序的链表,每次访问一个 key 就要把它移动到链表头部。如果 Redis 真的这样做,每次 get / set 除了操作内存数据以外,还要维护链表节点位置,主线程额外开销会非常大,高性能打折扣。

Redis 的做法是近似 LRU:它不为每个 key 精确维护全局访问顺序,而是在需要进行淘汰时,从配置的所有 key 空间中随机抽取若干个 key(默认采样 5 个,由maxmemory-samples控制),比较它们各自的空闲时间(idle time),也就是“距离上次访问过去了多久”,然后淘汰空闲时间最长的那一个。这个过程的概率意义是:sample 数越大,淘汰结果越接近真正的 LRU,但付出的 CPU 成本也越高。默认 5 个采样是官方在精度和性能之间取的平衡点。

这也解释了为什么 Redis 官方文档说:如果内存紧张、抽样样本太小,近似 LRU 有可能淘汰掉一个最近刚被访问过的 key,只是概率较低。面试场景下,能说出“Redis 用的是近似 LRU,不是精确 LRU,靠随机采样 + 空闲时间比较来近似全局最久未访问”这句话,就说明你是真的理解了这个设计,而不是只背了算法名。

3.4 LFU 策略:解决“历史热点”问题

Redis 4.0 引入了 LFU(Least Frequently Used)淘汰策略,这是对 LRU 一个重要缺陷的修正。LRU 只看“最近访问时间”,不看“访问频率”,所以一个 key 在过去被高频访问、现在是热点衰减后的缓存,只要最近被偶尔访问过一次,空闲时间很短,就很容易在淘汰时“幸存”下来。但对业务来说,它早就不是高价值数据了。

LFU 给每个 key 维护一个访问频次计数器,淘汰时优先淘汰访问次数少的。Redis 的实现里,这个计数器不是简单的整数累加,它采用了“衰减机制”:计数会随着时间推移按一定概率降低,防止一个 key 在很久以前被疯狂访问、之后一直不访问,却因为计数器数值很高而永远不被淘汰。用生活化的例子来说,LFU 像一个“关注热度排行榜”,一个话题即使昨天是热搜第一,今天没讨论度了,排名也会掉下去,而不是永远霸榜。

在面试里主动提到 LFU 以及它和 LRU 的适用差异,属于加分项。面试官会认为你不是只会背默认配置,而是真的用过不同场景的数据特征去选策略。

3.5 淘汰出的空间去哪了:为什么说 Redis 的“缓存”本身就是有损服务

很多初学者对淘汰有一个错误预期:既然配了 maxmemory,内存就一定能精确稳定在上限附近,被淘汰的 key 空间也立刻就能复用。实际中你会发现 Redis 淘汰 key 是有代价的:每次淘汰至少释放一个 key 的空间,但如果淘汰的是大 key,释放比较顺利;如果淘汰的全是几十字节的小 key,那么每释放出几 MB 空间就要淘汰大量 key,每一次淘汰都要消耗主线程的 CPU 和额外的内存管理操作。

另外,淘汰完成后 Redis 并不会把内存归还给操作系统,而是放进自己内部的内存分配器继续复用。这些细节解释了为什么生产上内存告警时,不应该只盯 maxmemory 数值,还要看INFO memory输出里的 used_memory、used_memory_rss、mem_fragmentation_ratio 等指标。面试官如果在场景题里问“为什么内存涨了但淘汰好像没生效”,你如果能答到“淘汰触发依赖写入动作、而不是实时监控内存水位”这一层,就是碾压级别的回答。

4. 面试现场:从原理到追问的完整应对框架

4.1 基础问题:说说 Redis 的过期删除策略

回答这类问题,我建议用“两条链路 + 一个兜底”的结构。先说惰性删除,强调“访问时检查,过期即删,节省 CPU 但不主动清理”;再说定期删除,强调“随机抽样 + 时间预算 + 删除比例控制,兼顾清理效率与主线程性能”;最后点一个兜底——持久化和主从复制场景下是如何保持数据一致的。这个结构的好处是,逻辑层层递进,从内存到磁盘再到多实例,让面试官感受到你的完整知识面。

4.2 进阶问题:为什么 Redis 不用定时删除

面试官问“为什么不用定时删除”,其实是在考察你对“单线程模型”的理解。如果 Redis 为每个 key 都设置一个定时器,到了过期时间立刻删除,CPU 会被大量定时任务打断;而且 key 数量巨大时,维护定时器的内存开销也高得离谱。Redis 是单线程处理命令的,任何阻塞操作都会拖慢所有客户端,所以它选择了“以访问触发的惰性删除为主,以时间采样的定期删除为辅”的组合方案。这个组合本质上是 CPU 和内存之间的交易:用可能的内存浪费换取 CPU 的高效。

4.3 场景问题:线上缓存命中率低怎么排查

这种题没有标准答案,考的是你做没做过真实运维。我会这样回答:先看INFO stats里的keyspace_hits和keyspace_misses,计算命中率;再用INFO memory看内存水位和淘汰次数;再看maxmemory-policy是不是配置合理。如果发现淘汰次数很高但命中率低,可能是 maxmemory 设置太小,也可能是淘汰策略不合适——比如全是热点数据却配了 random 策略,冤枉了“重要 key”。另一个思路是检查是否存在大量设置了相同过期时间的 key,导致同一时间集体过期,形成周期性缓存雪崩。

这个回答的核心逻辑是:不要凭空猜,先用 Redis 自带的指标锁定位问题范围,再根据业务特征调参数,并且观察调整前后的趋势。

5. 生产实践:配置、命令与避坑经验

5.1 三个关键参数怎么配合

我们把redis.conf里最关键的三个参数一起看:maxmemory、maxmemory-policy、maxmemory-samples。我的建议是:

  • maxmemory不要设成“恰好等于业务预估容量”,要给 buffer。比如预估峰值 3GB,可以设置maxmemory 4gb,留出一定余量,否则淘汰会过于频繁,命中率暴跌。
  • maxmemory-policy缓存类业务一般用allkeys-lru;如果对数据有强一致性要求、宁可写入报错也不能丢数据,用noeviction;如果热点非常集中且访问趋势易衰减,用allkeys-lfu。
  • maxmemory-samples默认 5 一般够用,如果内存相对充足而你又想让淘汰更精确,可以调到 10,代价是每次淘汰时的计算量增加,性能要求极高的场景不建议调太高。

5.2 常用命令速查,面试和排查都靠它

下面是我认为最值得记住的几组命令:

  • CONFIG GET maxmemory/CONFIG GET maxmemory-policy查看当前配置。
  • CONFIG SET maxmemory-policy allkeys-lru临时动态修改淘汰策略,不用重启。
  • EXPIRE key seconds给 key 设置过期时间;TTL key查看剩余秒数,返回 -1 表示永不过期,-2 表示 key 不存在或已过期。
  • PERSIST key去掉过期时间,让 key 变成持久 key。
  • INFO stats查看expired_keys(累计过期删除数量)和evicted_keys(累计淘汰数量)。
  • INFO memory查看used_memory、maxmemory、mem_fragmentation_ratio。
  • MEMORY USAGE key查看某个 key 占用的字节数。

我在排查问题的时候,第一反应通常不是看代码写的对不对,而是先拉INFO stats的expired_keys和evicted_keys。这两个数字能告诉你 Redis 内部到底在发生什么。如果evicted_keys持续快速上涨,说明 maxmemory 设置不合理或者业务数据增长比预期快;如果expired_keys大量增长,说明过期时间设置过于集中或生命周期设计不合理。

5.3 我踩过的一个坑:把 volatile-lru 当成“只淘汰带过期时间的 key 很安全”

有一次负责一个内部项目,缓存 key 有两种,一种设了过期时间,一种没设,但没设的那部分其实是“需要长期保留但也不是绝对不可丢”的。我当时的想法是:volatile-lru 只动带过期时间的 key,不带过期时间的 key 永远不会被淘汰,很安全。结果内存持续上涨到 maxmemory 之后,设了过期时间的 key 被疯狂淘汰,缓存命中率大幅下降,而很多不带过期时间的 key 大量堆积,占着内存不放。

后来我们分析发现,业务里 80% 的热点数据恰恰是那些忘设过期时间的 key。于是把策略改为allkeys-lru,让所有 key 在内存紧张时都参与淘汰,优先淘汰真正不活跃的数据。这个案例给我的教训是:策略选择不是背定义,而是要看业务里“哪些数据可以被牺牲”以及“热点集中在哪类 key 上”。

5.4 避免“缓存雪崩”“缓存击穿”的过期时间设计建议

讨论过期删除时,面试官一定会顺带问缓存雪崩和击穿。这里我给你一条非常实用的设计建议:给同一业务用途的 key 设置过期时间时,不要用同一个固定值,而是在固定值基础上加一个随机偏移量。比如基础过期时间 3600 秒,实际设置时是3600 + random(0, 300)秒。这样即使大量 key 在同一时间被写入,过期时间点也会被分散开,避免出现“同一秒内大面积 key 同时过期”导致的雪崩。这个方法成本极低、效果显著,是线上非常常用的技巧。

对于热点 key 的击穿问题,常见做法是互斥锁重建缓存,或者把热点 key 的过期时间直接设为永不过期、由后台任务定期更新。面试时能说出“热点 key 不设过期时间,用后台异步更新”这种带实战色彩的操作,会比单纯背“缓存击穿是大量请求同时打到数据库”显得有经验得多。

5.5 如果 Redis 设置了 noeviction,内存满了会发生什么

这是面试中经常作为收尾的一个问题。noeviction策略下,当内存达到 maxmemory 上限时,所有会增加内存的写命令都会返回错误,类似OOM command not allowed when used memory > 'maxmemory'。读操作和 del、expire 这类能释放内存的命令不受影响。这意味着应用层必须做好错误处理,不能盲目相信写入一定成功。生产环境中,如果业务对缓存可丢性要求高,强烈不建议用 noeviction,因为它的行为对业务逻辑侵入性太强,容易引发线上报警甚至数据写入链路故障。

6. 底层原理补充:过期字典、LRU 时钟和内存分配器

6.1 过期字典(expires dict)是怎么工作的

Redis 的每个数据库实例(redisDb 结构)里,除了保存所有 key-value 数据的 dict 之外,还有一个独立的 dict 专门保存 key 和它的过期时间,这个 dict 叫 expires。当执行EXPIRE key 100时,Redis 会在这个 dict 里写入 key 对应的过期时间戳;当 key 被删除,或者PERSIST去掉过期时间,Redis 会同步从这个 dict 里移除对应条目。惰性删除和定期删除的检查对象,实际上都是这个 expires dict。从内存角度看,一个 key 设置过期时间和不设置过期时间,占用的内存是不同的,因为多了 expires dict 里的一个条目。

6.2 LRU 时钟和 idle time 的近似计算

Redis 内部维护了一个lru clock,它不能精确到绝对时间,而是一个以一定分辨率更新的全局时钟。每个 key 在被访问时,会记录下当时的 lru clock 值。需要进行近似 LRU 淘汰时,Redis 通过比较当前 lru clock 与 key 记录的值,算出该 key 的 idle time。需要注意的是,lru clock 会因为时钟回绕(达到上限后归零)产生计算误差,所以 Redis 内部对时间差的计算做了回绕处理。这个细节知道的人很少,但在面试中如果能说出来,会显得你研究过源码逻辑,而不只是看二手资料。

6.3 内存碎片:为什么 maxmemory 没满,进程内存却很高

内存碎片是生产环境最容易困惑的问题之一。Redis 使用分配器(默认是 jemalloc)管理内存,jemalloc 为了性能会按固定大小分级分配内存。当你写入大量不同大小的 key 并频繁删除后,内存中会产生很多细小的空洞,导致 used_memory 不高,但 used_memory_rss(进程实际占用的物理内存)却很高。这时即使没有达到 maxmemory,也可能因为 RSS 过高被系统 OOM Killer 盯上。应对方式是定期用MEMORY DOCTOR查看碎片建议,或者在业务低峰期重启节点、使用activedefrag yes配置开启在线碎片整理。这个指标在面试中不会直接问,但在项目经验描述里提到“我处理过内存碎片率过高的问题”,是非常好的实战亮点。

7. 从验收到实战:一份可以直接照着用的自查清单

为了让你既能应对面试又不至于在生产环境踩坑,我把上面所有内容浓缩成一份自查清单,你可以保存下来,在项目上线或性能排查时对照使用。

  • [ ] 是否设置了合理的maxmemory,并预留了 20% 至 30% 的余量?
  • [ ]maxmemory-policy是否匹配业务的数据可牺牲性和热点分布特征?
  • [ ] 是否存在大量相同过期时间的 key,是否已加随机偏移?
  • [ ] 是否对“热点 key 永不过期 + 后台更新”这种特殊方案做了预案?
  • [ ] 是否监控了evicted_keys和expired_keys的增长趋势?
  • [ ] 内存碎片率是否长期保持在高位,是否需要开启activedefrag或定期维护?
  • [ ] 大数据 key 是否单独做了大小限制,避免淘汰和迁移过程中出现延迟毛刺?

这几条如果你能全部回答上来,说明你不是“背过 Redis 面试题”,而是真正用 Redis 做过内存规划设计。面试官如果给你一个场景题,你可以直接把这个清单里的判断逻辑串成一条线讲出来。

8. 最后分享一点个人体会

这类问题的价值其实不只是面试。我在实际排查过一个类似“线上缓存命中率骤降”的问题,花了两小时才发现是某次配置更新把maxmemory-policy从 allkeys-lru 改成了 volatile-lru,而大部分热点 key 都没设过期时间,导致内存一满就开始疯狂误杀少量带过期时间的 key,很多本该被淘汰的冷数据反而一直占着内存。那次之后我养成一个习惯:不管改 Redis 哪个参数,改完都立刻拉一次INFO stats,观察evicted_keys和expired_keys的增量变化。你如果也想在这个领域变得扎实,建议不只背策略名字,而是亲手对一个测试实例写入不同类型的 key,删掉一些、改改策略,再用INFO观察数字变化。踩过一次坑,比看十篇博客都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进