一文搞懂 Redis 过期删除与内存淘汰:把它想成一家 24 小时便利店
文章目录
图 1:Redis 像一家货架有限的 24 小时便利店,既要处理过期商品,也要在货架装满时决定腾出哪些位置。
晚上 11 点 58 分,街角的“Redis 便利店”突然忙了起来。
店长小 R 刚把一批“午夜促销券”摆上货架,每张券都贴着标签:零点失效。另一边,外卖订单、用户会话、商品详情还在不断送进仓库,货架已经快被塞满了。
新来的店员问:
“过期商品不是会自动消失吗?那货架为什么还会满?”
小 R 摇摇头:
“保质期到了,解决的是‘东西什么时候不能再卖’;货架满了,解决的是‘现在必须扔掉谁,才能放进新东西’。这是两套机制。”
这句话正好点出了 Redis 中最容易混淆的两个概念:
- 过期删除(expiration):Key 到达 TTL 后失效;
- 内存淘汰(eviction):Redis 达到
maxmemory后,为新数据腾出内存。
它们经常同时出现,却不是一回事。今天我们就把 Redis 当成一家 24 小时便利店,从一张即将过期的优惠券开始,把 TTL、主动过期、被动过期以及各种淘汰策略讲清楚。
先把便利店和 Redis 对上号

图 2:用便利店里的商品、保质期和货架,对应 Redis 的 Key、TTL 与内存空间。
先建立一份“翻译表”,后面的故事就不会跑偏:
| 便利店里的东西 | Redis 中的概念 |
|---|---|
| 一件商品 | 一个 Key 及其 Value |
| 商品条码 | Key 名称 |
| 商品保质期 | TTL |
| 零点过期的优惠券 | 带过期时间的临时 Key |
| 店内货架 | Redis 可使用的内存 |
| 收银员结账时发现商品过期 | 被动过期 |
| 店员定时巡检临期商品 | 主动过期 |
| 货架已满,必须扔掉一些商品 | 内存淘汰 |
| 店长制定的清货规则 | maxmemory-policy |
类比只能帮助理解,真正决定行为的仍然是 Redis 命令和配置。接下来我们一层一层拆开。
给 Key 贴一张“保质期标签”
Redis 的 Key 默认没有过期时间。如果不主动删除,它可以一直存在。
给 Key 设置过期时间最常见的方式是 EXPIRE:
SET coupon:midnight "20元无门槛"
EXPIRE coupon:midnight 10
TTL coupon:midnight
可以把这三条命令理解成:
- 把一张优惠券放上货架;
- 给它贴上“10 秒后过期”的标签;
- 看看距离过期还剩多少秒。
如果希望创建 Key 时就把“商品”和“保质期”一次性录入,可以直接使用 SET 的 EX 或 PX 参数:
# 10 秒后过期
SET coupon:midnight "20元无门槛" EX 10
# 1500 毫秒后过期
SET lock:order:1001 "request-abc" PX 1500
常用命令可以这样记:
| 命令 | 作用 |
|---|---|
EXPIRE key seconds |
设置秒级过期时间 |
PEXPIRE key milliseconds |
设置毫秒级过期时间 |
TTL key |
查询剩余秒数 |
PTTL key |
查询剩余毫秒数 |
PERSIST key |
移除过期时间,让 Key 长期存在 |
TTL 返回的 -1 和 -2 是什么意思
执行 TTL 时,不只会看到正数:
- 返回正数:Key 存在,并且还剩对应秒数;
- 返回
-1:Key 存在,但没有设置过期时间; - 返回
-2:Key 已经不存在。
SET drink:cola "冰可乐"
TTL drink:cola
# -1
DEL drink:cola
TTL drink:cola
# -2
这两个负数在线上排查时非常重要。看到 -1,往往意味着业务忘记设置 TTL,或者 TTL 被后续写操作意外清掉了。
一个很隐蔽的坑:更新商品时把保质期擦掉了
假设店员拿起一瓶还有 30 分钟过期的牛奶,只修改了价格,却把原来的保质期标签撕掉了。牛奶突然变成了“永不过期”。
Redis 里也会发生类似的事情:
SET notice:flash-sale "第一版" EX 30
TTL notice:flash-sale
# 30 左右
SET notice:flash-sale "第二版"
TTL notice:flash-sale
# -1
普通 SET 会覆盖旧 Value,也会移除旧 TTL。如果只是更新 Value,同时希望保留原过期时间,需要使用 KEEPTTL:
SET notice:flash-sale "第一版" EX 30
SET 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 "马上失效" EX 1
# 等待超过 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_keys |
evicted_keys |
| 常见配置 | EXPIRE、SET EX |
maxmemory、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 \
--port 6399 \
--bind 127.0.0.1 \
--protected-mode yes \
--save "" \
--appendonly no \
--daemonize yes \
--dir /tmp
redis-cli -p 6399 PING
# PONG
验证 TTL、PERSIST 和 KEEPTTL
redis-cli -p 6399 SET coupon:midnight "20-off" EX 30
redis-cli -p 6399 TTL coupon:midnight
redis-cli -p 6399 PERSIST coupon:midnight
redis-cli -p 6399 TTL coupon:midnight
# -1
redis-cli -p 6399 EXPIRE coupon:midnight 30
redis-cli -p 6399 SET coupon:midnight "30-off"
redis-cli -p 6399 TTL coupon:midnight
# -1,普通 SET 清除了 TTL
redis-cli -p 6399 EXPIRE coupon:midnight 30
redis-cli -p 6399 SET coupon:midnight "40-off" KEEPTTL
redis-cli -p 6399 TTL coupon:midnight
# 仍然大于 0
观察过期和淘汰计数
redis-cli -p 6399 INFO stats |
grep -E 'expired_keys|evicted_keys|keyspace_hits|keyspace_misses'
其中:
expired_keys:累计过期删除的 Key 数;evicted_keys:因达到内存上限而被淘汰的 Key 数;keyspace_hits:读取命中;keyspace_misses:读取未命中。
命中率可以粗略计算为:
hits / (hits + misses)
但不要孤立看命中率。某些大 Key 即使命中次数不多,也可能节省大量数据库或计算成本。
停止实验实例
redis-cli -p 6399 SHUTDOWN NOSAVE
Java/Jedis 示例:优惠券过期与会话续期
下面使用 Jedis 7.5.x 的 RedisClient API。程序连接本机 6399 端口,展示四件事:
- 优惠券到期后读取为
null; - 活跃用户访问后刷新会话 TTL;
- 普通
SET会清除旧 TTL; KEEPTTL可以在更新 Value 时保留截止时间。
package com.example;
import redis.clients.jedis.RedisClient;
import redis.clients.jedis.params.SetParams;
public class RedisExpirationDemo {
public static void main(String[] args) throws InterruptedException {
try (RedisClient redis =
RedisClient.create("redis://127.0.0.1:6399")) {
redis.flushDB();
redis.set(
"coupon:midnight",
"20元无门槛",
SetParams.setParams().ex(2)
);
System.out.println("coupon ttl=" + redis.ttl("coupon:midnight"));
Thread.sleep(2200);
System.out.println(
"coupon after deadline=" + redis.get("coupon:midnight")
);
redis.set(
"session:user:42",
"online",
SetParams.setParams().ex(5)
);
Thread.sleep(1100);
long before = redis.ttl("session:user:42");
redis.expire("session:user:42", 5);
long after = redis.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 ttl=" + redis.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 ttl=" + redis.ttl("notice:keep-deadline")
);
}
}
}
示例运行时应该看到类似输出:
coupon ttl=2
coupon after deadline=null
session ttl 4s -> 5s
plain SET ttl=-1
KEEPTTL ttl=30
真实业务里,会话续期要防止无限存活,并考虑退出登录、账号冻结以及多端登录等主动失效场景。TTL 是兜底机制,不是完整的会话安全方案。
线上内存为什么看起来没有立刻下降
有时 expired_keys 在增长,但操作系统看到的进程 RSS 没有同步下降,于是有人怀疑 Redis 没有释放内存。
需要区分几组指标:
used_memory:Redis 分配器统计的已用内存;used_memory_rss:操作系统看到的常驻内存;mem_fragmentation_ratio:RSS 与分配内存之间的关系;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 分钟,再随机增加 0~5 分钟,而不是让所有商品详情在整点一起消失。
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 command
- Redis keyspace and key expiration
- Redis key eviction
- Redis configuration
- Jedis guide
- Connect to Redis with Jedis
- 个人小游戏


更多推荐



所有评论(0)