1. 先说结论:Redis根本没有"到点即删"的定时器
很多人一听到"键过期",第一反应是:Redis是不是在每个key上挂了个定时器,时间一到就自动删掉?如果你也这么想,那面试官问这道题的目的就达到了——这是最常见的误解。
实际上Redis的过期删除是一个非常"经济实用主义"的设计,它既没有为每个key维护定时器,也不会在key过期的瞬间立刻把它从内存里抹掉。整个机制由惰性删除和定期删除两个策略协同完成,再配合一个经常被大家混淆的内存淘汰做兜底。
这背后有个很现实的原因:Redis是单线程模型,所有命令都是排队执行的。如果为每个key都搞一个精确到毫秒的定时器,一旦几千上万个key同时到达过期时间,主线程会被疯狂的定时任务淹没,正常的读写命令全部卡死。这就像你一个人要同时给几百个闹钟上发条,还要抽空回复消息、做饭、处理工作——根本忙不过来。
所以Redis选择了一个"被动为主,主动为辅"的删除方案,核心设计目标只有一个:用最小的性能代价,保证过期的key最终能被清除,而且不能阻塞主线程的正常服务。这套方案的具体结构是这样的:
| 删除机制 | 触发方式 | 作用角色 | 核心目标 |
|---|---|---|---|
| 惰性删除 | 访问key时顺带检查 | 所有Redis实例 | 保证读不到已过期的数据 |
| 定期删除 | 后台周期性的采样扫描 | 所有Redis实例 | 主动回收未被访问的过期key |
| 内存淘汰 | 内存达到maxmemory上限时 | 所有Redis实例 | 兜底回收内存,避免OOM |
先说惰性删除和定期删除,它们两个加起来,才是面试里标准的"Redis过期键删除策略"答案。
2. 惰性删除:被动触发的闸门,为什么它够用又不够用
惰性删除的逻辑非常直白:每次从Redis里读一个key之前,先翻一下它的过期时间,如果发现已经过期了,就当场删除,然后返回空结果。这个动作在Redis源码里对应一个核心函数expireIfNeeded,几乎每个读取命令执行前都会调用它。
2.1 它到底在什么时候触发
不管你是执行GET、SETNX还是TTL、EXISTS,只要命令涉及的key带过期时间,Redis在执行命令之前都会先过一次expireIfNeeded。这个检查本身很有讲究:
- 如果key没设置过期时间,或者还没到过期时间点,直接跳过,正常执行命令。
- 如果key已经过期,Redis会先把key删除,然后对调用方表现为"这个key不存在"。比如GET一个过期的key,返回nil;LPUSH一个过期的list,相当于从零开始创建一个新list。
你可能会问:那如果这个key过期了我永远不去访问它呢?它不就一直赖在内存里了?这就是惰性删除的短板——它只能处理"被读取"的key,对于那种写过一次就再也没人碰的冷数据,惰性删除拿它毫无办法。你写入了10万个带过期时间的key,之后一个都不读,内存就被这些"尸体"白白占着。
2.2 为什么说它"够用"
从语义上讲,惰性删除保证了Redis的绝对正确性:任何时刻,客户端都不可能从Redis里读到已过期的数据。因为只要读操作发生,过期检查就必然先执行,过期数据肯定被拦下来。这一点对业务来说是底线要求。
而且它的性能开销几乎可以忽略不计。每次读key时多做一次时间比较,在源码里就是一次dictFind加一个mstime()毫秒时间戳比较,CPU开销在纳秒级别。Redis把这点开销摊在每个已有的读取命令上了,不会新增额外的循环或批量任务。
2.3 为什么它又"不够用"
因为内存不是无限大的,Redis不可能容忍一大批过期key永远不被清理。你业务里设置过期时间,本质是想让Redis自动释放资源。如果没人读它就不删,过期时间这个功能就名存实亡了。尤其是缓存场景:你给一个热点活动的数据设了缓存1小时,大量key写入后再也没被访问,1小时后它们还占着内存,下波活动数据写进来时内存可能要爆了。
所以必须有一个主动清扫的机制来兜底——这就轮到定期删除了。这是一对黄金搭档:惰性删除解决"读不到过期数据"的正确性问题,定期删除解决"过期数据会占内存"的空间回收问题。
3. 定期删除:主线程里"偷时间"的扫地僧
在Redis的默认配置里,有一个后台任务叫activeExpireCycle,它就是定期删除策略的实体。每隔一段时间,它会从所有设置了过期时间的key中随机抽一批出来,检查是否过期,过期的就删。
3.1 具体是怎么跑的
activeExpireCycle的完整规则可以拆成几步:
- Redis默认每100ms执行一次这个任务(也就是每秒10次),每次运行会遍历所有库。
- 在每个库里,从"设置了过期时间的key集合"(也就是 expires 字典)中随机抽取20个key。
- 检查这20个key里有多少已经过期,逐一删除。
- 如果本次抽取的20个key中,过期key的比例超过25%,说明这个库里的过期key是普遍现象,那就继续抽取下一轮20个key,直到过期比例降到25%以下,或者本次任务的总耗时超过CPU时间预算。
- 如果某一次发现的过期key比例很低(不到25%),说明当前库的key分布比较健康,就提前结束本库的清理,进入下一个库。
这个25%和20个key的组合不是随便定的,它形成了一个自适应机制:过期key越多,清理频率越高;过期key变少了,清理也就自然放缓。这样的话,即使某个瞬间大量key同时过期,Redis也能通过连续多轮扫描把它们清掉,而不是一次性全量遍历。
3.2 为什么用"随机采样"而不是"全量扫描"
这是我觉得Redis整个过期删除设计里最巧妙的地方。如果每次遍历所有设置了过期时间的key,假设某台Redis里存了1000万个带过期时间的key,那每次清理任务都要扫描1000万个key,即使每个key只做一次时间比较,算下来也会严重阻塞主线程。要知道,这期间所有的GET、SET都要排队等着。
随机采样20个的做法,把单次任务的时间开销变成了可控的常量,而不是和数据量线性增长。整个过程总有明确的时间上限——Redis给activeExpireCycle设计了一个CPU时间预算,虽然不同版本的具体值有微调,但核心思想一致:每次任务最多只允许占用主线程几毫秒(通常是1~5ms量级),时间到了立刻收手,让位给正常的客户端命令。
这让定期删除变成了一个"找空闲时间干活"的扫地僧。你不用担心它因为扫描太多key把主线程卡住,因为在最坏情况下,它也就是多抽几轮20个key,但时间预算到了就强制刹车。
3.3 主从架构下它做了什么改动
面试里经常追加一个问题:主从节点上,过期删除是怎么保持一致的?
答案是:从节点不会主动执行过期删除,它完全听主节点的。主节点执行惰性删除或定期删除,把一个key删掉后,会生成一条DEL命令同步给从节点;从节点收到DEL后才会真正把它删掉。
这样设计的一个理由是:避免主从各自判断过期时间产生不一致。如果主从都能自作主张地删除key,在存在时钟偏移(时间不准)或网络分区的情况下,主从数据很容易对不上。统一由主节点删除并传播DEL命令,从节点的状态就始终能被校准。
这里有个衍生知识点:如果从节点响应来自客户端的读请求,它也会检查过期时间。但在Redis的经典实现里,从节点在读取一个已过期的key时,会返回nil(表示不存在),但不会真的删除它,只会等主节点的DEL。这保证客户端在从节点上也读不到过期数据。
3.4 定期删除的常见"事故"现场
虽然activeExpireCycle有意限制了CPU时间,但有个场景还是会出问题:大量key设置了完全相同的过期时间点。比如业务用EXPIREAT给1万个key设置了同一个未来的时间戳,当那个时刻到来时,这些key会同时进入过期状态。定期删除的第一轮扫描发现过期比例超过25%,于是继续扫第二轮、第三轮……虽然每轮有CPU时间预算兜底,但这种"雪崩式过期"还是会明显拉高主线程的CPU占用,极端情况下会拖慢所有命令。
我在生产环境踩过一次坑:运营活动结束的瞬间,几万个活动相关的缓存key同时过期,Redis实例的CPU直接飙到80%以上,正常的业务查询平均延迟从2ms涨到了100多ms。后来排查下来就是批量设置相同过期时间导致的。
后面在代码里给过期时间加了个随机抖动,把同一个批次key的过期时间点均匀打散在几十秒的范围内,问题就消停了:
// 伪代码示意:设置过期时间时加随机偏移 long expireSeconds = 3600 + random.nextInt(120); // 这样同一批key不会都在同一秒过期,而是分散在2分钟窗口内这个"过期时间加随机抖动"的做法,在面试里如果你能主动提出来,是很加分的,因为说明你理解定期删除的抽样机制在实际运行时的脆弱点。
4. 内存淘汰:很多面试者的混淆点,过期删除之外的兜底
讲到这里必须停下来澄清一个高频混淆点:Redis的过期删除和内存淘汰,是两个不同的机制。面试的时候如果你混为一谈,前面讲得再流畅也会被打折。
内存淘汰有一个前提条件:maxmemory配置项必须被设置,并且maxmemory-policy配置了具体的淘汰策略。只有当Redis的内存使用量达到maxmemory上限时,它才会启动淘汰逻辑。它是根据策略挑一些key直接删除,以释放内存空间。在这些候选key里,有一部分可能是没设置过期时间的"永久key",有一部分可能正好是已过期但还躺着的key——但淘汰逻辑本身不看它是否过期,只看它的内存占用和回收价值。
4.1 八种淘汰策略怎么选
Redis的maxmemory-policy一共有八种取值,可以按作用范围分成两派:
| 策略名 | 作用范围 | 淘汰逻辑 |
|---|---|---|
| noeviction | 全部key | 不淘汰任何key,写命令直接报错 |
| allkeys-lru | 全部key | 淘汰最近最久未使用的key |
| allkeys-lfu | 全部key | 淘汰最不经常使用的key |
| allkeys-random | 全部key | 随机淘汰任意key |
| volatile-lru | 仅设置了过期时间的key | 淘汰其中最近最久未使用的key |
| volatile-lfu | 仅设置了过期时间的key | 淘汰其中最不经常使用的key |
| volatile-random | 仅设置了过期时间的key | 从过期key里随机淘汰 |
| volatile-ttl | 仅设置了过期时间的key | 优先淘汰剩余存活时间最短的key |
生产环境最常用的是allkeys-lru或allkeys-lfu,因为它的作用范围覆盖所有key,不会出现"过期key清完了,但内存还是不够"的尬局。volatile-*系列有个隐患:如果业务里大部分key都不带过期时间,内存快满时可能无key可踢,最后缓存雪崩。
4.2 Redis的LRU是"近视眼",不是严格LRU
严格意义上的LRU(Least Recently Used)需要在内存里维护一个巨大的访问时间排序结构,代价极高。Redis的LRU是近似LRU:它不记录全局的访问时间排序,而是给每个key存了一个24bit的LRU时钟字段,记录最近一次访问的"逻辑时间"。淘汰时随机抽取若干个key(默认采样5个),从这里面挑出LRU时钟最老的淘汰。
这个设计思路和定期删除的随机采样如出一辙:用随机采样+局部比较去逼近全局最优,把成本打下来。Redis里这个采样数由maxmemory-samples控制,默认5,调大到10会更接近严格LRU的淘汰效果,但CPU开销也随之上升。
LFU类似,但不是看"多久没访问",而是看"访问频率低不低"。24bit的lru字段在LFU模式下被拆成两部分,一部分是上次衰减时间,一部分是访问计数。它会周期性地对计数做衰减,避免"很久以前访问极多"的key赖着不走。
4.3 内存淘汰和过期删除的交集点
你可能会问:内存淘汰时如果正好选中了一个已经过期的key,算谁干的?其实Redis在淘汰前会先检查这个key是否过期,如果过期了就直接走过期删除逻辑,当作一次正常的过期清理,不再占淘汰名额。这个细节不多见,但面试里抛出来能体现你对源码的熟悉度。
另外要记住一个关键差异:过期删除是只要key设置了过期时间,到了时间点就可能被删;内存淘汰却要满足"内存达到上限"这个触发条件才会发生。我在Redis 5.2里就遇到过测试环境把maxmemory设得很大,导致永远不触发淘汰,过期key只能依赖惰性+定期删除,最终内存和CPU都异常上涨的情况。要排查过期key的删除效率,可以从INFO stats里的expired_keys字段观察每秒失效key的数量变化。
5. 面试时的高频追问清单:从"怎么删"到"删了会怎样"
这是本帖最实战的部分。了解机制只是第一步,能扛住面试官的连环追问才是关键。以下是我梳理的高频追问链,每个环节都附上了合理的回答思路。
5.1 追问一:为什么不用定时器来做精确删除?
这个问题的本质是考察你对"CPU效率"和"单线程模型"的理解。精确的定时删除需要引入时间事件调度,给每个key挂一个定时器。几个问题随之而来:
- 定时器需要独立线程或者精确的时间轮结构,如果几千个key同时到期,会发生突发的CPU飙高,这正是Redis极力避免的。
- Redis是单线程的,定时任务本身也是在主线程里执行的。那和定期删除有什么区别?区别在于定时删除的粒度和精度要求更高,需要在精确时间点唤醒并执行删除,频繁唤醒会打乱事件循环;而定期删除是"每隔固定周期扫描抽样",时间复杂度可控,不会出现单点瞬间的高负载。
- 从数据结构角度说,精确到key级别的定时器也需要维护一个有序的时间堆(最小堆/时间轮),每次key的过期时间更新都要调整堆结构,这份额外开销对Redis这种追求极致吞吐的组件来说不划算。
5.2 追问二:惰性删除不够,定期删除有延迟,那数据过期了还能读到吗?
这个问题考察的是你对流程细节的掌握。结论分两种情况:
- 主节点:读操作前走
expireIfNeeded,过期key直接删掉再返回nil,所以主节点上不存在"读到已过期数据"的情况。 - 从节点:从节点在读取时也会检查过期时间,发现过期会返回nil,但不会删key。注意这个返回nil的动作是Redis实现的逻辑行为,不是说从节点上还能读到值。
所以你严格来说,任何一台Redis节点上,客户端都读不到已过期key的value。这里真正存在的"脏数据"风险只在于:从节点在等待主节点同步DEL的空窗期内,如果被直接用底层命令(比如避免走逻辑检查的方式)访问内部数据结构,是有可能触达过期key的。但我个人不推荐向面试官主动延伸这种极端case,除非他刨根问底到源码级。
5.3 追问三:定期删除每次扫描会不会阻塞主线程?
从设计意图上说,不会,因为存在CPU时间预算和25%阈值双重控制。Redis 2.6之前是每100ms扫描一批,后面的版本做了多次优化,比如Redis 7.0里对过期扫描引入了增量式的改进,并且允许过期扫描工作按时间切片分配到多次事件循环。真正要注意的是极端场景:同一时间点过期key过多时,多轮连续扫描叠加起来,会明显吃掉主线程的时间片。答到这里可以顺势抛出"加随机抖动"的实践经验——这也是我自己的真实生产案例。
5.4 追问四:如果你要生产一个缓存服务,你会怎么设计过期键删除方案?
这是把知识变成工程判断的考察。可以给出一个完整的方案:
- 分层设计:读路径上做惰性删除,确保语义正确;后台做类似定期扫描的主动清理,控制单次CPU开销在1ms以内。
- 不要全盘扫描:采用分片+随机采样策略,只处理一部分key,把扫描成本从O(N)降到O(1)。
- 对过期时间做离散化处理:批量写入时加随机偏移,避免大规模同刻过期。
- 大value的删除做成异步:删除大key如果耗时超过阈值,放到后台线程去删,避免阻塞主线程。这其实对应了Redis 4.0引入的
unlink命令和 lazy free 机制。 - 监控心跳:持续观察过期key的产生速率与删除速率,如果后者总是跟不上前者,就要考虑优化扫描频率或调整过期时间分布。
能答出这套分层设计的逻辑,面试官基本能确认你不是背题的,而是真正理解了这个机制的设计哲学。
5.5 面试应答的完整话术框架
最后给一套可以直接套用的标准话术。我建议你在面试时按这个顺序讲,逻辑最顺:
Redis的过期键删除不是定时器方案,而是"惰性删除+定期删除"的组合。惰性删除是每次读key之前检查过期时间,过期就删,保证不会读到过期数据。定期删除是后台每100ms左右从所有带过期时间的key里随机抽20个,如果过期比例超过25%则继续抽,同时限制CPU时间预算,避免阻塞主线程。这两个机制配合,一个管正确性,一个管内存回收。另外还有一个容易混淆的内存淘汰,它是在maxmemory达到上限时才触发的,和过期删除不是一回事。大value过期删除也可以结合 lazy free 的方式异步处理,减少对主线程的影响。
这套话术讲下来大概45秒,信息密度高,而且每句话背后都有可继续追问的技术支撑。如果你能在此基础上再补上自己生产环境里"批量过期时间抖动"的实战优化案例,那这道高频面试题基本就稳了。