Redis过期时间深度解析:从命令细节到缓存雪崩与分布式锁实践
2026/9/18 10:32:03 网站建设 项目流程

1. 从一次线上事故说起:Redis 过期时间为什么值得单独研究

我做 Redis 相关开发和排障差不多快十年了,最开始接触这个缓存中间件的时候,说实话并没有太把“过期时间”当回事。当时心里的想法很简单:Redis 嘛,一个 key 存进去,什么时候删不删,Redis 自己会管,我只要 SET 的时候顺手加个 EX 参数就完事了。直到后来线上出了一个不大不小的事故,才让我意识到这个念头有多大坑。

那次事故的场景很典型:一个活动页的接口做了 Redis 缓存,key 的有效期设置成 1 小时,结果活动开始后突然涌入大量流量,Redis 里的缓存 key 在高峰期一批一批地集体失效,数据库瞬间被打满,接口超时率直接飙到 30% 以上。事后排查的时候发现,问题不只出在“缓存过期时间太短”,更关键的是团队里几个人对 TTL、EXPIRE 这些命令的理解都是半吊子,有的人用了 PEXPIREAT 但传的时间戳单位写错,有的人在 SET 里面同时写了 EX 和 PX,还有的人在 Spring Data Redis 里直接调用了错误的 API 导致过期时间根本就没生效。那次事故之后,我把 Redis 过期时间相关的命令、机制、业务设计全部整理了一遍,今天这篇文章就是这份整理的完整版。

这篇文章适合谁看?答案是:只要你用过 Redis,哪怕只是 SET 和 GET 的水平,也很值得往下读。因为过期时间不只是“给 key 设一个生存时间”这么简单,它牵扯到命令返回值怎么解读、过期删除机制怎么运作、缓存雪崩怎么避免、分布式锁怎么续期、延迟任务怎么设计等一大堆问题。读完之后,你至少能做到:遇到 TTL 返回 -2 不懵,知道为什么 Redis 不会到点就删 key,以及能在面试里把过期时间和内存淘汰机制讲得明明白白。

2. 设置过期时间:五条路,各自适用什么场景

2.1 EXPIRE / PEXPIRE:单位不一样,效果都一样

先看最基础的一对命令:

EXPIRE key seconds PEXPIRE key milliseconds

命令字面意思已经说得很清楚了,EXPIRE 以秒为单位,PEXPIRE 以毫秒为单位,它们做的事情都是给一个已存在的 key 设置相对过期时间。所谓“相对”,可以理解成“从现在开始算,再过多久这个 key 过期”,而不是指定一个具体的过期时刻。

使用的时候有几件小事属于那种“文档里写了但未必有人提醒你”的:

第一,EXPIRE 的 seconds 参数必须是大于 0 的整数。传 0 或负数,Redis 会直接把 key 删掉,这不是设置了一个“马上过期”的状态,而是明确地执行了删除操作。如果你有这个需求,也别觉得奇怪,很多人写脚本时确实会故意用 EXPIRE key 0 来达到删除效果,因为它在某些事务脚本里比 DEL 更顺手。

第二,EXPIRE 命令是可以反复对一个 key 调用的,这意味着新设置的过期时间会覆盖掉旧值。这个特性用来做“滑动过期”很方便,比如用户操作一次,就把 token 的过期时间往后推半小时。但是反过来,如果团队里不同服务都去操作同一个 key,一个服务在刷新过期时间,另一个服务在做清理,就会产生互相覆盖的问题。这个在多人协作的项目里几乎是必踩的坑。

第三,PEXPIRE 的毫秒单位在大多数业务里其实用不上,Redis 内部本来也只能保证一定程度的过期精度,不要指望毫秒级的精确删除。它的存在更多是补齐命令体系,让调用方不用自己做单位换算。

2.2 EXPIREAT / PEXPIREAT:适合做限时任务的绝对时间

与相对时间对应的是绝对时间戳:

EXPIREAT key timestamp PEXPIREAT key timestamp-ms

EXPIREAT 接收一个 Unix 时间戳,单位是秒;PEXPIREAT 接收的是毫秒级时间戳。这种写法适合什么场景呢?最常见的是“限时活动”。比如每天早上 10 点整开始抢购,你要在启动活动的时候往 Redis 里写一批临时 key,希望它们在当天 23:59:59 统一过期,那么用相对过期时间就需要算一个差值,而用 EXPIREAT 可以直接传当天的秒级时间戳,逻辑上更直白,出错概率也更低。

用 EXPIREAT 有一个特别容易踩的坑:时间戳混单位。我在实际审查代码的时候,不止一次看到有人用 Java 的 System.currentTimeMillis() 去配 EXPIREAT,这个方法的返回值是毫秒,而 EXPIREAT 要的是秒,一传上去就是 1000 倍的时间差。那结果就是 key 要么秒删,要么相当于没设过期时间,非常坑。后来我在团队里定了一条规矩:凡是 EXPIREAT / PEXPIREAT 必须写清楚时间戳单位的注释,代码评审阶段专门查这个。

2.3 SET 带 EX/PX:一步到位的原子写法

前面说的 EXPIRE 家族命令,有一个隐藏的坑:它们都要求 key 已经存在,且 SET 和 EXPIRE 是两条独立命令,如果分开执行,中间万一出点幺蛾子,就可能出现 key 设置了但过期时间没设上的情况。虽然极端,但确实存在。所以更推荐的写法是用 SET 的扩展参数:

SET key value EX seconds SET key value PX milliseconds

语法很直观:SET 命令后跟上 EX 或 PX,就可以在写入时同时指定过期时间,而且这个操作是原子的,不需要担心两条命令之间被打断。在 Spring Data Redis 里,对应的写法是 RedisTemplate 的 set(K key, V value, Duration timeout),本质上就映射到了 SET key value EX 这类指令。

有人可能会问,那 SET 带上 NX、XX 是不是也能一起用?是的,完全可以。比如:

SET locks:order:1001 "locked" EX 30 NX

这条命令的意思是:只有当 key 不存在时才设置,并且 30 秒后自动过期。它把“不存在才写”和“设置过期时间”这两件事合并成一步,是 Redisson 分布式锁之外最轻量的加锁写法,用来实现一些简单的互斥逻辑非常合适。这也是我在项目里最常用的一条组合指令。

2.4 SETEX / PSETEX:老命令也有存在价值

再来看一对老一点的命令:

SETEX key seconds value PSETEX key milliseconds value

SETEX 可以理解成 SET + EXPIRE 的合并,5.0 版本之前这是少数能在一条命令里完成“写值+设过期”的途径。现在有了 SET 的 EX/PX 参数,SETEX 的存在感确实低了不少,但它并没有被废弃,而且有几个场景还是能派上用场。

一个典型场景是兼容性。某些老客户端库或者内部封装的中间件,对 SET 扩展参数的支持不完整,遇到 SET key value EX seconds 可能会当成普通 SET 来处理,直接把“EX seconds”这部分当成 value 的一部分写进去,这可就闹笑话了。碰到这种情况,用 SETEX 这种老牌命令反而更稳妥。

还有一个场景是代码可读性。SETEX key seconds value 的参数顺序虽然有点反直觉(value 放最后),但它的目的非常明确,就是“给一个 key 设置值并指定过期时间”,比起在 SET 后面挂一个 EX 参数,语义上更直白,对新手也更友好。当然,习惯用 Redis 命令行的人还是更习惯 SET EX。

2.5 设置过期时间的副作用:覆盖、重设和删除

这一段要专门说几个反直觉的行为,因为我在实操中被坑过,后来发现身边的人也经常被坑。

第一个是:对一个已经存在过期时间的 key 执行 SET,会把旧过期时间清掉。什么意思呢?比如你先执行了 SET token:123 "abc" EX 300,过了 100 秒后,你又执行了一条不带过期参数的 SET token:123 "changed",那么原来的 300 秒过期时间会直接被清掉,这个 key 会变成永久 key。这不是 Redis 的 bug,而是 DEL 一样,只是很多人没意识到它有这个副作用。

第二个是:SETEX 和 EXPIRE 是可以设置成功后立刻把 key 变成“永不失效”的。如果某天你想把一个 key 从“有过期时间”改成“永久 key”,不要绞尽脑汁去算一个很远的时间戳,直接用:

PERSIST key

这个命令就会把过期时间抹掉。同样,如果业务上需要临时让一个 key 长期存活,做缓存预热的时候也可以先用 PERSIST 去掉过期时间,等稳定之后再重新设置,避免周期性的缓存重建。

第三个是:如果给一个不存在的 key 设置过期时间,命令会返回 0。这一点在脚本里很关键。比如你用 Lua 脚本来做“如果 key 存在就刷新过期时间”,就必须先判断 EXPIRE 的返回值,否则会出现你以为设置成功了,实际上因为 key 已经被别的地方删掉,过期时间根本没设置上的情况。

3. 获取剩余时间:TTL 与 PTTL 怎么看懂返回值

3.1 TTL 返回 -1、-2 和正整数分别代表什么

获取过期时间,最常用的命令是 TTL:

TTL key PTTL key

TTL 返回的是 key 剩余存活时间,单位是秒。它有三种返回值,很多人只能说出前两种,第三种最容易被误解,我在面试候选人的时候会重点问:

  • 返回一个大于等于 0 的整数:key 存在,并且有剩余过期时间。比如返回 128,意思是还有 128 秒后过期。注意这个数字是四舍五入还是向下取整,Redis 官方文档的说法是“以秒为单位返回剩余生存时间”,但对小数秒的处理会取整,所以如果你用 PTTL 去对比精度,会发现两个返回值不是简单的毫秒除以 1000 的关系。
  • 返回 -1:key 存在,但没有设置过期时间,也就是永久 key。
  • 返回 -2:key 不存在。注意,Redis 2.8 之前的版本,key 不存在时 TTL 返回的是 -1,和“永久 key”混在一起,后来为了区分才改成 -2。如果你在用非常老版本的 Redis,或者兼容老协议的一些代理中间件,看到这种返回值时要留个心眼。

这个返回值在业务代码里用处很大。比如做缓存续期的时候,你希望只有当 key 剩余时间少于某个阈值时才去刷新过期时间,那就可以先 TTL 判断一下,避免频繁调用。

这里有一个小细节:TTL 的精度是秒,而且返回的是向下取整的值。如果 key 实际只剩 0.5 秒就过期,TTL 会返回 0。所以当你判断一个 key 是否快要过期时,别用 TTL == 0 来当作“已经过期”,它在并发场景下很容易误判,应该用 EXISTS 命令去确认真实状态。

3.2 PTTL:毫秒级精度在哪个场景才需要

PTTL 就是 TTL 的毫秒版本:

PTTL key

正常情况下,Redis 命令执行一次只有几十微秒到几毫秒,所以用 PTTL 去拿一个高精度的剩余时间,在绝大多数业务里属于“精度过剩”。但有一个场景确实需要用到它:分布式锁的自动续期。

我在做分布式锁的时候遇到过一个问题:锁的过期时间设置成 10 秒,业务执行可能到 9 秒还没跑完,这时候需要给锁续期。如果我用 TTL 去判断,它返回 9,看起来还剩挺多时间,实际上可能已经过了 9.6 秒,再晚点续期锁就莫得了。用 PTTL 拿到毫秒级剩余值,就能设定一个更精准的续期阈值,比如剩余时间少于 3000 毫秒时才重新设置过期时间,这样既能减少续期操作次数,又能降低锁提前失效的风险。

另外,单元测试里也常见 PTTL。我要验证一个 key 是否真的设置过期时间,又不方便真的等几秒去观察,就写一个测试:SET key value EX 5,然后立刻断言 PTTL key 返回值在 4800 到 5000 之间,既证明命令生效了,又不用真的去等 5 秒。

3.3 过期时间持久化与副本同步的几个细节

这块属于“平时没感觉,一遇到主从切换就出事”的内容。你在主节点设置了一个 key 的过期时间,Redis 在做持久化和主从复制时,是怎么处理这个信息的?这里有几个关键点值得背下来。

第一个,RDB 持久化。Redis 生成 RDB 文件的时候,会把 key 的剩余过期时间也存进去。比如某个 key 原本是 60 秒后过期,在生成 RDB 快照的时刻它已经过了 10 秒,那 RDB 里记录的不是“60 秒后过期”,而是“还剩 50 秒后过期”。这样从 RDB 恢复数据时,Redis 能重新计算剩余时间,而不会出现恢复后 key 又多活了 60 秒的问题。

第二个,AOF 持久化。AOF 重写时,有过期时间的 key 会用一条带过期参数的写入命令来记录,比如 SETEX key seconds value,这样重放时也能保留过期时间。

第三个,主从复制。这是最容易出幺蛾子的地方。Redis 从节点不会自己发起删除过期 key 的操作,它会等主节点删除后才同步删除动作。因为如果从节点各自判断自己到的过期时间主动删,主从之间的数据一致性就很难保证了,特别是在网络分区场景下,可能出现一个数据在主节点已经被删了,从节点还照样能读到的情况。

这四个字就是这套设计的核心:主从一致。当你读从节点时,如果发现某个 key 在主节点已经过期了但从节点还在返回数据,不要惊讶,这是设计如此。对一致性要求特别高的场景,要么你就只读主节点,要么就对过期容忍度做业务层面的补偿。

4. 过期删除不是等到点再删:Redis 的惰性删除与主动删除

4.1 惰性删除:平时不干活,读取时检查

很多第一次接触 Redis 的人会有一个直觉:既然设置了过期时间,那 Redis 是不是有一个定时器,到时间了就把 key 删掉?答案是否定的。Redis 不会为每一个 key 单独创建一个定时任务,那样内存和 CPU 开销都扛不住。它采用的是两种策略结合的方式。

第一种叫惰性删除。当客户端访问一个 key 时,Redis 会先检查这个 key 是否已经过期,如果过期了就立即删除,然后返回给客户端“这个 key 不存在”。这个策略的好处是只在你真正用到这个 key 时才去判断,CPU 开销极小;坏处也明显:如果某个 key 被设置过期后,一直没有任何客户端来访问它,那它就会一直占着内存,不会被主动清除。这就是“惰性”两个字的含义。

举个例子,你设置了一个活动专用的 key,有效期为 1 分钟,但活动结束以后这个 key 彻底没有访问了。从逻辑上看,它应该在 1 分钟后消失,但实际上它会就那么静静地躺在内存里,直到某一天有请求读到它,或者 Redis 内存紧张触发其他清理机制,它才会被真正释放。在内存充足且 key 量不大的场景里,这种残留问题不严重;但如果这种“死 key”数量巨大,内存浪费就不可忽视了。

4.2 定期删除:每 10 次/秒的抽样回收

为了解决惰性删除带来的内存残留问题,Redis 还有一个主动策略,通常叫“定期删除”。它不是遍历整个数据库检查所有 key,而是定期(默认每秒 10 次)从设置了过期时间的 key 集合中随机抽取一批,检查哪些已经过期,然后删掉它们。

这里的关键词是“随机抽样”,不是全量扫描。抽样数量的多少和 CPU 消耗之间有一个动态平衡:如果本次抽样发现过期 key 的比例比较高,Redis 会认为此时处于“过期密集期”,会适当增加本轮删除的 key 数量;如果抽样发现过期 key 比例很低,就会尽快结束本轮操作,把 CPU 让给正常请求。

所以你在设计大量 key 的过期时间时,尽量避免把所有 key 的过期时刻设置在同一秒,否则会对 Redis 造成一个“过期风暴”,导致短期内大量 key 需要被扫描和删除,主线程卡顿。我之前做活动缓存预加载时,会在原始过期时间上加上一个随机的 0 到 300 秒的偏移量,就是为了打散这个风暴时刻。

4.3 内存淘汰与过期删除的区别

这个问题我面试时几乎必问,因为很多人会把“过期删除”和“内存淘汰”混为一谈。简单说:

  • 过期删除:key 本身带了过期时间,时间到了就删,这是业务语义决定的。
  • 内存淘汰:Redis 内存满了,没有设置过期时间的 key 也可能要被淘汰,这是物理资源限制决定的。

当 Redis 的 maxmemory 达到上限后,会根据配置的淘汰策略(如 allkeys-lru、volatile-ttl、allkeys-random 等)选择淘汰哪些 key。allkeys-lru 会在所有 key 里淘汰最久没被使用的;volatile-ttl 则只会从设置了过期时间的 key 里挑剩余时间最短的淘汰。

注意,如果配置了 noeviction,内存满了之后,所有写入操作会直接报错,而读操作不受影响。我曾经在一台低配服务器上部署 Redis 时没调内存策略,结果缓存写满了直接导致业务写入全部失败,排查了半天才在日志里看到 OOM command not allowed when used memory。从那之后,我在生产环境一般会把淘汰策略配置成 allkeys-lru,除非有非常特殊的业务要强制保留 key。

5. 业务设计中的过期时间实战

5.1 缓存自动降温和固定过期时间的选择

缓存场景里,过期时间的最常见问题是“缓存雪崩”。简单说,大量 key 在同一时间一起过期,导致大量请求同时打到数据库。解决办法有一个屡试不爽的组合拳:

  • 过期时间增加随机扰动,比如基础 1 小时加 0 到 300 秒随机值。
  • 热点 key 可以考虑不设置过期时间,而是通过后台任务主动更新。
  • 数据库层面做好兜底,比如对热点数据做永不过期 + 哨兵更新。

但有些业务不能用“固定过期”一刀切。比如用户登录态,你希望用户长时间活跃时登录态一直有效,如果固定 30 分钟过期,用户每过 30 分钟就得重新登录一次,体验很差。这时候用“滑动过期”更合理:用户在 30 分钟内只要有任何操作,就刷新过期时间。

实现滑动过期很简单:

# 每次用户请求时先判断剩余时间,若不足 5 分钟则续期 TTL user:token:xxx # 若返回值小于 300,执行 EXPIRE user:token:xxx 1800

但这里有个细节:每一次操作都调用 EXPIRE 会对 Redis 产生额外的写压力。如果用户的每次请求都触发续期,那在高并发场景下,原本只是读 Redis 的头,变成还要写 Redis,性能就会受影响。更聪明的方案是把续期条件设置得宽松一点,比如剩余时间少于 10 分钟才续期,这样平均续期频率会低很多;或者直接用 Lua 脚本判断并续期,减少网络往返。

5.2 分布式锁中的过期时间:也别太迷信魔法数字

Redis 做分布式锁,最经典的问题是“锁太短”和“锁太长”。锁太短:业务还没执行完,锁就过期了,另一个线程拿锁进来,导致临界区代码并发执行。锁太长:业务出问题一直不释放,后面所有线程都拿不到锁,系统直接卡死。

一种常见的做法是设置一个合理的过期时间,比如 30 秒,然后业务里启动一个看门狗线程定期续期。Redisson 的看门狗默认锁租约续期是锁持有时间的 1/3,每 10 秒续期一次,其实就是围绕“过期时间续期”做文章。如果你不想引入 Redisson,自己写续期逻辑时要特别注意“续期必须基于旧值判断”,否则会把自己释放的锁又续上,造成锁失效。

我个人的建议是:业务能用“SET key value EX 秒数 NX”就先用最简方案,因为多数分布式锁需求没那么复杂;等真的出现“业务执行时间经常超过锁过期时间”的问题,再引入看门狗或 Redisson 也不迟。为了一个几毫秒的临界区代码,上全套分布式锁框架,是一个很典型的过度设计。

5.3 用 key 过期事件做延迟任务

Redis 的 key 过期不只是释放内存,还可以作为事件源。打开 key 空间通知功能之后,Redis 会在 key 过期时发布一条事件,广播给订阅者。配置方法是在 redis.conf 里设置:

notify-keyspace-events Ex

这里 E 表示 key 事件,x 表示过期事件。代码端可以通过 SUBSCRIBE 订阅__keyevent@0__:expired这个频道,监听到某个 key 过期后,就知道“时间到了”并触发后续业务。

这个机制非常适合做简单的延迟任务。比如下单后 15 分钟未支付自动取消,可以设置一个键 order:12345,过期时间 900 秒,等它过期后,收到事件就去扫描数据库,判断订单是否已付款,如果未付款就自动取消。这种方案比定时轮询数据库要轻量,但它有一个很大的局限:Redis 的过期事件不是严格实时的,与定期删除的周期有关,一般来说会有秒级延迟。如果你需要毫秒级精确的延迟任务,还是老老实实用消息中间件的延迟消息或专门的任务队列。

另外要注意,Redis 5.0 之后的 stream 功能也可以用来做延迟队列,但复杂度并不低。我的经验是:延迟任务对时间要求不高的场景,Redis key 过期事件是性价比最高的方案;对时间要求高或者要支持大量积压任务的场景,慎用 Redis,换专业组件吧。

6. 常见问题与高频面试题速查

最后这部分做成一个速查表,把日常工作和面试中最高频的几个问题一次性说透。

问题答案背后原理
TTL 返回 -2 和 -1 有什么区别-2 表示 key 不存在,-1 表示 key 存在但永不过期Redis 2.8 之前两者都返回 -1
EXPIRE 能设置永不过期吗不能,EXPIRE 的秒数必须大于 0等于设置成 0 或负数时直接删除 key
怎么取消一个 key 的过期时间用 PERSIST command与 EXPIRE 覆盖逻辑相反
Redis 到时间就立刻删除 key 吗不是,采用惰性删除 + 定期删除有秒级延迟
主从架构下 key 过期怎么处理从节点不主动删,靠主节点同步删除命令为了主从一致性
设置了过期时间的 key 可以 SET 覆盖吗可以,但普通 SET 会清掉过期时间除非新 SET 也带过期参数
缓存雪崩的解法过期时间加随机值,错开删除高峰避免同时产生大量过期 key
分布式锁的过期时间怎么给一般取业务预计耗时的 3 到 5 倍,再加续期机制防止提前失效导致并发进入临界区
怎么监听 key 过期配置 notify-keyspace-events Ex,订阅keyevent@0:expired依赖定期删除,有延迟

我在实际使用中还有两个容易被忽略的小技巧,也一起分享出来。

第一个,批量设置同一小时的过期时间。如果你有一批相同的 key 要在同一时间过期,与其遍历给每个 key 调用 EXPIRE,不如在生成 key 名字时就带上一个批次号,比如cache:2025022014:user:123,然后对整个批次做清理时直接按前缀 SCAN 删除,比依赖过期机制更主动、更可控。

第二个,调试过期时间相关逻辑时,别干等着。Redis 自带的 DEBUG 命令虽然不推荐在生产使用,但在本地环境快速验证非常方便:

DEBUG SET-ACTIVE-EXPIRE 0

这条命令可以让定期删除暂定,帮你观察某个 key 在“到期但没人访问”时确实不会被立刻删除。验证完记得设回 1,否则本地开发环境里的内存会把 key 一直堆着。

Redis 过期时间的用法,说复杂确实不复杂,但如果只停留在会敲 EXPIRE 和 TTL 这两个命令,后面业务一复杂,各种坑就会接踵而至。希望这篇文章能把你在过期时间这块的最后一个盲区补上。

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

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

立即咨询