如果你在一个稍微有点规模的业务系统里待过,一定遇见过类似这种诡异的问题:Redis里的某个key明明设置了过期时间,数据却迟迟没有消失;另一个key没有设置过期时间,结果内存一天天涨上去,最后把整个实例拖垮。多数情况下,问题的根源都指向同一个东西——Redis的过期时间机制。这个功能看起来不过是"给key设一个TTL"这么简单,但真正用起来,从设置命令、读取剩余时间、理解删除策略,到利用过期事件驱动业务,每个环节都有讲究。这篇文章把我这些年折腾Redis过期时间的经验完整梳理了一遍,从命令细节到踩坑实录再到事件通知,适合所有被缓存过期问题困扰过的开发者参考。
1. 过期时间不只是"缓存清理",它是系统稳定性的底线
1.1 我为什么专门研究过期时间
之前维护过一个做营销活动的服务,用户每天可以领一张优惠券,我们把用户维度的活动状态写进Redis,key设计成activity:{userId}:{date},当时犯了个错误:只想着设置过期时间让数据"第二天自动消失",结果大量key的过期时间设置在了同一时刻。活动整点开始的那一秒,Redis的CPU使用率瞬间冲到90%多,大量请求超时,最后排查下来就是过期的key在同一秒内密集触发删除,导致实例卡顿。
那次之后我彻底意识到:过期时间从来不是一个"锦上添花"的优化项,而是直接影响系统可用性的核心机制。把过期时间用对,你的缓存层才称得上稳定;用不对,轻则数据错乱,重则线上事故。
1.2 过期时间在项目里扮演的三个关键角色
抛开理论,单从实际项目出发,过期时间至少承担着以下三类职责,每一类都值得认真设计:
第一,缓存数据的一致性保障。缓存和数据库之间天然存在数据不一致的风险,给缓存key设置过期时间是成本最低的对账手段。业务数据更新后,即使缓存没有主动失效,到了过期时间也会自动拉回到数据库的最新状态。没有过期时间的缓存,一旦某次更新逻辑漏掉了主动删除,脏数据可能永远留在缓存里。
第二,临时凭证和限流的生命周期管理。登录token、短信验证码、秒杀接口的限流计数,这些数据天然有时效性。验证码5分钟有效、限流窗口60秒重置,用过期时间来实现再自然不过。这类场景还经常需要动态续期——用户连续操作时刷新token的有效期,这就需要用到EXPIRE这类命令的续期能力。
第三,分布式锁和任务调度的自动释放。Redis实现分布式锁时,必须给锁设置过期时间,防止持有锁的服务宕机后造成死锁。任务队列里的延迟任务,也经常借助过期时间来实现"定时触发"的效果。没有过期时间,很多分布式场景根本没法安全落地。
另外还有一个容易被忽略的作用:防止冷数据堆积。即使业务中没有主动清理逻辑,设置了合理过期时间的key也能在生命周期结束后自动释放内存,避免无效key长期占用空间。
2. 设置过期时间的五类命令,和它们之间的细微差异
Redis里能给key设置过期时间的命令不止一个,很多人只知道EXPIRE和SETEX,但实际生产中根据不同场景,选择完全不同的命令会带来完全不同的效果。这一节把常用的设置方式全部过一遍。
2.1 最常用的EXPIRE与它的毫秒级变体
EXPIRE key seconds是最基础的设置过期时间命令,以秒为单位。它作用于已存在的key,返回值表示设置是否成功:返回1说明设置成功,返回0说明key不存在或操作未生效。
> SET user:1001 "zhangsan" > EXPIRE user:1001 300 (integer) 1与秒级对应的还有PEXPIRE key milliseconds,精度更高,适用于需要更短过期时间的场景,比如接口限流窗口设置为500毫秒:
> SET rate:1001 1 > PEXPIRE rate:1001 500 (integer) 1这里有个隐藏技巧:EXPIRE/PEXPIRE是可以对已经存在但没设置过期时间的key直接补设的,也可以对已有过期时间的key重新修改剩余时间。这个特性在"滑动续期"场景中非常有用。比如用户登录后,每次有操作就重新执行一次EXPIRE,让token的过期时间不断顺延,用户只要持续活跃就不会被迫重新登录。
2.2 SETEX与PSETEX:创建即设置,一步到位
SETEX key seconds value和PSETEX key milliseconds value是"写值+设过期时间"的原子组合操作。设计它们的主要目的是避免SET和EXPIRE分两步执行可能出现的中间状态:如果设置完值、还没来得及设过期时间时服务崩溃,这个key就成了永久key,白白占用内存。
> SETEX sms:13800138000 300 "123456" OK需要注意的是,SETEX在设置新值的同时会覆盖掉这个key上原有的过期时间,如果key原本有TTL,SETEX执行完会重新开始计算。另外它无法单独用来"续期"——续期该用EXPIRE。
2.3 SET命令的EX/PX/EXAT/PXAT选项:更细粒度的控制
从Redis 2.6.12开始,SET命令本身集成了过期时间参数,这也是官方最推荐的做法之一:
> SET user:1001 "zhangsan" EX 300 OK > SET user:1001 "zhangsan" PX 300000 OK > SET user:1001 "zhangsan" EXAT 1750000000 OK > SET user:1001 "zhangsan" PXAT 1750000000123 OK四个选项的区别在于单位和参照基准:
EX seconds:秒级相对过期时间PX milliseconds:毫秒级相对过期时间EXAT timestamp-seconds:绝对过期时间,需要一个Unix秒级时间戳PXAT timestamp-milliseconds:绝对过期时间,需要毫秒级时间戳
EXAT和PXAT非常适用于"业务端已经算好一个过期时刻"的场景,例如"这个验券二维码到今天晚上24点失效"——你可以在凌晨算出时间戳,直接写死到key里,无需再换算剩余秒数。
2.4 EXPIREAT与PEXPIREAT:指定过期时刻而非时长
与SET EXAT类似的还有独立的EXPIREAT key timestamp和PEXPIREAT key timestamp-ms命令,不过它们针对的是已存在的key,作用等价于"根据一个绝对时间点来设置过期"。
> EXPIREAT user:1001 1750000000 (integer) 1这里有一个细节值得注意:如果你传入的是一个已经过去的时刻,Redis会立刻删除这个key。所以这类命令也可以当作"指定条件删除"来用——把过期时刻等于当前时间,效果等同于DEL。
2.5 GETEX命令:读取的同时修改过期时间
GETEX是Redis 6.2引入的命令,在读取key的值的同时给它设置一个新的过期时间。它有四个选项类似SET的EX/PX/EXAT/PXAT,还有一个特殊参数PERSIST,用来移除过期时间。
> GETEX user:1001 EX 600 "zhangsan" > GETEX user:1001 PERSIST "zhangsan"这个命令最大的应用价值在于"读取即续期"的原子场景,比如热点配置的集中管理:每次配置被读取时自动延长有效期,活跃的配置一直不会消失,不活跃的配置自然冷却过期,不用再写两步操作。
2.6 命令选型横向对比
为了让你在实际项目中快速决策,我把这些设置方式整理成了一张对照表:
| 命令/方式 | 单位 | 适用key状态 | 是否会覆盖原值 | 典型场景 |
|---|---|---|---|---|
| EXPIRE | 秒 | 已存在 | 否 | 已存在key的续期 |
| PEXPIRE | 毫秒 | 已存在 | 否 | 需要毫秒级精度的续期 |
| EXPIREAT | 绝对秒 | 已存在 | 否 | 按固定时刻过期 |
| PEXPIREAT | 绝对毫秒 | 已存在 | 否 | 高精度固定时刻过期 |
| SETEX | 秒 | 存在/不存在 | 是 | 一步完成写入+过期 |
| PSETEX | 毫秒 | 存在/不存在 | 是 | 一步完成写入+毫秒过期 |
| SET EX | 秒 | 存在/不存在 | 是 | 官方推荐的标准写法 |
| SET PX | 毫秒 | 存在/不存在 | 是 | 需要毫秒精度写入 |
| SET EXAT | 绝对秒 | 存在/不存在 | 是 | 按固定时刻过期写入 |
| SET PXAT | 绝对毫秒 | 存在/不存在 | 是 | 高精度固定时刻过期写入 |
| GETEX | 秒/毫秒/绝对/清除 | 已存在 | 否(但可清TTL) | 读取并续期 |
一个经常有人搞混的细节:对已有key执行SET命令(不带EX系列参数),会把这个key上已经设置的过期时间清除掉。也就是说,如果你先SET了一个带过期时间的key,之后又用SET更新了value但没带过期参数,这个key就变成永久key了。生产环境里这种问题非常隐蔽,排查起来也费劲,后面我会专门讲。
3. TTL族命令:把剩余生命期读出来的正确姿势
设置好了过期时间,接下来就是读取。Redis提供TTL和PTTL两个命令分别以秒和毫秒为单位查看剩余过期时间。读法看起来简单,但返回值的语义其实有不少坑。
3.1 TTL与PTTL的返回值语义
> SET user:1001 "zhangsan" EX 300 OK > TTL user:1001 (integer) 297 > PTTL user:1001 (integer) 296998返回值分三种情况:
- 正数:key存在且有过期时间,返回剩余秒数/毫秒数
- -1:key存在,但没有设置过期时间,属于永久key
- -2:key不存在
很多人只关注正数的情况,容易忽略-1和-2的区分。这个区分在实际业务里很有用:用TTL key > 0判断一个key是否存在且尚在有效期内,比EXISTS key更安全。因为EXISTS只判断存在性,不判断是否已经过期,一个逻辑已过期但还没被删除的key,EXISTS还是返回1。
3.2 精确判断"过期没",不能只看EXISTS
这里我要详细说一下:Redis删除过期key是惰性的,一个key到了过期时间,物理上不一定立即消失。所以:
> SET user:1001 "zhangsan" EX 1 OK > sleep 2 > EXISTS user:1001 (integer) 1 # key还在,但已经过期了 > TTL user:1001 (integer) -2 # TTL已经知道它过期了看到区别了吗?key过了TTL之后,EXISTS可能仍然返回1,但TTL返回的是-2。因为在Redis内部,key的真正"死亡"要等删除动作发生。所以如果你想判断一个key是否对业务生效,应该优先看TTL/PTTL的返回值,而不是EXISTS。这也是我做了很久才注意到的细节。
3.3 批量查看过期时间的高效姿势
生产环境偶尔需要排查哪些key快过期了、哪些key是永久key,用TTL一条条查肯定不行。我一般用SCAN配合TTL,或者直接写一段Lua脚本批量处理。
一个简单的bash循环示例:
redis-cli --scan --pattern 'activity:*' | while read key; do echo "$key $(redis-cli ttl $key)" done但这样每条key都发起一次网络请求,效率不高。更推荐用Redis的pipeline批量执行:
redis-cli --scan --pattern 'activity:*' | redis-cli --pipe这段只是示例,实际生产里更好的做法是用Lua脚本一次性完成扫描和TTL查询:
local cursor = '0' local result = {} repeat local scan = redis.call('SCAN', cursor, 'MATCH', ARGV[1], 'COUNT', 100) cursor = scan[1] for _, key in ipairs(scan[2]) do local ttl = redis.call('TTL', key) table.insert(result, {key, ttl}) end until cursor == '0' return result使用EVAL执行时传入匹配模式,就能拿到一批key的剩余过期时间,一次调用搞定,在key数量较大的情况下仍然很快。
3.4 关于过期时间精度的重要补充
在Redis 7.0之前,TTL命令返回的秒数是向下取整的,也就是说一个key实际剩余1.9秒时,TTL可能返回1。不要用TTL等于0来判断"刚好过期",它可能还有不到1秒的存活时间。需要精确判断时,务必用PTTL。
Redis 7.0之后,过期时间的设计做了优化,底层用了更精确的过期跟踪方式,但对上层TTL/PTTL命令的语义影响不大,你仍然应该以PTTL为准做精确判断。
4. 过期key的"拆迁队":删除策略与内存淘汰的边界
设置和读取都理清了,接下来要回答一个很多人困惑的问题:过期时间到了,key真的会被立刻删除吗?
答案是不一定。Redis的过期删除不是实时扫描全库的,它采用了两套策略配合:惰性删除和定期删除。
4.1 惰性删除:用的时候才清理
惰性删除很好理解:当有请求访问某个key时,Redis会先检查这个key是否已过期,如果已过期,就立即删除并返回空结果。
> SET code:1001 "123456" EX 5 OK > sleep 6 > GET code:1001 (nil) # 访问时发现已过期,删除并返回nil这种方式的好处是CPU开销最小,只在访问时检查;坏处是没有被访问的过期key会一直占用内存。如果一个key设置过期后永远没人访问,它就永远赖在内存里不走。
4.2 定期删除:兜底的主动清理
为了解决上面这个"永远没人访问"的问题,Redis还会周期性地主动抽查一部分key,检查是否过期,过期则删除。这个动作由后台每秒执行固定次数的循环完成,每轮会从设置了过期时间的key中随机抽取一批来检查。
但注意"随机抽取"这四个字——它不是扫描全库,而是抽样检查。所以仍然可能存在少量已过期key暂时没被抽查到、继续留在内存里的情况。这也是上一节里EXISTS返回1而TTL返回-2现象的底层原因。
4.3 内存淘汰策略和过期时间的关系
当内存占用达到maxmemory上限时,Redis会根据配置的淘汰策略(maxmemory-policy)进行内存回收。这里有个关键区分:淘汰策略处理的是"所有key",不只是"已过期的key"。
常见淘汰策略包括:
- noeviction:不淘汰,写命令直接报错
- allkeys-lru:从所有key中按LRU淘汰
- volatile-lru:只从设置了过期时间的key中按LRU淘汰
- allkeys-random:从所有key中随机淘汰
- volatile-random:只从设置了过期时间的key中随机淘汰
- volatile-ttl:从设置了过期时间的key中按剩余时间从短到长淘汰
这里要特别提醒:如果你的实例配置了maxmemory并使用了volatile-xxx这类策略,业务上一定要给key设置过期时间,否则这些key永远不会被淘汰。之前见过一个事故,Redis内存被打满后大量请求失败,原因就是把淘汰策略配成了volatile-lru,但很多关键的缓存key没有设置过期时间,导致内存完全不可回收。
4.4 为什么设置了过期时间,内存却还在涨
这是后台经常遇到的经典问题。设置了过期时间的key,理论上到期后会被删除,内存为什么还会持续增长?原因有三个:
第一,大量key同时过期之前,内存会先增长到峰值。比如一批key设置了7天过期,7天内这些key都有效,内存就一直处于被占满的状态。
第二,惰性删除存在内存释放滞后。只要key没被访问、也没被定期删除抽中,内存就一直被占用。在高写入低读取的场景下尤其明显。
第三,删除key的内存碎片不一定立即归还操作系统。Redis释放内存后,jemalloc分配器会复用这些空间,但RSS(常驻内存)不一定会立即下降。这是正常现象,不代表内存泄漏。
理解这些,你就能明白:过期时间不是内存管理的全部,它还需要配合主动清理、内存淘汰策略以及监控告警一起使用。
5. 实战中最常见的五个过期时间坑,与完整绕过方案
这一节我直接把实战中最容易踩的坑列出来,每个坑都按"现象 → 排查 → 解决方案"的顺序讲,方便你遇到类似问题时直接参照。
5.1 坑一:SET更新value时,过期时间被悄悄清掉
现象:业务上明明给key设置了24小时过期,第二天发现这个key还在,甚至变成了永久key。
根因:中途有逻辑用不带过期参数的SET命令更新了value。Redis的SET默认会清除key上已有的过期时间。
> SET user:1001 "zhangsan" EX 300 OK > SET user:1001 "lisi" # 没带EX/PX参数 OK > TTL user:1001 (integer) -1 # 变成永久key了排查链路:先用TTL确认变成了-1,再用OBJECT IDLETIME user:1001查看key的空闲时间,对比最近一次业务更新数据的时间,基本能定位到是哪段代码执行的SET。
解决:所有更新key的地方统一封装,在SET时显式带上过期时间参数;或者用SET user:1001 "lisi" KEEPTTL——Redis 6.0以后支持KEEPTTL选项,可以在更新value的同时保留原有剩余过期时间:
> SET user:1001 "lisi" KEEPTTL OK5.2 坑二:用TTL判断key是否存在,误把-1当成有数据
现象:代码里写了类似if (ttl > 0) { 用缓存 }的逻辑,结果没有过期时间的永久key永远命中缓存。
根因:TTL返回-1表示key存在但没设过期时间,如果判断条件只写了ttl > 0,会让永久key永远走不到"重新加载数据"的逻辑。
排查链路:检查代码中所有TTL判定的分支,给测试key手动SET一个不带过期时间的值,观察命中行为是否异常。
解决:用TTL判断业务数据有效性的逻辑,建议统一改成:
# 有效 = key存在且未设置过期时间 或 剩余时间大于0 TTL key != -2或者更严格些,期望所有缓存key都有过期时间,那判断条件就应该写成ttl >= 0。具体选哪种,取决于业务设计里是否允许永久key存在。
5.3 坑三:大量key设置同一过期时刻,触发缓存雪崩
现象:整点时刻Redis CPU飙升、请求延迟上升、大量请求穿透到数据库。
根因:一批key的过期时间在业务逻辑里是固定算出来的,比如所有用户的活动数据统一设置当天24点过期,那么午夜0点之前那一瞬间,数十万key同时到期,触发集中删除。
排查链路:看监控里expired_keys指标是否存在瞬间暴涨的尖峰,再查看业务代码中过期时间是否都来自同一个基准时间计算。
解决:在过期时间上增加随机偏移量,打散过期时刻:
# 基础过期时间 + 随机0-300秒的偏移 SET activity:1001 "data" EX 86400 EXAT 1750000000推荐的标准做法是在基础过期时间上叠加一个随机值,让key的过期时间均匀分布在一个时间区间内,而不是全部集中在同一个点。
5.4 坑四:主从架构下,过期key在主库已删、从库还在
现象:主从切换后,新主库上读取到了"应该已经过期"的数据。
根因:Redis的主从复制中,过期删除操作并不是直接把DEL命令同步给从库,而是依赖从库自己执行惰性删除或定期删除。Redis Master删除一个过期的key时会向从库发送DEL命令,而从库收到DEL后会删除对应的key。但如果key在主库过期时,从库因为分区等原因没有立即收到DEL,或主库的删除本身就是惰性触发的,那么从库上这个key可能还在。
排查链路:对比主从两个实例上同一key的TTL返回值,检查从库是否在过期时间窗口内返回了旧数据。
解决:从业务层面降低影响:主从切换后,缓存数据宁可穿透到数据库重新加载,也不要信任从库可能过期的数据。可以在从库读逻辑中额外判断TTL,把TTL为-2的key直接视为不存在;多数时候更简单的方法是——待Redis 7.x版本之后,主从之间的过期同步机制已经做了很多优化,尽量把Redis版本升到7.0以上。
5.5 坑五:Redis Cluster模式下,单key过期时间在迁移后失效
现象:对Cluster中的某个key设置了过期时间,key迁移到另一个slot后,过期时间莫名丢失。
根因:Cluster的key迁移(slot迁移)过程中,如果使用了不带过期参数的RESTORE-ASKING等命令,或者迁移完成后在目标节点重建key时没有保留TTL,就会出现过期时间丢失的情况。这种情况多出现在使用某些客户端工具做数据迁移或扩缩容时。
排查链路:对比迁移前后同key的TTL值,查看迁移工具的版本和参数,很多老版本工具默认不迁移TTL。
解决:尽量使用支持TTL迁移的官方工具或新版本客户端;迁移完成后跑一遍全量校验脚本,用SCAN+TTL对比源节点和目标节点的剩余过期时间,偏差超过阈值就重新迁移。生产环境更推荐的做法是迁移后不清缓存、让缓存自然过期重建,避免人工迁移TTL带来的各种隐患。
6. 让过期时间变成业务信号:Keyspace过期事件通知
过期时间不只是用来"清理数据"的,它还能成为业务逻辑的触发器。Redis的Keyspace Notifications机制可以在key过期时发布事件通知,订阅方收到通知后执行业务逻辑。听上去很美好,但实际用起来有不少细节要处理。
6.1 配置与订阅
要启用过期事件通知,需要修改redis.conf中的notify-keyspace-events参数,或者用CONFIG SET命令动态开启:
> CONFIG SET notify-keyspace-events Ex OK其中E表示启用key事件通知,x表示只发送过期事件。配置好后,在客户端订阅__keyevent@0__:expired频道(0代表db0),就能收到所有key过期的通知。
用redis-cli订阅的效果:
> SUBSCRIBE __keyevent@0__:expired当有key过期时,会收到类似消息:
1) "message" 2) "__keyevent@0__:expired" 3) "order:1001"注意第三个元素是key的名字,而不是value。如果你需要拿到key对应的业务数据,必须在key过期前把数据另存一份,或者把关键信息放到key名里面。这是很多人第一次用时的困惑点。
6.2 延迟问题:过期通知并非实时
过期事件通知是在key被真正删除时才发布的,而删除可能是惰性删除或定期删除触发,所以通知天然存在延迟。如果某个过期key一直没被访问,也没有被定期删除抽中,它的过期通知可能延迟很久,甚至延迟到下一次抽查才发布。
因此,不要把过期通知当作高实时性的业务触发器用。比如"用户下单后15分钟未支付就自动关单"这种场景,直接依赖过期通知做,用户可能等了30分钟订单才被关闭,体验很差。
6.3 更好的替代方案
真正可靠的延迟任务方案,我更推荐用以下三种:
- Redis Streams + 延迟队列:生产者把任务写入Stream,消费者用XREADGROUP阻塞读取,配合一个专门的"待延迟"ZSet(score为计划执行时间戳)做延迟调度,这种方案可控性强、消息不丢失。
- Redisson的RDelayedQueue:基于Redis的分布式延迟队列实现,已经封装好了,使用成本低,适合在Java生态中使用。
- 定时扫表 + 状态机:最传统的方案,定期从数据库扫描超时订单,状态机驱动关单。虽然多查一次库,但逻辑最直白、最不容易出错。
过期通知适合拿来做哪些事?我实际用下来,比较靠谱的场景包括:统计类指标的清理通知、清理本地缓存的联动信号、以及对实时性要求不高的数据补偿。凡是"晚几秒也能接受"的场景,用过期通知没问题;凡是"必须准点执行"的场景,别用它。
把过期时间管理沉淀成一套习惯
文章写到这儿,核心内容基本讲完了。如果你问我这些年最大的体会是什么,我会说:Redis的过期时间从来不是一个"设置了就完事"的参数,它是一个贯穿数据读写、故障排查、架构设计的系统性问题。
我个人的做法是,在每个项目里把过期时间的使用沉淀成一套规范:所有缓存key必须有明确的TTL,所有更新value的操作必须显式处理过期时间(要么KEEPTTL,要么重新指定),所有判断key有效性的逻辑统一用TTL而不是EXISTS,所有批量key的过期时间必须加随机偏移。这些规则看着琐碎,但在线上救过我很多次。
最后再分享一个小技巧:给key命名时把过期时间相关的信息带上,比如cache:user:info:{id}:ttl300,这样所有同事在Redis Desktop Manager里看到key就能直接知道它的生命周期策略,排查问题时能少走很多弯路。过期时间的坑,大多不是Redis本身造成的,而是使用的人对它的理解还停留在表面。希望这篇文章能帮你把这块短板补上。