ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis过期键删除策略:惰性删除、定期删除与内存淘汰机制详解

Redis过期键删除策略:惰性删除、定期删除与内存淘汰机制详解 Redis这颗缓存中间件用久了你会发现一个很有意思的现象明明给 Key 设置了过期时间也等到了过期的那一刻但查内存却发现 used_memory 一点没降。又或者某个 Key 已经过期了但在某个瞬间你依然能读到旧值。这不是 Redis 出了问题而是它本来就把“删除过期键”这件事设计成了一套由多个策略组合起来的妥协机制。Redis 过期键删除策略说白了就是惰性删除、定期删除、内存淘汰这三驾马车如何配合、如何取舍的问题。这篇文章我打算把这些机制掰开了讲清楚包括底层触发流程、主从库的差异、面试高频问法和生产环境里踩过的坑希望能给你一个相对完整的认知框架。1. 过期键删除策略的流派与选型逻辑1.1 为什么 Redis 不选择“立刻删除”这个最直观的方案大多数初学者第一次听到“过期键删除”时第一反应是每个 Key 不都带一个 TTL 吗Redis 为什么不在 TTL 到期的那一刻立刻把这个 Key 删掉这个思路在业务代码里很自然但在 Redis 这种单线程事件循环的服务端里却是个大问题。如果采取“时间一到立刻删除”的绝对定时方案Redis 就需要为每个带有过期时间的 Key 都维护一个高精度定时器并且在高并发写入、大量 Key 同时到期的场景下持续触发回调。假设某一秒内有 10 万个 Key 同时过期定时器就要在同一时间点执行 10 万次删除操作。Redis 是单线程模型所有命令都在同一个线程里排队处理一旦这 10 万次删除操作开始执行其他客户端的读写命令就只能阻塞等待。哪怕每次删除只要毫秒级叠加起来也会造成明显的命令延迟抖动。另外一个问题是内存和时间成本。每个精确到毫秒的定时器都要占用额外的数据结构如果 Redis 里存了上亿个带 TTL 的 Key这个定时器队列的内存开销会非常夸张。这也是为什么 Redis 官方最终选择了“惰性删除 定期删除”这种近似方案而不是绝对精确的定时删除。它牺牲了“过期 Key 立即消失”的强一致性换取了主线程的高吞吐和低延迟。1.2 三套策略的职责划分与优缺点对比Redis 实际的过期键删除体系可以用一张简单的表来理解。策略触发时机优点缺点惰性删除访问某个 Key 时发现已过期不会额外消耗 CPU只在必要时处理如果过期 Key 一直不被访问会一直占着内存定期删除后台周期性任务扫描部分 Key能回收一部分“冷”过期 Key每次扫描的 Key 有限无法保证全部清理干净内存淘汰内存达到 maxmemory 上限写入命令触发最兜底的保护机制防止 OOM可能误删未过期数据需要配置策略惰性删除可以理解成“懒到家”的方案每次从字典里取 Key 的时候Redis 会先检查这个 Key 是否已过期。如果过期了删除并返回空结果。这个检查逻辑在expireIfNeeded函数中完成几乎所有读取路径都会经过它。它的好处是精度高——只要你能访问到这个 Key它一定不会返回过期数据。坏处也很明显如果一个过期 Key 很长时间没人访问它就会一直占着内存形成变相的“内存泄漏”。定期删除则是为了解决惰性删除的缺陷而补上的主动机制。Redis 会周期性地从所有数据库的过期字典里随机抽取一部分 Key 进行检测发现过期的就删除。这个“抽检”不是把全库都扫一遍否则又会回到耗时过长、阻塞线程的老问题。它更像社区保洁不保证一尘不染但每隔一段时间清理一遍显眼的垃圾让整体内存不至于失控。内存淘汰策略则是最后一道防线。当 Redis 的 used_memory 达到配置的 maxmemory 上限后如果有新的写入请求进来Redis 会根据配置的淘汰策略来腾出空间否则无法继续写入。严格来说它不单单服务于“过期键删除”但在生产环境中很多内存告警和 Key 被莫名清掉的问题最终都归结到淘汰策略上。理解这三者的关系是读明白 Redis 内存治理的第一课。1.3 一个真实场景Key 过期了内存为什么没降我一直觉得最好的教材是现场。之前维护过一个用户会话服务里面存了约 2000 万个格式为session:{userId}的 KeyTTL 统一设置 30 分钟。业务量高峰过后我发现 Redis 的 used_memory 几乎纹丝不动但活跃用户数已经明显下降。当时的第一反应是“删除没生效”。后来排查发现session Key 的特点是天然分布非常分散绝大多数 Key 在过期后根本不会再被读取。也就是说惰性删除在这个场景下完全失效——用户不会再回来访问它Redis 也就永远不会触发这个 Key 的删除。而定期删除不是全量扫描30 分钟的有效期内Redis 最多只能抽检到其中一小部分新产生的过期 Key 数量又远远大于清理速度最终内存自然看起来是“只增不减”。这个现象在缓存清理、验证码、临时令牌等场景里非常典型。它不算 Redis 的 bug而是过期策略本身在设计上就允许“过期 Key 驻留内存”这种情况发生。要解决它得靠定期删除参数的调优、主动清理脚本、或者借助内存淘汰策略来兜底。后面我会详细展开这些手段。2. 定期删除机制的幕后原理2.1 serverCron 与 hz 参数谁来推动定期清理定期删除并不是一条独立的线程而是寄生在 Redis 的心跳机制serverCron里。serverCron 是 Redis 所有周期任务的集合比如更新服务器统计信息、处理客户端超时、持久化触发检查等过期键清理就是其中一项。serverCron 的执行频率由hz决定默认值是 10也就是每秒执行 10 次。这个参数可以在 redis.conf 中配置也可以运行时通过CONFIG GET hz查看。你可以把 hz 理解为“心跳次数”它决定了 Redis 对后台任务的处理密度。调高 hz定期删除任务的执行会更频繁过期键的清理速度会更快但代价是 CPU 开销上升。默认的 10 在绝大多数业务场景里是合理的除非你明确感知到过期键积压导致内存过高否则不建议随手调大。在周期任务里Redis 会遍历所有数据库随机抽取一批 Key 判断是否过期。之所以用“随机抽取”而不是顺序遍历是因为全量顺序扫描在大 Key 数量下会阻塞主线程。随机抽样的思想是用有限的 CPU 成本换取尽可能大的清理覆盖面这和后面要说的近似 LRU 有异曲同工之处。2.2 抽检数量、扫描上限与删除比例是怎么定的很多文章把定期删除流程简单描述成“每隔 100ms 随机查一批 Key”但里面有几个值得展开的细节。首先Redis 会先检查当前数据库的过期 Key 数量。如果过期字典为空就直接跳到下一个库。如果存在过期 Key它会随机抽取一批 Key默认是 20 个逐个检查。检查中如果发现某个 Key 已过期就删除并记录本次删除比例。这里的关键决策点是删除比例如果超过 25%说明当前库里过期 Key 很密集Redis 会认为清理不彻底于是继续在同一个库里进行下一轮抽取直到删除比例低于 25% 或者本轮任务的总耗时超过 25 毫秒上限。这个机制保证了清理动作既不会无限持续又能针对过期 Key 密集的库多花一点时间。这 25 毫秒的时间上限非常关键。因为 Redis 是单线程所有删除操作都相当于在主线程里“插队”执行。如果某一轮清理在大量过期 Key 上做了太久后续命令就会被延迟。1 毫秒对 Redis 来说都是大事件25 毫秒意味着其他客户端最多可能感知到几十毫秒的卡顿。所以定期删除在尝试回收内存的同时始终在小心控制自己的“代价”。需要注意的是不同版本 Redis 对这里的具体数值和流程有细微调整比如activeExpireCycle的处理逻辑在 2.6 里和 7.0 里就有不少差别但总体的“抽样—统计—限时限量”思路一直没变。2.3 主从复制模式下过期键清理有另一套逻辑主从架构是生产环境标配但这里的坑比单机多得多。假如主库上有一个带 TTL 的 Key 已经过期Redis 并不会立即同步给从库一个 DEL 命令因为从库在复制模式下不允许独立执行过期键删除。换句话说从库自己不会主动判定并删除过期 Key它的键空间里可能保留着一个“逻辑上已过期但物理上仍然存在”的数据。那从库的数据一致性靠什么保证答案是主库的 DEL 命令传播。当主库通过惰性删除或定期删除真正删除一个过期 Key 时它会往 AOF 文件里追加一条删除记录同时向所有从库发送 DEL 命令从库收到后才会真正把这个 Key 删掉。这也是 Redis 主从复制中一个非常经典的面试题从库为什么会读到过期数据因为从库不会自行删除过期键必须等主库的命令。这个设计看似绕实际上是为了保证主从节点之间数据的一致性和复制链路的简洁否则每个从库如果都按照本地时间独立删除很容易出现主从数据不一致。不过它也带来了一个隐患如果主库因为某种原因迟迟没有删除某个过期 Key从库那边读到的旧数据可能会赖着不走。更极端的情况是主库宕机从库晋升为新的主库此时它才会恢复主动删除过期键的能力但晋升那一刻内存里可能还残留着大量已过期的 Key需要靠定期删除逐批清理。3. 惰性删除的实现细节与生产兜底3.1 惰性删除是在哪一步被触发的惰性删除听起来很简单表面上就是“读的时候判断有没有过期”但落实到源码实现里它会出现在所有路径的入口处。Redis 对外服务的所有操作最终都会走到键查找这一步比如 GET、SETNX、LRANGE、MGET 等。在查找键的核心逻辑lookupKeyRead里Redis 会先调用expireIfNeeded检查这个键是否已过期。如果判断为过期就直接删除并向上层返回“空”的结果。如果没读到这个 Key那惰性删除就没有机会执行。有一点容易搞混惰性删除并不只发生在读操作里写操作同样会触发。比如对一个已过期的 Key 执行 SETRedis 会先把这个旧 Key 删除再写入新值。所以我们可以把它理解成“任何访问都会先做一次过期检查”而不是只有 GET。惰性删除的好处是它对 Redis 的主线程延时影响微乎其微因为每个 Key 的检查都是 O(1) 复杂度就是看一眼过期时间戳、跟当前时间比一下。但它的代价也非常直观如果你永远不访问某个过期 Key它就永远不被删除。这也就解释了为什么纯靠惰性删除内存占用会一直堆高。3.2 从库视角为什么从库不会主动删这个点值得单独提出来再说一遍因为实际线上事故里很多同学第一次遇到“从库读到了过期数据”时都会一脸懵。Redis 从库在默认情况下不会对读取做过期判断。也就是说即便主库上这个 Key 已经过期并删除了只要主库的 DEL 同步还没到从库从库依旧可能返回旧值。更准确地说在 Redis 2.6 版本之前从库连expireIfNeeded都不执行后来版本里改了逻辑从库在本地时钟认为 Key 过期时会将其标记为已过期但在返回数据前仍然不会删除也不会直接返回空而是返回 Key 的旧值让客户端层能够感知到“这可能是个过期数据”。这个设计带来的实际影响是如果你的业务读取经过了读写分离从库上的缓存一致性天然会比主库弱。对于要求强一致的数据不应该依赖 Redis 的过期机制而是要在应用层额外做逻辑过期校验。我见过不少团队把验证码、登录态这类数据也走从库读结果偶发出现“已过期但还能用”的线上反馈就是这个机制在背后作怪。3.3 业务侧如何规避惰性删除的局限性理解了上面这些机制我们就能明白一个结论Redis 的过期键删除策略是一个“尽力而为”的系统它不能保证过期键一定会被及时删除。所以生产环境里的内存治理不能把宝全押在 Redis 自己的机制上。我提供几个自己在项目里验证过的兜底手段大家可以根据场景选用。第一招是加一层“逻辑过期”判断。存数据的时候除了原始 Value再附带一个业务过期时间。读取时先判断业务时间如果逻辑上已过期就重新加载数据并回写缓存旧值还可以选择再保留一小段时间用于应对缓存雪崩。这种方式的好处是即使 Redis 里的物理 Key 还没来得及删业务层也已经把它当成过期数据处理了。第二招是给 TTL 加随机偏移。缓存雪崩的经典解法在设置 TTL 时不要让所有 Key 在同一个时刻失效而是加一个 0~300 秒的随机数。我见过一个典型的翻车案例某活动页所有商品 Key 的 TTL 都设成了 3600 秒结果活动上线后每小时整点数据库连接数直接打满。原因就是所有 Key 在同一秒被懒删除机制碰到触发大量回源。加随机偏移后这个问题基本消失。第三招是对批量写入的临时 Key配合定期任务主动清理。比如验证码、短信限流这类 Key它的生命周期短、量大而且被访问概率低指望惰性删除根本不现实。这种场景下可以自己写一个定时任务用 SCAN 配合 TTL 筛选出 TTL 极短的数据统一删除它比反复调大 hz 对 CPU 的影响要小得多。4. 内存淘汰策略最后一道生命线4.1 背景maxmemory 与淘汰策略的关系前面聊的惰性删除和定期删除都是在“这个 Key 已经过期”的前提下做清理。但生产环境还有另一类场景Key 还没过期但 Redis 的内存已经快满了。这时候 Redis 必须做点什么否则新写入命令就会报 OOM 错误。这个“做点什么”就落在内存淘汰策略上。内存淘汰的触发条件是maxmemory。这个参数可以在配置文件中配置也可以运行时用CONFIG SET maxmemory修改单位是字节。当 used_memory 超过该值后写入类命令比如 SET、LPUSH、SADD会进入淘汰流程先尝试为新的写入腾出空间。腾不出空间就拒绝写入而读命令一般不受影响。所以你在生产监控里时常看到的现象是写入失败率上升、用户头像刷不出来但不影响白名单接口的读操作。Redis 提供了多种淘汰策略不同策略意味着“选择牺牲哪些 Key”。如果把 Redis 比作一个停满车的停车场那么 maxmemory 就是停车场围墙新来的车辆要想停进去就必须先挪走一辆车。挪走哪一辆就是策略决定的。有的是挪即将过期的有的是挪最近最少用的有的是随机挪的。4.2 八种淘汰策略速览与适用场景Redis 官方根据noeviction、allkeys-*、volatile-*三个维度衍生出八种策略。很多面试题问“Redis 的淘汰策略有哪些”表面上考的是八选一实际上更想考察你对 volatile 和 allkeys 这两组的理解。volatile 开头的策略只处理设置了过期时间的 Keyallkeys 开头的策略则可以对所有 Key 下手。策略作用范围淘汰依据适用场景noeviction全部不淘汰写入直接报错对数据完整性要求极高禁止丢失volatile-lru设置了 TTL 的 Key按 LRU 淘汰大部分缓存业务的首选allkeys-lru全部 Key按 LRU 淘汰内存紧张允许淘汰任何数据volatile-lfu设置了 TTL 的 Key按 LFU 淘汰有热点数据、希望保留高频 Key 的缓存allkeys-lfu全部 Key按 LFU 淘汰热点突出、缓存命中率要求高的场景volatile-random设置了 TTL 的 Key随机淘汰数据重要性相近、没有热点的缓存allkeys-random全部 Key随机淘汰临时缓存、机器性能均匀的场景volatile-ttl设置了 TTL 的 Key优先淘汰剩余 TTL 最短的业务希望“快过期的先走”很多人会想当然地选 volatile-ttl认为按剩余时间最短删除最合理因为这种数据本来也快失效了。但实际使用中volatile-lru 往往更好因为 TTL 最短并不等于“最不值得留”一个马上过期但访问量极高的 Key和另一个还有半小时才过期但没人访问的 Key前者被淘汰后可能引发缓存击穿后者淘汰了反而毫发无损。关于选择还有一个细节如果业务对数据安全性要求极高那应该用 noeviction也就是内存满了宁可直接拒绝写入也不准清退数据。这种策略在登录态、订单流水等不希望数据丢失的场景里更保险。但代价是当内存持续满时业务写入接口会频繁报错所以通常要配合扩内存或优化数据量。4.3 近似 LRU 与 maxmemory-samples 参数的权衡该说说 LRU 了。严格来说Redis 实现的并不是教科书里那种精确 LRU而是一种近似 LRU。教科书里的 LRU 需要维护一个有序链表每次命中都要移动节点位置在 Redis 这种单线程模型里成本太高。所以 Redis 的 LRU 策略实际上是给每个 Key 记录最后一次被访问的时间戳淘汰时随机抽取一批 Key然后在抽样的范围内选择最久没被访问的那个进行删除。随机抽样的比例由maxmemory-samples参数决定默认值是 5。这个参数控制的是“每次探测多少个 Key”。抽 5 个就是在这 5 个里挑最久没用的抽 10 个精确度更高更接近真实 LRU但消耗的 CPU 也更多。如果你对缓存命中率特别敏感可以适当调到 10 左右但没必要上到 20 以上边际收益会快速递减。LFU 策略与 LRU 不同的是它统计的是访问频率。Redis 4.0 版本开始支持。实现上它会记录一个对数计数器并通过衰减因子逐步降低历史频率从而避免“几年前访问过一次的热点 Key 永占内存”的情况。如果业务有明显热点比如一个人气商品的缓存命中次数远高于普通商品LFU 往往比 LRU 更聪明。5. 面试高频题与生产避坑实录5.1 Redis 的 Key 到期后为什么还能被读到面试里有个必考题某个 Key 明明设置了过期时间也到了过期时间但 GET 还是能够返回旧值这是为什么很多背八股的人会下意识回答“因为惰性删除不会主动删所以你先要访问它才能触发删除”。但这个回答并不全面因为如果它已经被你访问到了那惰性删除就该生效了你应该拿到空值才对。这里要分几种情况。第一种是主从场景下读从库。前面已经提到从库可能还没收到主库的 DEL 命令这时候读到旧值是正常的。第二种是集群模式下Key 分布在不同节点如果某个节点上的时钟与客户端时间不一致也会造成短暂的过期判断偏差。第三种是事务或 LUA 脚本中Redis 会尽量避免在脚本执行期间触发过期删除以保证脚本的原子性和确定性所以脚本里可能读到已过期的 Key。第四种是 Redis 的过期检查发生在命令执行前如果同一时刻有大量 Key 同时过期事件循环里可能在当前轮次的命令处理中还没来得及清理但下一个任务开始时这些 Key 就会不可见。理解这些细微差异比背出一个“惰性删除”的结论更有价值。面试官如果追问“那你怎么保证业务不读到过期数据”答案就是我前面提到的逻辑过期校验。它不依赖 Redis 内部机制永远由业务自己判断。5.2 内存明明没满为什么 Redis 开始丢数据这个问题我在社群答疑里被问过很多次。某天监控发现 Redis 的 Key 数量在减少但内存看起来还没到 maxmemory业务代码也没有主动删除。排查到最后绝大多数原因是配置了allkeys-lru或allkeys-lfu而监控面板里 Redis 的 maxmemory 又设置得比较小比如 1GB。你以为内存没满但实际 used_memory 已经撞到了 maxmemory 的天花板淘汰策略自动开始把一些不常用的 Key 清掉了。判断方法也很简单连上 Redis 执行INFO stats查看evicted_keys字段。这个字段是累计值只要它在持续增长就说明淘汰策略一直在干活。另外也可以看INFO memory里的maxmemory_policy和used_memory对照确认是否贴线。针对性解法是要么提高 maxmemory要么把淘汰策略改成 volatile-lru让没有设置 TTL 的 Key 幸存下来。还有一些团队会用redis-cli --bigkeys扫描出大 Key把那些占内存的大户单独压缩或者拆分也能缓解淘汰压力。重点是要记住淘汰策略不是 Redis 帮你清垃圾它是数据丢失的最后一道闸门一旦触发就要结合监控数据判断是内存规划问题还是业务写入量问题。5.3 缓存雪崩、击穿与过期键删除的关联过期键删除机制不只是底层细节它直接和上层业务故障联动。缓存雪崩的本质是大量 Key 在同一时间过期导致请求全部落到数据库。而定期删除机制在处理“大量 Key 同时过期”的库时删除比例超过了 25%会在这一个库里持续多轮清理这段时间内事件循环的耗时上升过期键清理造成的延迟会让数据库感受到瞬时高并发。缓存击穿则是某个热点 Key 刚好在过期后被大量线程同时访问。由于惰性删除是在第一次访问时触发删除后续所有线程会发现缓存未命中同时回源数据库。如果这个 Key 的构建很慢数据库会被瞬时打挂。解法一般是用互斥锁或者把这个热点 Key 的过期时间设置为逻辑过期由后台线程异步刷新。这里我特别想强调不要把所有 Key 的 TTL 都设置为同一个值尤其是 24 小时、1 小时这种整点数字。在定时任务和用户自然行为叠加下整点过期的概率远高于你想象。加一个随机偏移量的成本极低但带来的稳定性收益非常高这是我每次做缓存设计都要求团队必须加的规则。还有一个值得补充的经验如果你的 Redis 中大量 Key 的过期时间都很短比如几十秒级别那么定期删除任务的清理压力会非常大。遇到这种流量模式我建议不要把过多数据放进 Redis优先考虑冷热分离比如短时效的验证码只存一份到刚需实例或者改用专业的消息队列做延迟删除不然 Redis 的主线程早晚会被“高频过期”拖累。5.4 检查与调优过期键管理的常用命令速查最后给出一组我平时排查过期键问题时惯用的命令方便大家在现场快速定位。这些命令不复杂但放在一起就是一套完整的问题定位套路。# 查看当前过期键总量 redis-cli INFO keyspace # 查看内存与淘汰策略统计 redis-cli INFO memory # 查看被淘汰的键总量每次清除会自动累加 redis-cli INFO stats | grep evicted_keys # 查看最近访问 Key 的剩余 TTL redis-cli TTL user:1:token # 设置一条带过期时间的记录 redis-cli SET temp:1 hello EX 60 # 主动删除某个 Key redis-cli DEL temp:1 # 配置 hz 和 maxmemory-policy redis-cli CONFIG GET hz redis-cli CONFIG GET maxmemory-policy使用这些命令时有个技巧不要盯着单次结果判断因为许多指标是累计值或瞬时值。比如 evicted_keys 要看它在几分钟内是否持续增长TTL 要结合业务时间点来判断。如果发现某个实例的过期键积压严重可以先用 SCAN 配合 TTL 抽样扫描统计一下过期键占比再决定是临时调高 hz还是写脚本主动清理还是优化业务侧的 TTL 分布。个人在实际操作中的体会是Redis 在设计和实现上始终在做“精确和性能”之间的博弈。它牺牲了过期键删除的强实时性换来了单线程模型下极高的吞吐量。大家用了这么久 Redis如果遇到内存上涨或者偶发的脏读问题不要急着甩锅给集群、网络或代码先静下心从惰性删除、定期删除、内存淘汰这三个层面去拆解往往几分钟就能定位到根因。尤其是面试时把这三个层次讲透了比堆砌命令名和参数值更能体现你对 Redis 的真正理解。
返回列表