一文搞懂 Redis 过期删除与内存淘汰:把它想成一家 24 小时便利店 文章目录先把便利店和 Redis 对上号给 Key 贴一张“保质期标签”TTL 返回的 -1 和 -2 是什么意思一个很隐蔽的坑更新商品时把保质期擦掉了时间到了Key 会瞬间从内存消失吗被动过期顾客拿到收银台才发现过期主动过期店员定期巡检货架Redis 关机时保质期会暂停吗过期删除和内存淘汰究竟差在哪里maxmemory这家店到底有多少货架货架满了八类老策略和两位新店员怎么选noeviction货架满了暂停进货allkeys-lru优先扔掉最近没被访问的商品allkeys-lfu优先扔掉长期不受欢迎的商品random闭着眼睛随机拿走volatile-ttl谁最快到期先清谁Redis 8.6 的 LRM很久没补货或改价的先走volatile 策略最容易踩的坑Redis 为什么只做“近似 LRU/LFU”到底该选哪一种策略1. 数据能不能被自动丢弃2. 所有 Key 都是缓存吗3. 热点是短期变化还是长期稳定4. TTL 是否能反映业务价值动手实验亲眼看见 Key 过期和被淘汰启动隔离实例验证 TTL、PERSIST 和 KEEPTTL观察过期和淘汰计数停止实验实例Java/Jedis 示例优惠券过期与会话续期线上内存为什么看起来没有立刻下降生产环境常见的五个坑1. 缓存 Key 忘记设置 TTL2. 更新 Value 时意外清除 TTL3. 大量 Key 在同一秒过期4. 使用 volatile 策略却没有足够的 TTL Key5. 只配置策略不监控结果总结参考资料图 1Redis 像一家货架有限的 24 小时便利店既要处理过期商品也要在货架装满时决定腾出哪些位置。晚上 11 点 58 分街角的“Redis 便利店”突然忙了起来。店长小 R 刚把一批“午夜促销券”摆上货架每张券都贴着标签零点失效。另一边外卖订单、用户会话、商品详情还在不断送进仓库货架已经快被塞满了。新来的店员问“过期商品不是会自动消失吗那货架为什么还会满”小 R 摇摇头“保质期到了解决的是‘东西什么时候不能再卖’货架满了解决的是‘现在必须扔掉谁才能放进新东西’。这是两套机制。”这句话正好点出了 Redis 中最容易混淆的两个概念过期删除expirationKey 到达 TTL 后失效内存淘汰evictionRedis 达到maxmemory后为新数据腾出内存。它们经常同时出现却不是一回事。今天我们就把 Redis 当成一家 24 小时便利店从一张即将过期的优惠券开始把 TTL、主动过期、被动过期以及各种淘汰策略讲清楚。先把便利店和 Redis 对上号图 2用便利店里的商品、保质期和货架对应 Redis 的 Key、TTL 与内存空间。先建立一份“翻译表”后面的故事就不会跑偏便利店里的东西Redis 中的概念一件商品一个 Key 及其 Value商品条码Key 名称商品保质期TTL零点过期的优惠券带过期时间的临时 Key店内货架Redis 可使用的内存收银员结账时发现商品过期被动过期店员定时巡检临期商品主动过期货架已满必须扔掉一些商品内存淘汰店长制定的清货规则maxmemory-policy类比只能帮助理解真正决定行为的仍然是 Redis 命令和配置。接下来我们一层一层拆开。给 Key 贴一张“保质期标签”Redis 的 Key 默认没有过期时间。如果不主动删除它可以一直存在。给 Key 设置过期时间最常见的方式是EXPIRESET coupon:midnight20元无门槛EXPIRE coupon:midnight10TTL coupon:midnight可以把这三条命令理解成把一张优惠券放上货架给它贴上“10 秒后过期”的标签看看距离过期还剩多少秒。如果希望创建 Key 时就把“商品”和“保质期”一次性录入可以直接使用SET的EX或PX参数# 10 秒后过期SET coupon:midnight20元无门槛EX10# 1500 毫秒后过期SET lock:order:1001request-abcPX1500常用命令可以这样记命令作用EXPIRE key seconds设置秒级过期时间PEXPIRE key milliseconds设置毫秒级过期时间TTL key查询剩余秒数PTTL key查询剩余毫秒数PERSIST key移除过期时间让 Key 长期存在TTL 返回的 -1 和 -2 是什么意思执行TTL时不只会看到正数返回正数Key 存在并且还剩对应秒数返回-1Key 存在但没有设置过期时间返回-2Key 已经不存在。SET drink:cola冰可乐TTL drink:cola# -1DEL drink:cola TTL drink:cola# -2这两个负数在线上排查时非常重要。看到-1往往意味着业务忘记设置 TTL或者 TTL 被后续写操作意外清掉了。一个很隐蔽的坑更新商品时把保质期擦掉了假设店员拿起一瓶还有 30 分钟过期的牛奶只修改了价格却把原来的保质期标签撕掉了。牛奶突然变成了“永不过期”。Redis 里也会发生类似的事情SET notice:flash-sale第一版EX30TTL notice:flash-sale# 30 左右SET notice:flash-sale第二版TTL notice:flash-sale# -1普通SET会覆盖旧 Value也会移除旧 TTL。如果只是更新 Value同时希望保留原过期时间需要使用KEEPTTLSET notice:flash-sale第一版EX30SET notice:flash-sale第二版KEEPTTL TTL notice:flash-sale# 仍然大于 0这类问题很难从代码表面发现业务功能看起来正常Key 也能读到但它们会逐渐变成不再过期的“库存”最终把内存吃满。生产环境里应该把“写入 Value”和“TTL 语义”放在一起设计而不是到上线前再随手补一个EXPIRE。时间到了Key 会瞬间从内存消失吗先猜一下一张优惠券在 00:00 过期Redis 会不会在 00:00:00.000 精确地启动一个任务把它立刻从内存删除答案是不会为每一个 Key 单独创建定时器。如果 Redis 给百万个 Key 各维护一个精确定时任务管理这些定时器本身就会消耗大量 CPU 和内存。Redis 采用的是两条配合工作的清理路径被动过期与主动过期。图 3收银员在访问时检查过期商品巡检员则周期性抽查并清理无人访问的过期商品。被动过期顾客拿到收银台才发现过期顾客拿着优惠券结账时收银员先检查时间。如果已经过期就拒绝使用并把它清理掉。对应到 Redis客户端访问一个带 TTL 的 Key 时Redis 会检查它是否过期。如果已经过期就把它当成不存在不会把旧值返回给客户端。SET coupon:short马上失效EX1# 等待超过 1 秒GET coupon:short# (nil)这种方式的优点是精准真正访问时一定不会读到过期数据。问题也很明显如果商品过期后再也没人拿它结账它是不是会永远躺在角落里所以只靠被动过期不够。主动过期店员定期巡检货架便利店会安排店员巡检从贴有保质期标签的商品中抽查一批把已经过期的清走。如果这一轮发现过期商品很多就继续多检查一会儿。Redis 也会周期性检查一部分设置了过期时间的 Key并清理已经到期的 Key。它不会每次扫描整个数据库因为全量扫描会长时间占用主线程。这里需要抓住三个重点主动过期是周期性的不是每个 Key 一个定时任务检查采用受控的抽样和时间预算避免一次清理阻塞太久被动与主动两条路径共同保证过期数据不会再被返回也不会无限占着内存。因此“TTL 到了”首先代表Key 在逻辑上失效。物理内存的回收由访问检查、主动清理以及内存分配器共同完成不要拿某一个瞬间的 RSS 变化去判断 TTL 是否生效。Redis 关机时保质期会暂停吗不会。Redis 保存的是绝对过期时间时间不会因为实例停止而暂停。例如一个 Key 计划在 10 分钟后过期Redis 关闭 20 分钟再启动这个 Key 不会“还剩 10 分钟”而会被视为已经过期。这也意味着服务器时间必须稳定。严重的系统时钟跳变可能让一批 Key 提前过期或延后过期。过期删除和内存淘汰究竟差在哪里回到开头的问题。零点到了优惠券失效这是过期删除晚上 11 点 50 分货架已经装满新到的矿泉水没地方放这是内存淘汰。图 4过期删除由时间触发内存淘汰由 maxmemory 压力触发两者解决的问题完全不同。对比项过期删除内存淘汰触发原因Key 到达过期时间内存接近或达到maxmemory是否要求 Key 有 TTL是取决于淘汰策略主要目标防止过期数据继续使用为新写入腾出内存常见指标expired_keysevicted_keys常见配置EXPIRE、SET EXmaxmemory、maxmemory-policy一个 Key 完全可以没有 TTL却因为内存不足而被淘汰也可以设置了 TTL在还没到期时就因淘汰策略被提前移走。所以不能把“所有 Key 都设置 TTL”当成内存永远不会满的保证。写入速度、TTL 长度、数据大小和访问模式都会影响最终内存。maxmemory这家店到底有多少货架maxmemory相当于给 Redis 划定可用于数据的内存上限maxmemory 4gb maxmemory-policy allkeys-lru也可以在隔离测试实例里动态修改CONFIG SET maxmemory 64mb CONFIG SET maxmemory-policy allkeys-lru生产环境不建议临时拍脑袋修改。需要结合实例总内存、持久化、复制、客户端缓冲区以及系统预留空间一起评估。当写入操作会让内存超过上限时Redis 会按照maxmemory-policy决定拒绝本次写入或者挑一些 Key 淘汰再尝试写入。真正有趣的问题来了淘汰谁货架满了八类老策略和两位新店员怎么选Redis 的策略名看起来很长但拆开就很简单。前半部分决定“从哪里选”allkeys-*所有 Key 都可以参与volatile-*只从设置了 TTL 的 Key 中选择。后半部分决定“按什么规则选”lru最近最少使用lfu一段时间内使用频率较低lrm最近最少修改random随机ttl剩余寿命最短。图 5同一排货架采用不同策略会选中完全不同的商品。noeviction货架满了暂停进货这是 Redis 的默认策略。内存达到上限后不主动淘汰数据会增加内存的写命令通常返回错误读取仍然可以继续。它适合不能接受数据被自动移走的场景但应用必须正确处理写入失败。否则业务看到的就不是“内存保护”而是突然出现大量异常。allkeys-lru优先扔掉最近没被访问的商品想象便利店里有些饮料每天都有人买另一些已经很久没人碰。LRU 倾向于清走最近最少访问的商品。对典型缓存来说热点数据被频繁读取冷数据长时间不访问allkeys-lru通常是很实用的起点。allkeys-lfu优先扔掉长期不受欢迎的商品LRU 关心“最近一次是什么时候”LFU 更关心“最近一段时间用了多少次”。举个生活化例子矿泉水一个月卖了 1000 瓶只是刚好十分钟没人买雨伞半年只卖过两把刚才恰好有人拿起来看了一眼。LRU 可能因为雨伞刚被访问过而暂时保留它LFU 更倾向于保留长期高频的矿泉水。当业务热点比较稳定、偶发访问不应该改变长期判断时可以考虑allkeys-lfu。random闭着眼睛随机拿走如果所有 Key 的价值和访问概率非常接近维护访问历史的收益不大随机策略简单直接。它不是“更聪明”的选择但在访问分布均匀的特殊场景下足够便宜。volatile-ttl谁最快到期先清谁只看设置了 TTL 的 Key并优先淘汰剩余时间最短的。它很像店员发现一批商品反正马上就过期于是先把它们清掉。但这要求应用设置的 TTL 能真实表达数据价值否则“快到期”未必代表“最不重要”。Redis 8.6 的 LRM很久没补货或改价的先走Redis 8.6 增加了allkeys-lrm和volatile-lrm。LRM 是 Least Recently Modified关注 Key 最近一次被修改的时间而不是读取时间或访问频率。便利店里一瓶饮料可能天天被顾客拿起来看却很久没有补货或调整另一个商品刚完成库存更新。LRM 会更偏向保留最近发生过写入的数据。它适合“近期修改代表数据更有价值”的工作负载。不要仅仅因为它是新策略就默认启用选型仍然要看业务含义。volatile 策略最容易踩的坑所有volatile-*策略都只淘汰带 TTL 的 Key。如果实例里没有可淘汰的 TTL Key它们会表现得像noeviction内存满后写入失败。把永久数据与缓存数据混在同一个实例里会让策略选择和容量评估都变复杂。如果条件允许长期数据和可淘汰缓存最好使用不同实例。Redis 为什么只做“近似 LRU/LFU”实现精确 LRU可以给所有 Key 维护一条严格有序的访问链表。每次访问都要移动节点内存和 CPU 成本都很高并发访问越多维护工作越重。Redis 的选择是从候选 Key 中采样结合访问或频率信息挑出更合适的淘汰对象。这意味着 Redis 的 LRU/LFU 不是数学意义上的绝对精确但它用较低的开销换取了足够好的淘汰效果。可以通过maxmemory-samples调整采样力度候选样本越多通常越接近理想结果但 CPU 成本也会增加。生产调优应该以命中率、延迟和业务峰值数据为依据而不是单纯追求“更精确”。到底该选哪一种策略图 6先判断 Redis 是纯缓存还是混合存储再结合访问模式选择策略并持续监控。可以从下面这套问题开始1. 数据能不能被自动丢弃不能丢优先noeviction同时做好写入失败告警和容量扩展可以从数据库或其他来源重建继续看访问模式。2. 所有 Key 都是缓存吗是优先考虑allkeys-lru或allkeys-lfu不是评估拆分实例确实无法拆分时再谨慎考虑volatile-*。3. 热点是短期变化还是长期稳定最近访问最能代表价值LRU长期访问频率更能代表价值LFU最近修改更能代表价值Redis 8.6 的 LRM数据价值接近且访问均匀Random。4. TTL 是否能反映业务价值如果短 TTL 就意味着“很快没用了”可以考虑volatile-ttl。如果 TTL 只是技术上的刷新周期就不要把它误当成业务优先级。动手实验亲眼看见 Key 过期和被淘汰下面使用独立端口6399启动一个临时 Redis关闭 RDB 和 AOF。请不要在生产实例上执行FLUSHDB、修改maxmemory或运行压测。启动隔离实例redis-server\--port6399\--bind127.0.0.1\--protected-modeyes\--save\--appendonlyno\--daemonizeyes\--dir/tmp redis-cli-p6399PING# PONG验证 TTL、PERSIST 和 KEEPTTLredis-cli-p6399SET coupon:midnight20-offEX30redis-cli-p6399TTL coupon:midnight redis-cli-p6399PERSIST coupon:midnight redis-cli-p6399TTL coupon:midnight# -1redis-cli-p6399EXPIRE coupon:midnight30redis-cli-p6399SET coupon:midnight30-offredis-cli-p6399TTL coupon:midnight# -1普通 SET 清除了 TTLredis-cli-p6399EXPIRE coupon:midnight30redis-cli-p6399SET coupon:midnight40-offKEEPTTL redis-cli-p6399TTL coupon:midnight# 仍然大于 0观察过期和淘汰计数redis-cli-p6399INFO stats|grep-Eexpired_keys|evicted_keys|keyspace_hits|keyspace_misses其中expired_keys累计过期删除的 Key 数evicted_keys因达到内存上限而被淘汰的 Key 数keyspace_hits读取命中keyspace_misses读取未命中。命中率可以粗略计算为hits / (hits misses)但不要孤立看命中率。某些大 Key 即使命中次数不多也可能节省大量数据库或计算成本。停止实验实例redis-cli-p6399SHUTDOWN NOSAVEJava/Jedis 示例优惠券过期与会话续期下面使用 Jedis 7.5.x 的RedisClientAPI。程序连接本机6399端口展示四件事优惠券到期后读取为null活跃用户访问后刷新会话 TTL普通SET会清除旧 TTLKEEPTTL可以在更新 Value 时保留截止时间。packagecom.example;importredis.clients.jedis.RedisClient;importredis.clients.jedis.params.SetParams;publicclassRedisExpirationDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{try(RedisClientredisRedisClient.create(redis://127.0.0.1:6399)){redis.flushDB();redis.set(coupon:midnight,20元无门槛,SetParams.setParams().ex(2));System.out.println(coupon ttlredis.ttl(coupon:midnight));Thread.sleep(2200);System.out.println(coupon after deadlineredis.get(coupon:midnight));redis.set(session:user:42,online,SetParams.setParams().ex(5));Thread.sleep(1100);longbeforeredis.ttl(session:user:42);redis.expire(session:user:42,5);longafterredis.ttl(session:user:42);System.out.printf(session ttl %ds - %ds%n,before,after);redis.set(notice:flash-sale,v1,SetParams.setParams().ex(30));redis.set(notice:flash-sale,v2);System.out.println(plain SET ttlredis.ttl(notice:flash-sale));redis.set(notice:keep-deadline,v1,SetParams.setParams().ex(30));redis.set(notice:keep-deadline,v2,SetParams.setParams().keepttl());System.out.println(KEEPTTL ttlredis.ttl(notice:keep-deadline));}}}示例运行时应该看到类似输出coupon ttl2 coupon after deadlinenull session ttl 4s - 5s plain SET ttl-1 KEEPTTL ttl30真实业务里会话续期要防止无限存活并考虑退出登录、账号冻结以及多端登录等主动失效场景。TTL 是兜底机制不是完整的会话安全方案。线上内存为什么看起来没有立刻下降有时expired_keys在增长但操作系统看到的进程 RSS 没有同步下降于是有人怀疑 Redis 没有释放内存。需要区分几组指标used_memoryRedis 分配器统计的已用内存used_memory_rss操作系统看到的常驻内存mem_fragmentation_ratioRSS 与分配内存之间的关系used_memory_peak历史峰值。删除 Key 后内存可能先回到内存分配器供 Redis 后续写入复用不一定马上归还操作系统。大对象释放、异步释放和内存碎片也会让曲线出现延迟或差异。因此排查时不要只盯着系统监控的一条 RSS 曲线应该一起查看redis-cli INFO memory redis-cli INFO stats redis-cli MEMORY USAGE your:key生产环境常见的五个坑1. 缓存 Key 忘记设置 TTL这类 Key 会逐渐积累。应当统一封装缓存写入让 TTL 成为必填的业务参数并对无 TTL Key 做周期性审计。2. 更新 Value 时意外清除 TTL普通SET会移除原 TTL。根据业务语义选择重新设置过期时间或使用KEEPTTL。3. 大量 Key 在同一秒过期如果一批缓存使用完全相同的 TTL可能在同一时刻集中失效让请求同时回源数据库。常见做法是在基础 TTL 上加入合理的随机抖动。例如基础时间 30 分钟再随机增加 05 分钟而不是让所有商品详情在整点一起消失。4. 使用 volatile 策略却没有足够的 TTL Key当找不到符合条件的淘汰对象时写入可能失败。混合存储场景要重点验证这个边界。5. 只配置策略不监控结果策略是否合适最终要看线上数据evicted_keys是否持续快速增长命中率是否下降P99 延迟是否升高数据库回源和 CPU 是否同步增加写入是否出现 OOM 错误热 Key、大 Key 是否破坏了整体分布。总结最后回到那家 24 小时便利店EXPIRE是给商品贴保质期TTL是查看还剩多久PERSIST是撕掉保质期标签KEEPTTL是改价时保留原截止日期被动过期是收银员在访问时发现过期主动过期是店员周期性巡检maxmemory是货架容量maxmemory-policy是货架满时的清货规则LRU 看最近访问LFU 看访问频率LRM 看最近修改expired_keys和evicted_keys分别记录两种完全不同的离场原因。理解这两个机制以后再遇到“Key 明明设置了 TTL为什么内存还是满了”“内存满了会删哪些数据”“为什么更新后 TTL 变成 -1”这些问题就不会只靠猜配置了。Redis 不会真的经营便利店但它和店长小 R 面对的是同一类问题空间永远有限时间也不会停。真正重要的不是把所有东西都留下而是明确什么应该留下、留下多久以及什么时候必须让它离开。参考资料Redis EXPIRE commandRedis keyspace and key expirationRedis key evictionRedis configurationJedis guideConnect to Redis with Jedis个人小游戏