☰
Redis过期键清除:逻辑过期、物理删除与排查调优
2026/10/27 13:32:56 网站建设 项目流程

凌晨两点被 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:keys=1000000,expires=800000,avg_ttl=3600000,表示 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_STALE,10%),说明这个库里还有大量过期键没清,就继续抽下一批;如果比例低于阈值,认为清得差不多了,本轮结束,切换到下一个 db。

这个循环受两个上限约束:一个是时间预算,SLOW 模式默认是25% / hz,hz=10时约 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设成 10000(10ms),跑一轮会触发大 key 过期的业务流量,对比改动前后的慢日志条数和耗时分布。这个对比最直观。

第三个方法是压测复现。写一个小脚本,用EVAL批量创建一个几百万元素的 Hash 并设置 5 秒 TTL,然后立刻发GET请求测延迟:

# 造一个大 key,10 秒后过期 redis-cli EVAL "local t={} for i=1,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 6379

redis-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,或者每次更新时都带上新的EX:

SET user:1001:profile "v2" KEEPTTL # 或者 SET user:1001:profile "v2" EX 3600

GETSET也有同样的问题,它等价于"取值 + 覆盖",会清掉 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, ex=ttl)

选 10% 还是 20% 取决于你的容忍度。抖动幅度越大,过期越分散,但"提前过期"的键也越多,缓存命中率会略有下降。对于 1 小时 TTL 的会话数据,10% 意味着有些键 54 分钟就过期了,如果业务能接受这个误差,就没问题;如果业务要求必须至少 1 小时,那就只能往上抖(1 到 1.1 倍),代价是有一部分键活得更久。

还有一个更隐蔽的版本:不是 TTL 相同,而是写入时间集中导致的过期集中。比如凌晨 3 点跑一个全量预热任务,给 500 万个键设了 24 小时 TTL,那么第二天凌晨 3 点就会有 500 万个键同时过期。这种场景下加抖动尤其重要,而且抖动幅度要覆盖足够宽的时间窗,10% 可能不够,得按预热总量和清理能力来算。

粗略的估算方式是:假设active-expire-effort=1、hz=10,每轮 SLOW 模式大约能处理几千到上万个过期键(视键的大小而定),每秒 10 轮,那么每秒的清理能力在几万到十几万之间。500 万个键集中过期,理论清理时间在几十秒量级,期间主线程会有明显的 CPU 压力。如果这个数字再大一个量级,就需要考虑拆批预热或者提高 effort 了。

5.3 用 SCAN 定位"过期但还占内存"的键

如果你怀疑有大量僵尸数据(逻辑过期但物理还在),需要在不影响线上的前提下把它们找出来。KEYS *是绝对禁止的,它会阻塞主线程遍历整个主字典。正确做法是用SCAN渐进遍历。

#!/bin/bash # 统计某个前缀下已逻辑过期但仍存在的键数量 CURSOR=0 COUNT=0 PREFIX="tmp: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 256

active-expire-effort设成 3 而不是更高,是因为再往上 CPU 收益递减得很快。我做过对比,从 1 提到 3 对内存回收速度的提升很明显,从 3 提到 5 就相对有限了,再往上基本看不到差别。具体数值还是要在自己的实例上实测,和键的数量、类型、大小都有关系。

maxmemory-policy选allkeys-lru而非volatile-lru,是出于"安全兜底"的考虑。只要有任何一部分键没设 TTL,volatile-*就有写满报错的风险,而allkeys-lru至少有键可淘汰。代价是可能淘汰掉一些本该长期保留的键,所以如果你的实例里既有缓存又有持久数据,更合理的做法是拆成两个实例,而不是靠一个淘汰策略兼顾两种需求。

配置生效的验证方式也要说一下:CONFIG SET可以在线改大部分参数,但lazyfree-lazy-expire这类影响内部路径的,改完需要观察一轮完整的过期周期才能确认。CONFIG GET active-expire-effort可以确认改动是否落盘,别忘了还要同步改配置文件,否则重启后会丢失。

我在实际使用中体会最深的一点是:过期机制的参数不多,但每一个都牵一发动全身。与其一次改一堆参数然后观察整体指标,不如一次只动一个,用redis-cli --latency或者业务侧的 P99 曲线做前后对照。缓存的很多"优化"最后变成"调参玄学",根源就在于改得太快、观察得太粗——把变量控制住,Redis 的行为其实相当可预测。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询