资讯动态

Redis过期键清除:逻辑过期、物理删除与排查调优

发布时间:2026/10/30 3:47:48 来源:尧图企业网站定制
凌晨两点被 P99 毛刺叫起来十有八九能扯到 Redis 过期键上——这几乎是每个维护缓存的人都要经历的必修课。很多人对过期的理解停留在设了 TTL 就会自动消失于是把它当成一个不需要操心的黑盒内存涨了加机器慢了加副本。可真到线上出问题的时候你会发现这个黑盒里藏着两套完全不同的机制一套负责让键在逻辑上看不见一套负责把内存真正还回去两者之间的时间差就是绝大多数内存告警和延迟抖动的来源。这篇内容围绕Redis 过期键清除这件事展开不打算复述官方文档上的命令列表而是把过期了但内存没降下来主从数据看起来不一致某个大 key 一到期实例就卡住这几类反复出现的现象拆开讲清底层在做什么、参数为什么是这个值、以及一套可以直接照着调的排查和优化路径。适合已经在用 Redis 但还没深挖过过期机制的同学也适合正在为缓存内存发愁、想先弄清楚再决定要不要扩容的同学。读完之后你至少能对这个键到底什么时候被删的、内存为什么没降、我该改哪个参数给出有依据的回答而不是靠重启大法。1. 先把逻辑过期和物理删除拆开看这一节是后面所有排查的基础。如果这两个概念混在一起后面所有的现象都会显得莫名其妙。1.1 一个键过期了到底意味着什么在 Redis 里过期其实是两个独立的事件的组合只是我们习惯把它们当成一件事。第一个事件是逻辑过期当系统时间越过键记录的过期时间戳之后任何读取这个键的操作都应该拿不到值就好像它从来没有存在过。第二个事件是物理删除这条键值对连同它在内存里的所有数据结构被真正释放掉used_memory下降。关键在于这两个事件在 Redis 里并不是同时发生的。逻辑过期是确定的、精确的、立刻就生效的物理删除是不确定的、延迟的、依赖调度时机的。你在INFO memory里看到的used_memory只反映物理删除的结果而客户端读到的nil反映的是逻辑过期的结果。很多内存和预期对不上的困惑本质上就是把这两个数字放在一起比较了。我见过最典型的一个误判是这样的某个业务设置了 10 分钟 TTL凌晨低峰期写入了一个批量的临时键早上八点业务方问这批数据清了吗运维看DBSIZE没降多少判断没清干净于是手工跑了一轮删除结果和正常的后台回收撞在一起反而制造了一小段延迟抖动。实际上那批键早就逻辑过期了业务读不到只是物理内存还没来得及回收——这是个纯粹的正常现象。所以后面所有的讨论我会反复用逻辑上过期和物理上删除这两个词请注意区分。1.2 expires 字典TTL 到底存在哪儿开销有多大要理解 Redis 怎么判断过期得先知道 TTL 存在哪个结构里。每个redisDb里维护了两张主字典dict存所有键值对expires专门存设置了过期时间的键和它们的过期时间戳。expires的键是指向主字典键对象的指针不复制一份 SDS省内存值是一个毫秒级的 Unix 时间戳long long。这样设计直接决定了几个性能特征操作涉及结构复杂度EXPIRE/PEXPIRE插入expires字典O(1)TTL/PTTL查expires字典O(1)PERSIST从expires字典删除O(1)判断是否过期查expires 比较时间戳O(1)遍历所有带 TTL 的键遍历expiresO(N)每多一个带 TTL 的键就多一份字典条目的开销一个哈希表节点大约 24 字节加上过期时间戳的 8 字节在hashtable编码下摊下来每个键额外占几十字节。这听起来不多但如果你有 5000 万个带 TTL 的键光expires字典就是 1GB 量级的开销而且这个开销不会因为你用了什么编码方式而消失——expires永远是hashtable没法用listpack之类的紧凑编码。这里有个容易忽略的点DBSIZE统计的是主字典的键数量不区分有没有 TTL。想看带 TTL 的键有多少得看INFO keyspace输出里的expires字段比如db0:keys1000000,expires800000,avg_ttl3600000表示 100 万键里有 80 万设了 TTL。avg_ttl这个字段值得单独提一句。它是个估算值采样统计得出不是精确的平均数拿它做趋势观察可以拿它做精确报表就不合适了。1.3 内存不释放的真实原因往往不是 Redis 没删我遇到过好几次内存泄漏的求助最后查下来都不是泄漏而是下面这几种情况之一第一种过期键还没被物理删除。这是最常见的原因可能是访问量太低惰性删除没触发、也可能是定期删除的预算被别的操作挤占了。这种情况下的used_memory会在真正删除之后自行回落不需要干预。第二种内存碎片。就算 Redis 把 10 万个键全释放了used_memory会降但used_memory_rss进程实际占用的物理内存未必会同步降下来因为分配器jemalloc 或 libc未必把空闲页还给操作系统。这时候要看mem_fragmentation_ratio正常在 1.0 到 1.5 之间超过 1.5 且持续上涨才值得关注。真正想还内存得触发布局整理或者重启而不是删几个键。第三种客户端输出缓冲区。某个客户端执行了一个返回巨量数据的命令或者一个慢消费者订阅了高频频道client_output_buffer会吃掉大量内存这部分完全不受过期机制管辖。CLIENT LIST里的obl输出缓冲长度、oll输出列表长度、omem输出缓冲占用的内存能直接看到。第四种复制积压缓冲区。repl-backlog-size默认 1MB但如果从库掉线时间长积压会涨INFO replication里的repl_backlog_histlen能看到当前用量。把这四项分开看基本就不会误判了。真正的内存泄漏内存只涨不降、used_memory持续线性上涨在生产环境里非常罕见绝大多数是上面几种情况的组合。2. 惰性删除与定期删除主线程上跑的两套机制Redis 的过期删除策略教科书上一般列三种定时删除、惰性删除、定期删除。Redis 实际采用的是后两者的组合而且这两者的分工非常明确理解它们的边界比背诵定义重要得多。2.1 惰性删除把清理成本摊到访问那一刻惰性删除的入口函数是expireIfNeeded()它被嵌在几乎所有键查找路径上GET、HGET、LRANGE、EXISTS、TTL甚至SCAN的遍历过程都会调用。逻辑非常直白——每次要访问某个键时先查它在expires字典里有没有记录有的话比较当前时间和过期时间戳如果已经过了就在这次访问中把它删掉然后返回键不存在。这个设计的精髓在于成本摊销删除的代价由访问它的那个请求承担没人访问就不用承担。对于写多读少或者写完就不管的临时键这个策略极其划算但对于写完再也不访问、但一直占着内存的键惰性删除永远不会触发这些键就成了僵尸数据。主从环境下有个关键差异必须写清楚惰性删除只在主库上执行真正的删除。从库碰到逻辑过期的键只是返回nil不会删除也不会主动发起删除它安安静静等主库发来的DEL/UNLINK命令。这个设计是为了保证主从数据一致——如果从库自作主张删了主库那边其实还没到删除条件比如时钟略有差异就会出现主从状态分叉。还有一个细节值得注意在较新的版本里SCAN遍历时遇到逻辑过期的键也会顺带触发一次过期检查并删除主库上。所以一次全量SCAN扫描某种意义上也是一次批量清理只是这个副作用经常被忽略。2.2 定期删除activeExpireCycle 的采样循环怎么转惰性删除解决不了僵尸数据所以必须有主动扫描。但全量扫描expires字典显然不行——几千万个键在每个周期扫一遍主线程直接卡死。Redis 用的是随机采样 比例收敛的思路核心函数是activeExpireCycle()。它的运行逻辑大致是这样从expires字典里随机抽取一批键默认按ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP配置默认 20 个逐个检查是否过期过期的就删掉。抽完之后统计这一批的过期比例如果比例超过阈值默认ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE10%说明这个库里还有大量过期键没清就继续抽下一批如果比例低于阈值认为清得差不多了本轮结束切换到下一个 db。这个循环受两个上限约束一个是时间预算SLOW 模式默认是25% / hzhz10时约 25ms到点必须退出避免长时间独占主线程另一个是遍历范围避免每次都从头扫用current_db记录上次扫到哪个库下一轮从下一个库接着来。触发时机有两个serverCron()经过databasesCron()调用走 SLOW 模式这是主要路径随hz的节奏跑默认每秒 10 次。每次事件循环进入休眠前的beforeSleep()调用走 FAST 模式时间预算在毫秒级属于睡觉前顺手干一点的补充。这两个模式的存在感完全不同。SLOW 模式是主力承担绝大部分清理量FAST 模式的作用是在空闲时提前清理降低突发访问时碰上过期键的概率。调参的时候主要关注 SLOW 模式的时间预算FAST 模式一般不用动。采样机制带来一个很有意思的性质清理速度与过期键的密度正相关。如果库里 5% 的键过期了抽 20 个平均只能抽到 1 个很快就低于 10% 的阈值本轮就结束了这 5% 的键要花很多轮才能清完。如果 50% 的键过期了抽 20 个能抽到 10 个比例远超阈值会连续扫下去清理速度非常快。所以你会看到expired_keys的增速在批量过期瞬间是陡峭的然后迅速放缓——这不是 bug是采样算法的固有特性。2.3 hz、dynamic-hz 与 active-expire-effort 三个旋钮想调整过期清理的激进程度有三个参数可选但作用面完全不同不能混着用。hz是最基础的调度频率默认 10含义是每秒执行serverCron的次数。它影响的不只是过期清理还包括客户端超时检测、复制心跳、AOF 缓冲刷盘等等是个全局参数。把hz从 10 提到 100过期清理的触发频率提高十倍每次的时间预算相应缩小到 2.5ms 左右因为预算是按1/hz的比例算的总体 CPU 占用会上升。这个参数的调整要谨慎因为它同时影响很多子系统的行为。dynamic-hz新版默认开启解决的是空闲实例的 CPU 浪费问题。开了之后hz会随连接的客户端数量动态调整——客户端多的时候提到上限客户端少的时候自动降下来空转时几乎不消耗 CPU。生产环境建议保持开启除非你的实例连接数非常稳定且需要完全确定的调度节奏。active-expire-effort是新版本引入的专门参数范围 1 到 10默认 1。这是唯一一个只影响过期清理、不影响其他环节的旋钮所以调优时优先考虑它。它的作用方式是同时调整采样数量和可接受的陈旧比例effort 越高每轮采样的键越多、阈值越严格清理越积极CPU 开销也越大。参数默认值影响范围调大后的代价建议hz10全局调度含过期、超时、复制心跳整体 CPU 上升除非有明确需求不要轻易改dynamic-hzyeshz的动态调整无保持开启active-expire-effort1仅过期清理仅过期扫描的 CPU 上升内存压力大时提到 3-5 试试我的经验是如果你发现expired_keys增长很慢但内存迟迟不降先把active-expire-effort从 1 提到 3观察used_memory和instantaneous_ops_per_sec的变化多数情况能解决问题。真要还不行再考虑hz但要接受它带来的全局副作用。3. 主从与持久化下的过期语义这一块是认知偏差最集中的地方很多主从数据不一致的报警其实是正常行为。3.1 从库为什么不自己删过期键从库碰到过期键时的行为可以总结成一句话逻辑上当作不存在物理上原样保留。读请求打到从库如果键已经过了过期时间从库会返回nil业务侧看到的结果和主库一致。但这条键值对仍然实实在在躺在从库的内存里DBSIZE也会把它算进去。它必须等到主库那边真正执行了删除、并把DEL/UNLINK命令通过复制流发过来从库才会跟着删。这个设计的原因是保证删除操作的唯一决策权在主库。设想如果从库自己判断过期并删除那么当主库因为时钟偏差、或者因为expires字典采样没抽到该键而尚未删除时主从状态就分叉了——从库删了、主库还留着一旦发生主从切换数据就出现差异而且是无法自动修复的差异。由此带来一个运维上必须知道的现象从库的内存占用通常比主库高尤其是大量键集中过期的场景。因为主库在删除的那一刻才把DEL传播下去从库的内存下降会比主库滞后一个复制往返的延迟。如果你的读副本负载很高、写了大量短 TTL 的键从库内存明显高于主库是正常的不用慌。反过来expired_keys这个指标在从库上应该基本不增长除了从库被提升为主库之后如果你在从库上看到这个数字快速增长说明它在某个时刻被提升过或者配置有问题值得查一下。3.2 RDB 加载与 AOF 重写时的过期判定差异持久化文件里怎么处理过期键也有一套明确的规则而且主库和从库的处理方式不同。生成 RDB 的时候主库会跳过所有已经逻辑过期的键不写进文件。但从库生成 RDB 时不会跳过因为它没有删除权必须把键原样保存下来等待主库的删除命令。所以同样是一次BGSAVE主库生成的文件通常比从库小。加载 RDB 的时候规则对上了主库加载时会主动丢弃已经过期的键从库加载时则全部保留逻辑上仍然按过期时间屏蔽读取。这样无论哪个方向的恢复结果都和正常运行时的状态一致。AOF 的处理逻辑类似但路径不同。AOF 重写时那些已经逻辑过期的键不会被写入新文件这能显著减小重写后的文件体积——如果你的 AOF 文件远大于数据实际占用很可能就是因为大量过期键的写入记录没有被及时收敛。AOF 的日常追加过程中键被删除无论是惰性删除还是定期删除时会追加一条DEL或UNLINK命令保证重放时状态正确。这里有个实际踩过的坑AOF 文件大小和used_memory长期不匹配很多人第一反应是有键没删干净其实往往只是还没触发重写。auto-aof-rewrite-percentage和auto-aof-rewrite-min-size这两个阈值决定了什么时候重写默认是文件大小比上次重写时增长 100% 且不小于 64MB 才触发低频写入的场景可能很久都不会重写一次。3.3 系统时钟跳变带来的批量过期过期时间的判断依赖系统时钟。Redis 记录的是绝对时间戳判断时用的是当前的系统时间所以时钟往前跳会导致批量键瞬间过期往后跳会导致过期延迟。NTP 校时是最常见的触发源。正常的小幅校正毫秒级影响不大但如果服务器长时间运行后发生一次几百毫秒甚至几秒的跳变那一瞬间所有在这段时间内到期的键会同时变成逻辑过期状态紧接着定期删除会迎来一次密集的清理主线程出现短暂但明显的卡顿。如果跳变的幅度更大甚至可能出现大面积的缓存穿透——所有客户端同时发现缓存没了一起打到数据库。比较稳妥的做法是让 NTP 用平滑校正模式而不是跳变模式。另外在应用层做缓存击穿的防护互斥锁重建、逻辑过期、空值缓存本身就是必要的不能完全指望 Redis 的过期时间是平滑的。还有一个相关的细节TTL和PTTL返回的是相对时间-1表示键存在但没有设置过期时间-2表示键不存在或已过期。很多代码里只判断了返回值大于 0 的情况把-1和-2都当成异常处理实际上这两个值的语义完全不同区分清楚能省掉不少排查时间。4. 大 key 过期把主线程卡住的排查链路这是过期机制里唯一一个能直接把线上打挂的问题值得单独用一节把排查过程完整走一遍。4.1 从 P99 毛刺到 SLOWLOG一步步缩小范围现象通常是这样的接口 P99 每隔一段时间出现一次几百毫秒的毛刺间隔不固定但每次毛刺的时间点前后INFO stats里的expired_keys有一次明显的跳变。CPU 使用率在毛刺期间打到单核 100%但 QPS 并没有异常上涨。第一步先确认是不是过期删除引起的。把INFO commandstats拉出来看如果所有业务命令的耗时都正常但整体延迟有毛刺说明问题不在命令执行本身而在主线程被别的事情占用了。这时候看INFO stats的expired_keys累计值和毛刺时间点做对照如果吻合基本锁定。第二步找是哪个键。SLOWLOG GET 20里如果能看到耗时异常的DEL或UNLINK记录直接就有答案了。但很多时候DEL是在惰性删除路径里被隐式执行的比如一次GET触发了大 key 删除SLOWLOG里记录的会是那个GET看上去是个无辜的读命令耗时 300ms。这种情况需要结合键的规模判断。第三步确认键的规模。用MEMORY USAGE keyname看单个键的内存占用用STRLEN、LLEN、HLEN、SCARD、ZCARD看元素数量。一个几百万元素的 Hash 或 ZSet内存占用可能上 GB删除时释放这些元素的耗时会非常可观。这里的耗时不是释放内存本身而是遍历并释放每个元素的数据结构。Redis 的DEL对集合类型是 O(N)N 是元素个数几百万元素在主线程上跑几百毫秒完全正常。第四步确认这些大 key 是不是设了 TTL。如果设了那么每次过期删除都是一次潜在的卡顿点而且这类键往往是批量写入的可能在同一时间集中到期。我一般的排查顺序是先看是不是过期引发的对照expired_keys→ 再看是哪个键SLOWLOG 监控→ 再看键多大MEMORY USAGE→ 最后确认删除路径DEL还是UNLINK。这个顺序能把大部分情况在十分钟内定位。4.2 UNLINK 与 lazyfree 系列参数定位到大 key 之后解决方案的核心思路是把释放内存的动作从主线程挪到后台线程。UNLINK命令就是干这个的。它和DEL的区别在于UNLINK只是在主线程里把键从字典中摘掉O(1) 的哈希表删除然后把这个键对象扔进一个后台队列由专门的lazyfree线程异步释放内存。主线程立刻返回用户感知不到延迟。但UNLINK需要改代码业务代码里很难全部替换。更省事的办法是用配置项让内部路径自动走异步释放参数默认值作用lazyfree-lazy-expireno过期删除时用异步方式释放lazyfree-lazy-evictionno内存淘汰时用异步方式释放lazyfree-lazy-server-delno隐式删除如RENAME覆盖目标键用异步方式replica-lazy-flushno从库全量同步清库时用异步方式lazyfree-lazy-expire yes是针对本文主题最直接的一条。开启之后所有由过期机制触发的删除都会走后台线程大 key 过期不再卡主线程。我个人认为对于有大量集合类型缓存的场景这个参数应该默认打开。开启之后有两个副作用要提前知道。第一used_memory的下降会变得不即时——后台线程释放需要时间INFO memory在删除后的一小段时间里可能还显示旧值。第二后台线程本身也消耗 CPU如果同时有大量大 key 过期lazyfree线程可能成为瓶颈出现内存迟迟不释放的观感。不过这个代价远小于主线程被阻塞。lazyfree-lazy-server-del也值得一起开。它覆盖的是那些看起来和过期无关、实际上同样会阻塞的路径比如RENAME一个键到一个已存在的目标键要先删目标、SETRANGE缩短字符串、GETSET覆盖旧值等等。这些场景下如果旧值是个大对象同样会卡。4.3 验证改动是否生效的方法改完配置不能只看感觉好点了得有可验证的证据。第一个方法是观察INFO stats里的lazyfree_pending_objects部分版本叫lazyfree_pending_objects也有版本通过INFO memory下的lazyfree_pending_objects暴露。这个值表示当前队列里还有多少对象等着后台释放正常应该在 0 附近波动如果持续堆积说明后台线程跟不上生产速度。第二个方法是对比改动前后的SLOWLOG。把slowlog-log-slower-than设成 1000010ms跑一轮会触发大 key 过期的业务流量对比改动前后的慢日志条数和耗时分布。这个对比最直观。第三个方法是压测复现。写一个小脚本用EVAL批量创建一个几百万元素的 Hash 并设置 5 秒 TTL然后立刻发GET请求测延迟# 造一个大 key10 秒后过期 redis-cli EVAL local t{} for i1,2000000 do redis.call(HSET, KEYS[1], f..i, i) end redis.call(EXPIRE, KEYS[1], 10) return 1 1 big:hash:demo # 另一个终端持续测延迟 redis-cli --latency -h 127.0.0.1 -p 6379redis-cli --latency会持续输出最小、最大、平均延迟。在键过期的那一刻如果lazyfree-lazy-expire是no你能看到 max 延迟突然跳到几百毫秒改成yes重启后再跑一遍这个跳变应该消失或者大幅缩小。这个对照实验我做过好几次效果非常明显也是说服团队改配置最有力的证据。做这个实验记得在独立实例上跑不要在生产上造几百万元素的大 key。另外HSET循环 200 万次在EVAL里执行本身就要几秒会阻塞主线程实验时用redis-cli --pipe或者分批写入更稳妥。5. TTL 被悄悄吃掉的几种写法过期相关的 bug 里有一大半根本不是清除机制的问题而是 TTL 压根没设上、或者被后续操作清掉了。这类问题排查起来非常费劲因为现象是明明设了过期怎么内存还是涨而根因在业务代码的某一行。5.1 SET 覆盖、RENAME、INCR 对 TTL 的不同处理Redis 里不同的写操作对已有 TTL 的处理方式是不一致的这是历史包袱也是设计选择必须逐条记住。SET是最容易踩的坑。默认情况下SET一个已存在的键会清除它的 TTL因为它本质上是重建这个键。从 6.0 开始提供了KEEPTTL选项来保留原有 TTL但默认行为没有变。所以下面这种写法是危险的# 第一次写入设置 1 小时过期 SET user:1001:profile v1 EX 3600 # 后面某处更新缓存TTL 被清掉了 SET user:1001:profile v2 # TTL 返回 -1永久有效 TTL user:1001:profile正确的写法是加上KEEPTTL或者每次更新时都带上新的EXSET user:1001:profile v2 KEEPTTL # 或者 SET user:1001:profile v2 EX 3600GETSET也有同样的问题它等价于取值 覆盖会清掉 TTL。新版本建议用SET ... GET替代。RENAME的行为又不一样它会把源键的 TTL一起带到新键名上如果目标键已存在则先删除目标键这个删除动作会阻塞见上一节。所以RENAME之后 TTL 还在但可能不是你期望的那个值。INCR、DECR、HINCRBY、LPUSH、SADD这类在原值上修改的命令不会影响 TTL。这一点是符合直觉的你只是改了值没有重建键。EXPIRE系列本身有个坑对一个不存在的键执行EXPIRE在旧版本里返回 1成功新版本里返回 0。跨版本迁移代码时要注意这个差异。另外EXPIRE的NX、XX、GT、LT选项是新版本才有的用它们可以让只在没有 TTL 时设置、只在延长 TTL 时设置这类逻辑变得原子避免先TTL再EXPIRE的竞态。5.2 批量过期引发的集中清理与随机抖动另一个高频问题是批量写入的键用了相同的 TTL导致它们在同一个时间点集中过期。这个现象常被称为缓存雪崩危害有两个层面对数据库的冲击所有请求同时穿透和对 Redis 自身的冲击定期删除在短时间内要处理海量过期键主线程被拖慢。解决办法是在 TTL 上加随机抖动。这不是差不多就行的经验之谈而是有明确计算方式的import random BASE_TTL 3600 # 基础过期时间1 小时 JITTER_RATIO 0.1 # 抖动幅度 10% def cache_set(redis_client, key, value): ttl int(BASE_TTL * (1 random.uniform(-JITTER_RATIO, JITTER_RATIO))) redis_client.set(key, value, exttl)选 10% 还是 20% 取决于你的容忍度。抖动幅度越大过期越分散但提前过期的键也越多缓存命中率会略有下降。对于 1 小时 TTL 的会话数据10% 意味着有些键 54 分钟就过期了如果业务能接受这个误差就没问题如果业务要求必须至少 1 小时那就只能往上抖1 到 1.1 倍代价是有一部分键活得更久。还有一个更隐蔽的版本不是 TTL 相同而是写入时间集中导致的过期集中。比如凌晨 3 点跑一个全量预热任务给 500 万个键设了 24 小时 TTL那么第二天凌晨 3 点就会有 500 万个键同时过期。这种场景下加抖动尤其重要而且抖动幅度要覆盖足够宽的时间窗10% 可能不够得按预热总量和清理能力来算。粗略的估算方式是假设active-expire-effort1、hz10每轮 SLOW 模式大约能处理几千到上万个过期键视键的大小而定每秒 10 轮那么每秒的清理能力在几万到十几万之间。500 万个键集中过期理论清理时间在几十秒量级期间主线程会有明显的 CPU 压力。如果这个数字再大一个量级就需要考虑拆批预热或者提高 effort 了。5.3 用 SCAN 定位过期但还占内存的键如果你怀疑有大量僵尸数据逻辑过期但物理还在需要在不影响线上的前提下把它们找出来。KEYS *是绝对禁止的它会阻塞主线程遍历整个主字典。正确做法是用SCAN渐进遍历。#!/bin/bash # 统计某个前缀下已逻辑过期但仍存在的键数量 CURSOR0 COUNT0 PREFIXtmp:batch:* while true; do RESULT$(redis-cli --raw SCAN $CURSOR MATCH $PREFIX COUNT 1000) CURSOR$(echo $RESULT | head -n 1) KEYS$(echo $RESULT | tail -n 2) for k in $KEYS; do TTL$(redis-cli --raw TTL $k) if [ $TTL -eq -2 ]; then COUNT$((COUNT 1)) fi done [ $CURSOR 0 ] break done echo 逻辑已过期但物理仍存在的键: $COUNT这段脚本有个问题SCAN本身在新版本里遇到过期键会触发惰性删除所以你扫一遍下来等于顺手把僵尸数据清了统计到的是一个偏小的数字。这既是测量也是清理某种程度上是好事但如果你只是想测量就会发现数字对不上。更稳妥的测量方式是用INFO keyspace的keys和expires两个数字做长期趋势观察如果expires稳定但used_memory持续上涨且没有新写入那基本可以确定有大量键物理未删。要用MEMORY USAGE配合找大 key可以这样redis-cli --memkeys这个内置选项会做一次渐进式扫描并输出内存占用最大的键原理是SCAN加MEMORY USAGE对线上影响可控。类似的还有--bigkeys按元素数量找大 key和--hotkeys需要maxmemory-policy是 LFU 系列才有效。这几个工具是排查过期相关问题的标配建议提前在测试环境跑熟。6. 过期策略和 maxmemory 淘汰策略的配合把过期机制和淘汰机制放在一起看才能理解内存压力下 Redis 的完整决策链。这两套机制经常被混为一谈实际上它们管的是完全不同的事。6.1 volatile-* 与 allkeys-* 的筛选边界过期机制管的是到点了该删淘汰机制管的是内存满了该删谁。当used_memory超过maxmemory时Redis 会按maxmemory-policy挑选键来淘汰这时候过期时间和淘汰策略就产生了交叉。策略按筛选范围分两类策略候选范围挑选依据volatile-lru仅设了 TTL 的键最近最少使用volatile-lfu仅设了 TTL 的键访问频率最低volatile-random仅设了 TTL 的键随机volatile-ttl仅设了 TTL 的键剩余 TTL 最短allkeys-lru所有键最近最少使用allkeys-lfu所有键访问频率最低allkeys-random所有键随机noeviction不淘汰写入直接报错volatile-ttl值得单独说它挑的是最快要过期的键直觉上很像提前执行过期但它和过期机制没有任何代码路径上的关系只是恰好用了同一份 TTL 数据。这里有个经典的配置错误用了volatile-lru但大量键没有设 TTL。结果是maxmemory到了候选池里只有少量设了 TTL 的键淘汰几个就空了然后 Redis 开始对写操作返回OOM command not allowed when used memory maxmemory业务侧看到的是内存没满为什么写不进去。这个问题的根因就是候选池和实际内存占用不匹配。判断方法很直接对比INFO keyspace里的keys和expires。如果expires远小于keys用volatile-*系列就是危险的。要么把所有键都设上 TTL要么换成allkeys-*。另一个容易踩的点是allkeys-lru会无视 TTL。理论上一个键设了 10 分钟 TTL但你用的是allkeys-lru且它是热点键它就永远不会被淘汰——这本身没问题因为到点自然会过期。但如果它没到点且内存已经满了allkeys-lru可能淘汰掉其他更有价值的键而把这个即将过期的键留着这就不太经济了。这种场景下volatile-ttl反而更合适。6.2 该盯的几个指标过期相关的监控不用多盯住下面这几个就够了指标位置关注点expired_keysINFO stats累计过期数看增速突变evicted_keysINFO stats累计淘汰数非 0 说明内存压力大keyspace_hits/missesINFO stats命中率下滑可能是过期策略出问题used_memory/used_memory_rssINFO memory两者差值反映碎片mem_fragmentation_ratioINFO memory超过 1.5 需要关注expires/avg_ttlINFO keyspaceTTL 覆盖率与均值lazyfree_pending_objectsINFO memory开启 lazyfree 后看是否堆积expired_keys的用法是看增速的突变而不是绝对值。正常的缓存业务这个数字一直在涨没什么意义有意义的是某分钟它突然涨了十倍这通常对应着一次批量过期值得看看是不是 TTL 设置有问题。evicted_keys非 0 就意味着发生过内存淘汰这在很多团队里是条告警线。如果你的业务不能容忍淘汰那应该在maxmemory留足够余量而不是靠淘汰策略兜底。命中率这块要结合业务看。有些团队看到keyspace_hits下降就怀疑过期机制实际上更常见的原因是淘汰、或者缓存穿透。真正的过期导致的命中率下降特征是集中在某个时间窗口、和 TTL 设置强相关。6.3 一份可以直接抄的参数清单最后把上面所有讨论收敛成一份配置建议。这份清单针对的是有大量带 TTL 的缓存键、偶尔有大集合类型这种最常见的场景其他场景需要按实际情况调整# 过期清理的积极性内存压力大时从 1 提到 3-5 active-expire-effort 3 # 保持动态 hz避免空闲实例空转消耗 CPU dynamic-hz yes # 过期删除走异步释放避免大 key 过期阻塞主线程 lazyfree-lazy-expire yes # 隐式删除也走异步覆盖 RENAME、SETRANGE 等场景 lazyfree-lazy-server-del yes # 淘汰策略所有键都设了 TTL 时用 volatile-lru # 混合场景用 allkeys-lru 更安全 maxmemory-policy allkeys-lru # 慢日志阈值抓 10ms 以上的删除操作 slowlog-log-slower-than 10000 slowlog-max-len 256active-expire-effort设成 3 而不是更高是因为再往上 CPU 收益递减得很快。我做过对比从 1 提到 3 对内存回收速度的提升很明显从 3 提到 5 就相对有限了再往上基本看不到差别。具体数值还是要在自己的实例上实测和键的数量、类型、大小都有关系。maxmemory-policy选allkeys-lru而非volatile-lru是出于安全兜底的考虑。只要有任何一部分键没设 TTLvolatile-*就有写满报错的风险而allkeys-lru至少有键可淘汰。代价是可能淘汰掉一些本该长期保留的键所以如果你的实例里既有缓存又有持久数据更合理的做法是拆成两个实例而不是靠一个淘汰策略兼顾两种需求。配置生效的验证方式也要说一下CONFIG SET可以在线改大部分参数但lazyfree-lazy-expire这类影响内部路径的改完需要观察一轮完整的过期周期才能确认。CONFIG GET active-expire-effort可以确认改动是否落盘别忘了还要同步改配置文件否则重启后会丢失。我在实际使用中体会最深的一点是过期机制的参数不多但每一个都牵一发动全身。与其一次改一堆参数然后观察整体指标不如一次只动一个用redis-cli --latency或者业务侧的 P99 曲线做前后对照。缓存的很多优化最后变成调参玄学根源就在于改得太快、观察得太粗——把变量控制住Redis 的行为其实相当可预测。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑