
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-msEXPIREAT 接收一个 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 valueSETEX 可以理解成 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 和正整数分别代表什么获取过期时间最常用的命令是 TTLTTL key PTTL keyTTL 返回的是 key 剩余存活时间单位是秒。它有三种返回值很多人只能说出前两种第三种最容易被误解我在面试候选人的时候会重点问返回一个大于等于 0 的整数key 存在并且有剩余过期时间。比如返回 128意思是还有 128 秒后过期。注意这个数字是四舍五入还是向下取整Redis 官方文档的说法是“以秒为单位返回剩余生存时间”但对小数秒的处理会取整所以如果你用 PTTL 去对比精度会发现两个返回值不是简单的毫秒除以 1000 的关系。返回 -1key 存在但没有设置过期时间也就是永久 key。返回 -2key 不存在。注意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 订阅__keyevent0__: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 之前两者都返回 -1EXPIRE 能设置永不过期吗不能EXPIRE 的秒数必须大于 0等于设置成 0 或负数时直接删除 key怎么取消一个 key 的过期时间用 PERSIST command与 EXPIRE 覆盖逻辑相反Redis 到时间就立刻删除 key 吗不是采用惰性删除 定期删除有秒级延迟主从架构下 key 过期怎么处理从节点不主动删靠主节点同步删除命令为了主从一致性设置了过期时间的 key 可以 SET 覆盖吗可以但普通 SET 会清掉过期时间除非新 SET 也带过期参数缓存雪崩的解法过期时间加随机值错开删除高峰避免同时产生大量过期 key分布式锁的过期时间怎么给一般取业务预计耗时的 3 到 5 倍再加续期机制防止提前失效导致并发进入临界区怎么监听 key 过期配置 notify-keyspace-events Ex订阅keyevent0:expired依赖定期删除有延迟我在实际使用中还有两个容易被忽略的小技巧也一起分享出来。第一个批量设置同一小时的过期时间。如果你有一批相同的 key 要在同一时间过期与其遍历给每个 key 调用 EXPIRE不如在生成 key 名字时就带上一个批次号比如cache:2025022014:user:123然后对整个批次做清理时直接按前缀 SCAN 删除比依赖过期机制更主动、更可控。第二个调试过期时间相关逻辑时别干等着。Redis 自带的 DEBUG 命令虽然不推荐在生产使用但在本地环境快速验证非常方便DEBUG SET-ACTIVE-EXPIRE 0这条命令可以让定期删除暂定帮你观察某个 key 在“到期但没人访问”时确实不会被立刻删除。验证完记得设回 1否则本地开发环境里的内存会把 key 一直堆着。Redis 过期时间的用法说复杂确实不复杂但如果只停留在会敲 EXPIRE 和 TTL 这两个命令后面业务一复杂各种坑就会接踵而至。希望这篇文章能把你在过期时间这块的最后一个盲区补上。