尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Redis Key过期策略详解:从惰性删除到生产实践

发布时间:2026/9/24 21:46:48

资讯中心
01
ARTICLE

Redis Key过期策略详解:从惰性删除到生产实践

Redis Key过期策略详解:从惰性删除到生产实践
刚把公司这套 Redis 集群的内存水位线拉下来顺手把 key 过期策略这块又完整梳理了一遍。说个挺有意思的事很多人在面试时都能把“惰性删除加定期删除”这套说法背得滚瓜烂熟但真到了线上内存告警、大 key 莫名消失、从库读到过期数据的时候才发现自己对过期策略的理解只停留在背概念层面。这篇文章不打算继续对着教科书念定义而是会从命令用法、底层结构、源码路径、持久化与主从场景、生产踩坑这几个维度把 Redis key 过期策略这条链路彻底摊开讲清楚。无论你是刚接触 Redis 的初学者还是已经负责线上集群的运维/开发这篇内容应该都能给你一些之前没注意到的细节。1. 为什么需要过期时间从缓存雪崩和 Session 失效说起1.1 过期时间到底守住了什么很多人觉得 key 过期只是“让缓存自动失效”这么简单实际上它承载了非常多核心业务语义。先说最常见的 Session / Token 场景。用户的登录态不可能永久有效必须有一个失效时间过期之后要求重新登录。这个场景在 Redis 里就是一个SET token:xxx value EX 7200两小时后自动失效。验证码、短信验证码、邮箱验证码之类的基本上都是 5 分钟有效期。这类 key 如果不设置过期时间Redis 内存里就会堆满永远没人再访问的验证码相当于内存泄漏。限流场景也用得到过期时间。比如滑动窗口限流记录某个用户 1 分钟内访问了多少次用INCR加上EXPIRE组合实现。窗口一过 key 自动消失计数归零。还有一类更重要的场景分布式锁。Redis 分布式锁通常用SET lock:order:123 token EX 30来加锁。如果持有锁的进程崩溃了、或者 GC 停顿了锁必须自动过期释放否则其他进程永远拿不到锁系统直接死锁。可以看到过期策略不是一个可有可无的优化项而是一系列业务功能成立的前提。1.2 没有过期策略会怎样假设你是一个缓存服务往 Redis 里写了一大堆热点数据每个 key 都设置了几分钟后过期。这个操作本身是给数据库减压的。如果没有过期策略这些缓存数据就会永久驻留。等到原来的数据在数据库里已经更新Redis 里的旧缓存还在响应查询就会造成严重的数据不一致。此时你只能靠人工在代码里删除缓存的逻辑来兜底一旦漏删线上事故就来了。从内存角度看也一样。长时间运行的服务key 只增不减最终内存耗尽写入失败或者触发 OOM。Redis 的单线程模型决定了一般不会直接把进程搞崩但OOM command not allowed when used memory maxmemory这个报错相信不少人都见过。内存被打满之后所有写操作直接失败这在生产环境是非常恐怖的故障。所以过期时间既是数据生命周期管理的手段也是内存保护的底线。理解了这一点后面再看具体的实现机制就会清楚 Redis 为什么既要“惰性删除”又要“定期删除”。2. 过期命令全拆解EXPIRE、TTL 和时间戳的底层语义2.1 操作过期时间的命令家族Redis 里操作过期时间有一套完整的命令很多初学者只会用EXPIRE和TTL但实际工作中其他命令也经常用到。我把常用命令整理成了下面这个表。命令作用时间单位备注EXPIRE key seconds设置相对过期时间秒最常用PEXPIRE key milliseconds设置相对过期时间毫秒更高精度EXPIREAT key timestamp设置绝对过期时间Unix 秒级时间戳适合统一时间点失效PEXPIREAT key ms-timestamp设置绝对过期时间Unix 毫秒级时间戳很少直接用SET key value EX seconds写入并设置过期秒原子操作替代 SETEXPIREPSETEX key ms value写入并设置过期毫秒不常用TTL key查询剩余时间秒-1 表示永久-2 表示不存在或已过期PTTL key查询剩余时间毫秒精度更高PERSIST key移除过期时间无变成永久 key这里有一个很容易被忽略的操作细节SET key value不带 EX 参数会直接清掉这个 key 之前设置的过期时间。不少业务方在更新缓存时用普通的SET覆盖旧值结果发现 TTL 变成了 -1缓存永远不失效了数据就再也没更新过。这是一个非常经典的生产事故后面实战部分我还会再提。实际使用中我建议一律用SET key value EX n这种原子写法不要再写SET加EXPIRE两条命令。因为两条命令之间如果发生异常key 就成了永久 key线上数据就会出问题。看一个具体的命令例子 SET coupon:10086 code888 EX 300 OK TTL coupon:10086 (integer) 297 EXPIRE coupon:10086 600 (integer) 1 TTL coupon:10086 (integer) 598 PERSIST coupon:10086 (integer) 1 TTL coupon:10086 (integer) -12.2 过期时间在 Redis 里到底是怎么存的清楚了命令之后再看内部存储结构。Redis 的每个数据库结构redisDb里有两个关键的字典一个是主字典dict存放所有 key 和 value另一个是expires字典专门存放哪些 key 设置了过期时间以及对应的过期时间戳。expires字典并不是把 key 复制一份而是保存了一个指向主字典里同一个 key 对象的引用value 则是这个 key 的绝对过期时间毫秒级时间戳。也就是说一个 key 一旦设置了过期时间它就会同时存在于主字典和 expires 字典里。等到它过期被删除时两个字典里的对应条目会一起清理。这带来一个直观的影响每个带 TTL 的 key 会比不带 TTL 的 key 多占用一点内存虽然量不大但如果你有上千万个带 TTL 的 key这部分开销也要算进内存规划里。另外需要注意Redis 里的 TTL 本质上是一个绝对时间点而不是一个“剩余秒数”。EXPIRE key 300在执行时实际计算的是“当前时间戳 300 秒”然后存到 expires 字典里。所以如果你用EXPIREAT指定一个过去的时间戳或者用EXPIRE设置一个负数秒数Redis 会直接把这个 key 删掉。这个特性在主从复制场景里非常关键。主库执行删除之后会把一条 DEL 命令传播给从库保证两边数据一致。关于主从的细节我在第 6 节单独展开。3. 惰性删除访问路径上的一道隐形检查3.1 每次访问 key 都会先过一遍“过期检查”“惰性删除”这个名字听起来有点学术其实逻辑非常简单当你访问某个 key 的时候Redis 先检查它有没有过期如果过期了就先把它删掉然后当作这个 key 不存在来处理。Redis 里几乎所有命令在执行前都会走一层 key 查找逻辑。对于读命令走lookupKeyRead对于写命令走lookupKeyWrite而这两个函数最终都会调用到expireIfNeeded。这个函数就是惰性删除的核心入口。它的伪代码逻辑大概是这样的int expireIfNeeded(redisDb *db, robj *key) { // 从 expires 字典里取出过期时间 mstime_t when getExpire(db, key); if (when 0) return 0; // 没设置过期时间 // 当前时间还没到过期时间不处理 if (mstime() when) return 0; // 走到了这里说明 key 已经过期 // 主库负责真正删除并传播 DEL 命令 if (server.masterhost NULL) { deleteExpiredKeyAndPropagate(db, key); } return 1; }如果返回 1命令层就知道这个 key 已经不存在了于是向客户端返回 nil 或者空结果。这就是为什么你会遇到“明明 Redis 内存里还有这个 key但 GET 已经返回 nil”的情况——你访问它的那一瞬间它才被真正删除。更有意思的是TTL命令本身也会触发过期检查。你查一个已经过期的 key 的 TTL大概率返回 -2而不是返回一个剩余秒数。因为 TTL 查询过程中也会先执行过期检查顺便把这个 key 删掉。这里有一个细节值得注意在从库上expireIfNeeded并不会真正执行删除操作因为从库的删除动作必须由主库发来的 DEL 命令驱动否则主从数据就会不一致。从库如果遇到一个逻辑上已过期的 key在收到主库 DEL 之前它是“假装看不见”还是真的会返回这里其实要分情况我放到第 6 节主从复制里详细说因为这里引出的坑比大多数人想象得多。3.2 惰性删除的优点是省事缺点是会留垃圾惰性删除最大的好处是省 CPU。它不需要额外的后台任务去扫描所有 key只在访问时顺带检查开销几乎可以忽略不计。但它也有一个非常明显的缺点没有被访问到的过期 key会一直残留在内存里。举个极端例子你往 Redis 里一次性写入了 100 万个 key全部设置 1 分钟过期然后这一分钟内没有任何业务去访问它们。等到 1 分钟之后这 100 万个 key 在逻辑上全部过期了但因为惰性删除只在“被访问时”触发这些 key 依然躺在内存里直到定期删除任务或者内存淘汰策略来处理它们。如果 Redis 同时配置了比较小的maxmemory这些残留的过期 key 甚至可能先触发内存淘汰造成很多原本没过期的 key 被allkeys-lru随机淘汰掉影响缓存命中率。所以惰性删除只能算一个兜底机制Redis 必须再配一个主动清理机制也就是下面要说的定期删除。4. 定期删除抽样清理的完整流程4.1 serverCron 与 activeExpireCycle 的配合方式Redis 内部有一个周期函数serverCron它按照配置项hz设定的频率执行默认是每秒执行 10 次。serverCron会负责很多后台任务其中就包括调用activeExpireCycle函数来清理过期 key。activeExpireCycle做的事情可以概括成几个步骤遍历所有数据库默认 16 个。在某个数据库中从expires字典里随机抽取一批 key经典实现里每轮默认抽取 20 个。检查这批 key 中有多少个已经过期如果过期了就直接删除。如果抽样中过期的比例超过 25%说明这个数据库里过期 key 比较密集就继续抽下一批。如果过期比例低于 25%说明当前数据库过期 key 不多跳到下一个数据库继续处理。整个清理过程受时间预算约束不能一直在循环里跑否则会影响正常命令的响应。这是一个典型的“随机抽样 动态调整”算法。它的核心思想就是不追求一次性把所有过期 key 清理干净而是用可控的 CPU 开销不断把过期 key 占用的内存释放出来。为什么抽样比例是 25% 这个值这是一个经验值。如果阈值设得太低比如 5%那每轮抽到过期 key 的概率很低会提前跳到下一个数据库清理速度太慢如果设得太高比如 80%说明只有大量 key 过期时才值得继续清理平时可能残留较多垃圾。25% 是 Redis 作者在权衡 CPU 和内存之后选出来的一个比较均衡的数值。Redis 7.0 之后还引入了一个配置项active-expire-effort取值范围是 1 到 10默认 1。调大这个值activeExpireCycle会花更多 CPU 时间去清理过期 key适合内存压力大、但 CPU 有富余的场景。4.2 为什么定期删除必须采用抽样而不是全量扫描很多人第一次知道定期删除是抽样清理时都会有一个疑问全量扫描一遍所有 key把过期的删掉不是更干净吗理论上确实更干净但在 Redis 的单线程模型下这是一个非常危险的想法。Redis 的命令处理是单线程的如果activeExpireCycle全量扫描一个包含上千万个 key 的数据库它会把事件循环阻塞住。在它扫描的这段时间里所有客户端的读写命令都会排队等待表现就是 Redis 的响应时间突然飙高甚至出现秒级卡顿。对线上的核心链路来说这是完全不可接受的。抽样清理的本质是用“清理不够彻底”来换取“服务不卡顿”。Redis 把这个权衡控制得非常精细每一轮清理都有严格的时间预算时间到了就立刻退出循环把 CPU 交还给命令处理。理解了这个设计哲学你就能明白为什么 Redis 文档里会说keyspace里的 key 数量往往不能直观反映真正的内存占用因为有很多已过期的 key 正在等待后台任务慢慢清理。还有一点补充虽然定期删除不是全量扫描但在某些极端场景下大量 key 同时过期依然可能造成短时间的事件循环阻塞。解决这个问题的手段是后面实战部分会提到的给 TTL 加随机抖动避免集中过期。5. 内存淘汰策略当过期清理兜不住时的分级降级5.1 maxmemory 与八种淘汰策略过期策略解决的是“key 到时间了该删”的问题但还有一种情况是key 还没过期内存提前不够了。这时候就需要 Redis 的内存淘汰策略来兜底。启用方式很简单在配置项里设置maxmemory 4gb maxmemory-policy allkeys-lrumaxmemory不设置的话64 位系统下默认不限制内存使用等于把命运交给操作系统。生产环境最好不要这么干一定要设置maxmemory。Redis 提供了 8 种淘汰策略可以分成两类策略作用范围说明noeviction无默认策略内存满了后写操作直接报错allkeys-lru所有 key近似 LRU淘汰最久没被访问的allkeys-lfu所有 key近似 LFU淘汰访问频率最低的allkeys-random所有 key随机淘汰volatile-lru只针对设置了 TTL 的 key近似 LRUvolatile-lfu只针对设置了 TTL 的 key近似 LFUvolatile-random只针对设置了 TTL 的 key随机淘汰volatile-ttl只针对设置了 TTL 的 key优先淘汰剩余 TTL 最小的注意volatile-*这一组它们的操作范围只限于expires字典。如果 Redis 里所有 key 都没设置过期时间那volatile-*策略等于空闲状态内存满了之后写操作同样会报错。这里我要特别提醒一个认知误区内存淘汰和过期删除是两个完全独立的机制。过期删除是时间驱动的到了时间就删内存淘汰是内存压力驱动的内存不够了才删。它们之间没有任何依赖关系只是都会导致 key 被删除。5.2 Redis 的 LRU 不是真正的 LRURedis 里的allkeys-lru和volatile-lru官方叫法是“近似 LRU”不是严格意义上的 LRU。为什么是“近似”因为如果每次淘汰都遍历所有 key 找一个最久没访问的代价太大了。Redis 的实现是从所有 key 里随机抽取maxmemory-samples个作为样本默认 5 个在这批样本里找到最久未访问的那个删除掉。这种策略下淘汰结果不一定全局最优但胜在开销小。如果业务对缓存命中率要求比较高可以适当调大maxmemory-samples比如调到 10这会提升 LRU 判断的准确性代价是每次淘汰时多花一点 CPU。LFU 是 Redis 4.0 引入的它统计的是访问频率而不是最后访问时间。实现上用一个 16 位的近似计数器记录访问频率并且引入了衰减逻辑避免一个曾经很热但现在已经没人访问的 key 一直占着位置。实际选型我给个经验性的建议纯缓存场景所有 key 都是过期的缓存数据直接用allkeys-lru最简单。混合场景既有缓存又有需要长期保留的业务 key优先考虑allkeys-lru但要评估是否接受长期 key 被淘汰。热点访问非常集中的场景用allkeys-lfu可能比 lru 更合适比如活动页面的热数据。volatile-ttl慎用因为它只从抽样里挑 TTL 最小的抽样随机性可能导致结果和预期差很多。另外如果业务完全无法接受 key 被自动淘汰那就保持noeviction内存满了就让写操作报错然后靠监控告警去扩容或者清理。这个策略的优点是行为可预期不会出现缓存 key 莫名消失的情况。6. 与持久化、主从复制配合时的过期语义6.1 RDB 文件里的过期 key 怎么处理RDB 持久化是把内存数据快照写入磁盘文件。生成快照的时候Redis 会检查每个 key 是否过期已经过期的 key 不会写进 RDB 文件。但加载 RDB 文件时就分角色了。主库加载 RDB 的时候如果一个 key 在当前时间点已经过期会直接被丢弃不加载进内存。从库加载 RDB 的时候则不会做这个判断而是把所有 key 都原样加载。为什么从库要加载过期 key这是为了保证主从数据的一致性——从库不应该自己决定“这个 key 过期了”而应该等主库发来 DEL 命令再删除。这里就藏着一个经典问题如果主库做了一个 RDB 恢复但从库还在用旧数据主库可能已经删除了某个 key而从库的 RDB 里还留着它。主从同步协议会通过增量命令来纠正但如果网络隔离时间比较长从库上就会一直存在“逻辑上已过期”的数据直到链路恢复。6.2 AOF 文件里的过期 key 怎么处理AOF 记录的是写命令key 过期时的处理方式和 RDB 不同。当一个 key 因为惰性删除或者定期删除被清理时Redis 不只是从内存里删掉它还会往 AOF 文件里追加一条DEL命令。这样 AOF 重放的时候这个 key 也会被正确删掉。AOF 重写的时候已经过期的 key 同样会被过滤掉不会写进新的 AOF 文件。所以 AOF 重写之后文件里不会残留大量过期 key 的写命令体积也会明显变小。6.3 主从复制下从库为什么可能返回过期数据这是我在实际生产环境里踩得最惨的一个坑。Redis 主从模式下过期删除的权威节点是主库。主库负责执行过期删除动作然后向从库发送 DEL 命令。从库不会主动按期删除本地 key即使从库本地的系统时间已经超过了这个 key 的过期时间。这带来的直接后果是如果主从之间的网络链路断开从库无法收到主库的 DEL 命令它就会继续保留“逻辑上已经过期”的 key。此时如果业务把读请求切到从库就有可能读到已经过期的脏数据。对于这个问题的规避方案经典的 Redis 版本里没有特别完美的内置方案只能通过三个手段配合尽量避免在从库上承担强一致性的读请求从库只做异步备份、报表、大数据抽取这类的弱一致性场景。监控主从复制链路状态一旦发现从库与主库复制断连时间过长立刻把从库摘掉流量。业务层面对数据的时效性做兜底比如读出来后做一次本地校验。Redis 7.0 之后社区对从库的过期处理做了一些改进至少在访问路径上会更严格地处理逻辑过期 key但架构层面的原则没有变最终以主库的指令为准。理解了这一点回过头再看第 3 节里说的从库expireIfNeeded逻辑你就会明白为什么从库检查到过期后只是“假装”它不存在而不会真正执行删除操作。7. 生产环境的最佳实践与踩坑记录7.1 大量 key 同时过期引发的缓存货源缓存雪崩是一个经典问题它和过期策略直接相关。假如业务在每天零点的定时任务里批量写入缓存统一设置成“3600 秒后过期”。那么到了凌晨 1 点这一大批 key 会同时过期。这个时候 Redis 的主动删除周期任务会在短时间内发现大量过期 keyCPU 消耗快速上升同时所有请求全部穿透到数据库数据库压力瞬间飙升。这个问题的解法很朴素给过期时间加随机抖动。SET hotkey:001 value EX 3600 SET hotkey:002 value EX 3660 SET hotkey:003 value EX 3720也就是说每个 key 的 TTL 都在基础值上增加一个随机偏移量比如 0 到 600 秒之间的随机值。这样一来key 的过期时间被摊开不会集中在同一时刻触发大面积删除。代码层面的写法一般是int baseTtl 3600; int randomOffset ThreadLocalRandom.current().nextInt(600); redis.set(key, value, baseTtl randomOffset);这个技巧成本极低收益非常大强烈建议所有批量写入的业务都加上。7.2 大 key 过期导致的延迟抖动再说一个隐蔽的问题大面积删除 key 的时候单个 key 如果非常大删除动作本身可能造成阻塞。Redis 删除一个字符串 key 就是释放一块内存速度很快。但如果删除的是一个包含几十万元素的 list、set 或者 hash释放这块内存需要的时间就不可忽略了。在 Redis 单线程模型下这个释放过程会阻塞主线程。如果你发现线上 Redis 偶尔出现“秒级延迟毛刺”而且时间点和批量 key 过期的时间点吻合那八成就是大 key 过期导致的。Redis 4.0 引入了lazyfree机制可以解决这个问题。在配置文件里设置lazyfree-lazy-expire yes开启之后过期 key 的删除操作会交给后台线程异步执行主线程不再阻塞。除此之外lazyfree-lazy-eviction yes和lazyfree-lazy-server-del yes也建议顺手打开它们分别对应内存淘汰触发的删除和部分命令导致的内部删除。我的建议是如果你在线上还没有开启lazyfree-lazy-expire抓紧时间评估一下这个配置对降低延迟抖动很有帮助。不过也要知道异步删除释放内存会有一定延迟监控内存水位时要留一点余量。7.3 监控指标与排查手段最后分享几个我平时排查过期问题必看的指标。执行INFO stats可以看到两个关键计数器expired_keys: 1234567 evicted_keys: 89expired_keys是累计删除的过期 key 数量evicted_keys是累计被内存淘汰的 key 数量。如果evicted_keys在快速增长基本说明内存压力已经传导到缓存命中率了这时候要检查maxmemory-policy配置是否合理以及是否需要扩容。用INFO keyspace可以看到每个数据库里的 key 总量和带过期时间的 key 数量db0:keys100000,expires80000,avg_ttl123456其中expires表示数据库里有多少 key 设置了过期时间avg_ttl是所有带 TTL key 的平均剩余时间。如果expires占比很高说明大量 key 都设置了过期时间定期删除任务的压力会比较大。排查大 key 可以用redis-cli --bigkeys它会扫描整个实例并按类型输出最大的几个 key。但这个命令在线上高峰期尽量少跑它会消耗一定性能建议在低峰期执行。其实我个人在长期维护 Redis 集群的过程中最深的体会是这句话不要把过期策略当作一个独立的知识点去背它是和业务写入模式、内存模型、持久化机制、主从架构联动的一整套工程问题。你理解得越深遇到线上告警时定位问题的速度就越快不会一上来就想着重启和扩容。比如我现在接到凌晨内存告警第一反应不是看集群监控大盘而是先分析有没有某个业务线批量写入后马上过期或者某个缓存 key 的 TTL 设置是否合理。大部分看起来玄学的问题最后都能在过期策略这里找到答案。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。