1. 为什么一个“过期时间”值得单独拿出来写一篇先问个问题你写 redis 的时候是不是经常就是set key value然后在某些需要过期的场景下想起来用一下EXPIRE我见过太多项目里 expiry 用得相当随意的代码——有的同学给 key 设了过期时间结果发现内存只增不减有的用 SETEX 存会话却没搞明白它对已存在的 key 是怎么处理的还有人把分布式锁的过期时间拍脑袋定了个 5 秒然后在高并发下眼睁睁看着锁提前失效多个线程同时怼进临界区。这事的本质是什么redis 的过期时间不是一个“锦上添花”的功能它直接关系到你的缓存命中率、内存水位、数据一致性甚至整个系统的可用性。最典型的场景就摆在那验证码 5 分钟失效、登录 token 7 天过期、订单未支付 30 分钟自动关闭、限流窗口按秒滑动。这些都是靠 redis 过期机制在背后撑着。你要是没把这套机制的原理、命令选型、底层策略和那些隐藏很深的坑搞清楚线上迟早要出事。这篇文章我就不讲那些入门文档里抄来抄去的概念了直接围绕“设置过期时间”这个点把它拆开揉碎命令怎么选、底层怎么删、分布式下怎么用、哪些参数会反直觉、线上遇到问题怎么排查。适合刚接手 redis 的初级开发也适合用了两年 redis 但没系统性踩过坑的中级工程师。看完你至少能回答这几个问题为什么我的 key 过期后内存没有立刻释放为什么设置了过期时间的锁还是不安全为什么同一批 key 的过期时间不能设成完全一样2. 过期时间的基本操作命令层你未必用对了2.1 设置过期时间的四种姿势先用一张表把最常用的命令摆出来这是基础中的基础但我敢说有不少人是混着用、没想过差别的命令语法过期时机适用场景EXPIREEXPIRE key seconds秒对已有 key 追加过期时间PEXPIREPEXPIRE key milliseconds毫秒需要毫秒级精度的短时控制SETEXSETEX key seconds value秒设置字符串值并同时指定过期SET EX/PXSET key value EX seconds秒/毫秒原子设置值和过期时间推荐用这个SETNX EXSET key value NX EX seconds秒分布式锁的标准写法注意看第三行和第四行。SETEX和SET key value EX seconds本质上都能达到同样的效果但很多老文档推荐后者原因在于SET命令在 Redis 2.6.12 之后支持了EX、PX、NX、XX这些参数一句话就能完成“设置值 过期时间 不存在才写”的组合操作减少了一次网络 RTT。在有脚本化或流水线的场景里这个优势会被进一步放大。还有一个经常被忽略的细节EXPIRE是给已经存在的 key 追加过期时间SETEX是写新值并覆盖旧的过期设置。如果你业务上有“写缓存 覆盖式更新”的习惯用SETEX或SET ... EX反而更安全因为它天然把旧 key 的剩余存活时间给清掉重算了。而EXPIRE单独使用的时候要小心别在一个已经有过期时间的热 key 上重复调用导致过期时间被无意中延长——这个问题后面在坑位表里细说。2.2 查询与删除TTL 是你排障的第一抓手设置完过期时间你总得验证吧和过期时间直接相关的查询命令是这三个TTL key返回剩余存活秒数。PTTL key返回剩余存活毫秒数。PERSIST key去掉过期时间让 key 永久保留。TTL 的返回值一共有三类情况这点特别重要返回值含义正整数剩余存活时间秒/毫秒-1key 存在但没有设置过期时间-2key 不存在或已过期被删除我第一次排查线上缓存失效问题时就是靠 TTL 的分布情况定位的。比如执行TTL user:123:token返回 -1说明这个 key 当初写入的时候压根没带上过期时间那问题不出在过期策略而出现在写入代码里。返回 -2则说明 key 已经没了要么是过期被清掉了要么是内存淘汰策略把它逐出了要么是被人 DELETE 了。先把 TTL 分清楚再往下查原因成功率会高很多。另外PERSIST这个命令虽然看着简单实战里会用的人不多。比如你在运营后台临时要把某个活动页缓存延长一天正确的做法不是把值重新写入一边而是直接PERSIST key去掉过期或者EXPIRE key 86400重新设定。但要注意PERSIST 是 O(1) 操作在热 key 上没问题如果长时间持有的 key 突然需要统一清理那就要考虑批量扫描 删除别指望逐条 PERSIST 能撑住大规模场景。2.3 一个练习5 分钟快速验证你对过期时间的理解这个验证方法很简单在本机起个 redis然后开两个窗口操作。窗口 A 执行127.0.0.1:6379 SET page:home html.../html EX 30 OK窗口 B 每秒执行一次TTL page:home观察变化。这个过程本身没什么难度但请你同时做以下几件事DEBUG OBJECT page:home看内部编码和 idle 时间了解这个 key 的性质。在 5 秒时用PERSIST page:home再观察 TTL 是否变回 -1。在 15 秒时用GETSET page:home new观察过期时间还会不会沿用不会会清掉。在 20 秒时强行DEL page:home再查EXISTS page:home和TTL确认都是 0 和 -2。这一轮下来你对命令层的行为模式就会有肌肉记忆。别小看这一步——后面讲到底层策略、分布式场景的坑全都是建立在这些基础行为之上的。3. 底层原理过期 key 是怎么被删掉的为什么删得不及时3.1 redis 怎么记录过期时间你执行EXPIRE key 60之后redis 其实干了两件事在当前 key 的 dict 里找到对应 entry再把“过期时间”这个元数据记录到另一个专门的结构里。这个结构叫expires字典在 redis 源码里是dict *expires。它的 key 是指向主字典某个 entry 的指针value 是一个 64 位整数存的是绝对的过期时刻毫秒级时间戳。这样设计的好处是主字典里存真正的数据过期字典里只存“什么时候该删”的元数据两套结构互不干扰查找和删除的性能都保持在 O(1)。举个例子。你SET foo bar EX 100redis 内部会先往主 dict 写入foo - bar然后往 expires dict 写入foo - 当前毫秒时间戳 100000。注意这里存的是绝对过期时刻不是相对存活时长。所以当系统时间发生跳变时redis 对过期时间的判断会立刻受影响——这就是“为什么我明明设了 10 小时过期结果系统时间调了一下key 反而全没了”的根源。这个问题在生产环境因为 NTP 校时偶尔会遇到后面排查章节细说。3.2 被动删除惰性删除既然 redis 知道了每个 key 的绝对过期时刻那它是不是搞一个后台线程每秒扫描所有 key、把过期的一把删掉绝对不是。redis 默认是单线程事件循环模型如果每隔几毫秒就全库扫一遍其他所有命令都被卡住性能直接崩掉。所以 redis 采用了“惰性删除 定期删除”的组合策略。惰性删除是什么意思就是你访问一个 key 时redis 才去检查它到底过没过期。逻辑在expireIfNeeded函数里执行路径在lookupKeyRead/lookupKeyWrite这类入口。大致流程在 expires dict 里找这个 key 的过期时间。如果没找到说明没设过期正常返回。如果找到了用当前时间戳和绝对过期时刻比较。如果还没过期正常返回。如果已经过期就把这个 key 从主 dict 和 expires dict 里同时删掉然后再告诉调用方“这个 key 不存在”。也就是说一个已经过了期的 key如果你一直不去访问它它可以继续躺在内存里不释放。只有在你下一次访问它时redis 才意识到“哦这家伙过期了”然后把它清理掉。这种策略叫“被动删除”好处是对 redis 主流程的干扰最小坏处就是“过期数据”在内存里滞留的时间不可控。3.3 主动删除定期删除如果只靠惰性删除那些设置了过期时间但从此不再被访问的 key 就会永远占着内存这显然不可接受。所以 redis 还有一个定期删除的机制源码主要在activeExpireCycle函数里。简单来说redis 会在它的时间事件循环里定期调用这个函数按一定策略采样一批 key 来检查和处理。注意不是全库扫描而是采样。具体逻辑大致是从 expires dict 中随机取出一批 key默认每个数据库检查一定数量的 key。检查它们的绝对过期时间如果过期则删除。控制删除操作的总耗时避免阻塞主线程太久。如果被采样的 key 中过期比例仍然很高超过一定阈值会继续多轮采样把“该清的过期 key”尽量清掉。这个策略有个特点它消耗的时间是有限的、有上限的所以 redis 不会因为清理过期 key 导致自身卡死。但代价是如果瞬时出现了大量 key 同时过期定期删除那一轮只能处理一部分剩下的过期 key 会继续占据内存直到下一次轮询或惰性删除把它们清掉。这也是为什么后面我会反复提醒给 key 设置过期时间时千万不要让大量 key 在同一秒内同时过期。3.4 内存淘汰策略和过期时间的关系还有一个概念容易混淆过期删除 ≠ 内存淘汰。虽然两者都可能导致 key 消失但事件完全不同过期删除针对的是设置了 TTL、且时间到了的 key。这些 key 在 expires dict 里有明确的过期时刻。内存淘汰针对的是当 redis 内存达到maxmemory阈值时按照配置的策略逐出某些 key。被逐出的 key 可能根本没设置过期时间。配置项是maxmemory-policy常见的有这些策略含义与过期时间的关系noeviction内存满时新写命令直接报错不淘汰任何 key过期 key 只靠惰性删除和定期删除allkeys-lru从全量 key 中基于 LRU 淘汰可能淘汰掉没有过期时间的长驻 keyvolatile-lru只从设置过 TTL 的 key 里按 LRU 淘汰未设置 TTL 的 key 永不被淘汰allkeys-random随便挑一个 key 淘汰不区分是否有 TTLvolatile-random只从设置了 TTL 的 key 里随机淘汰同上这里我想特别提一下volatile-*系列。如果你的缓存架构里同时存在“永久 key”和“带 TTL 的 key”而你想让“带 TTL 的 key”在内存吃紧时优先被逐出那volatile-lru是很直观的选择。但它有一个潜在风险如果 Redis 里大部分 key 都没设 TTL那在内存满的时候volatile-lru 可淘汰的范围非常小几乎等于 noeviction新写入命令可能会直接报 OOM 错误。所以选型之前最好用INFO keyspace看一眼各种 key 的数量分布。4. 实操场景一缓存治理中的过期时间设计4.1 缓存过期时间设多少才合理理论讲完来点实打实的东西。最常见的需求是把数据库的查询结果缓存到 redis比如用户信息、商品详情、配置项。那么过期时间设多少有人说 5 分钟有人说 1 小时还有人直接不设置。这些拍脑袋的方案真的没问题吗我先把设计原则说清楚再给一个参考表过期时间不能比业务上“可容忍的数据不一致窗口”还长。比如你缓存的是库存数量那 5 分钟可能都嫌长如果你缓存的是文章标题10 分钟也问题不大。过期时间要配合缓存更新策略看。如果你有主动更新的机制比如收到 MQ 消息后主动删缓存那过期时间更多是兜底性质可以设长一点如果只能靠过期以后回源数据库那设置宜短不宜长否则会长时间读到脏数据。同一批 key 的过期时间要加一个随机偏移量这个后面专门说。数据类型过期时间建议备注用户 Session / Token30 分钟 ~ 2 小时滑动续期每次访问重设过期验证码3 ~ 5 分钟不宜过长且校验后立即删除商品库存10 ~ 30 秒配合数据库扣减缓存只做短窗口保护配置项10 ~ 30 分钟更推荐用发布订阅主动刷新运营活动页5 ~ 10 分钟大范围涌流时回源数据库压力大建议加大随机量4.2 缓存穿透、击穿、雪崩和过期时间的关系这三个概念和过期时间直接绑在一块我用最直白的大白话解释。缓存穿透压根不存在的数据被高并发请求打到数据库。比如用户查一个不存在的订单号你的代码先查缓存、没命中就查库、库里也没有、于是没有回写缓存。下次同一个订单号还是会穿透。这时设置过期时间管用吗不管用因为压根没数据可缓存。标准方案是缓存一个空的占位符并设置短过期时间比如 30 秒把“不存在的查询”也挡住。注意这个空值缓存的过期时间不能太长否则数据真出现了之后缓存里还是空造成一致性问题。缓存击穿一个热点 key 恰好在某个时刻过期而同时有大量请求打过来全部穿透到数据库。这个问题的根源就是一个 key 的过期时间到了。解决办法热点 key 可以考虑不设置过期时间改为后台主动刷新或者使用互斥锁只允许一个线程重建缓存再或者用逻辑过期——在 value 里存一个逻辑过期时间应用层自己去判断。缓存雪崩大量 key 在同一时间段内集中过期导致大量请求同时穿到数据库。这就是为什么我反复强调同一批 key 的过期时间不能一刀切。给过期时间加随机偏移量是一种最简单、最有效的预防手段。比如你原本计划设 600 秒实际设置的时候生成 600 random(0, 60) 秒这样过期就不会集中在同一秒。4.3 实战Spring Boot 下正确的缓存过期时间写法写代码这块我用 Spring Boot RedisTemplate 来示范。注意这不是唯一方案但能反映大多数项目的实际情况。定义一个带随机偏移的过期时间工具public class RedisTtlUtil { private static final ThreadLocalRandom RANDOM ThreadLocalRandom.current(); /** * 生成一个带随机偏移的过期时间避免缓存雪崩 * 基础秒数 baseSeconds实际在 [baseSeconds, baseSeconds jitterSeconds] 区间浮动 */ public static Duration randomTtl(long baseSeconds, long jitterSeconds) { long realSeconds baseSeconds RANDOM.nextLong(0, jitterSeconds 1); return Duration.ofSeconds(realSeconds); } }然后写缓存的读改写逻辑Service public class ProductService { Autowired private StringRedisTemplate redisTemplate; Autowired private ProductMapper productMapper; private static final String KEY_PREFIX product:detail:; public Product getById(Long productId) { String key KEY_PREFIX productId; String json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, Product.class); } // 互斥锁重建防止缓存击穿 String lockKey lock:product: productId; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (locked) { try { json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, Product.class); } Product product productMapper.selectById(productId); if (product null) { // 空值缓存防穿透 redisTemplate.opsForValue().set(key, , Duration.ofSeconds(30)); return null; } String productJson JSON.toJSONString(product); // 带随机偏移的过期时间防雪崩 redisTemplate.opsForValue().set(key, productJson, RedisTtlUtil.randomTtl(600, 60)); return product; } finally { redisTemplate.delete(lockKey); } } else { // 睡一小会儿再重新读取实际生产用 while 超时上限 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } json redisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, Product.class); } } // 兜底走到这里说明锁竞争失败且缓存没恢复直接查库 return productMapper.selectById(productId); } }这里值得注意的坑点有两个setIfAbsent就是SET key value NX EX它在设置锁的同时指定了锁的过期时间。这个过期时间一定要设置否则进程宕了锁永远不会释放。缓存重建之后的过期时间一定要带随机偏移。你可能觉得“我把过期时间设成 10 分钟一小时后所有 key 同时过期查库尖峰不是还能接受吗”——放在单机上可以但在分布式集群里所有请求打到同一个数据库时尖峰就非常致命。加随机量几乎零成本收益却很高。5. 实操场景二分布式锁里过期时间怎么定才安全5.1 固定过期时间的锁为什么危险分布式锁用 redis 实现时最经典的模式就是SET lockKey ownerId NX EX expireTime。锁的持有者靠过期时间保证如果持有者进程挂了锁也能自动释放不至于死锁。但问题来了这个过期时间设多大设短了比如 5 秒但你的业务逻辑执行需要 6 秒。第 5 秒锁自动过期另一个线程拿到锁进来两个线程同时执行分布式锁形同虚设。最典型的场景就是定时任务跨节点重复执行你以为锁住了结果两台机器都跑了同一批数据。设长了比如 30 秒如果持有者真挂了其他线程要等 30 秒才能抢到锁系统可用性下降。而且如果业务异常导致持有者既没执行完、也没释放锁那这段时间就是人为造成的“死锁窗口”。5.2 一个可行的解决方案续期机制真正的解决方案不是把过期时间调大而是动态续期。在 Java 生态里Redisson 的watch dog机制干的就是这件事。它的行为大致是默认lockWacthdogTimeout是 30 秒加锁成功后后台有一个定时任务每 10 秒检查一次锁是否还在。如果锁还在且业务没执行完就自动把锁的过期时间重新续到 30 秒。如果业务执行完了主动释放锁后台任务也被取消。这样无论业务执行多久只要进程活着锁就不会因为“固定过期时间太短”而提前失效。但如果进程挂了呢后台定时任务也没了30 秒后锁自动释放不会死锁。如果你不想引入 Redisson自己写续期也不难。一个简单的思路加锁成功后启动一个定时任务每过期时间的三分之一时间去检查并调用PEXPIRE续期。在 finally 块里释放锁并取消定时任务。下面给出一个简易版本的锁续期工具思路public class RenewSchedule { private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); public void renew(String key, String owner, long expireMillis) { scheduler.scheduleAtFixedRate(() - { // 检查锁是否还是自己的 String current redisTemplate.opsForValue().get(key); if (owner.equals(current)) { redisTemplate.expire(key, Duration.ofMillis(expireMillis)); } }, expireMillis / 3, expireMillis / 3, TimeUnit.MILLISECONDS); } }这个实现是简化版生产上需要考虑 scheduler 的优雅关闭、续期失败时的告警、锁竞争竞争的公平性等等。但核心思路不变过期时间不是用来限制业务执行时长的而是兜底防死锁的。5.3 续期之外锁的 owner 标识为什么不能省还有一个细节容易被忽略——设置锁的时候value 一定不能是固定的“1”必须是当前线程或节点的唯一标识。因为释放锁时要做校验先判断 value 是不是自己的是才删除。如果不做这个校验可能出现下面的情况线程 A 加锁value 是 A。线程 A 执行时间太长锁自动过期。线程 B 加锁成功value 是 B。线程 A 执行完了执行DEL key。锁被 A 释放了但此时持锁的人明明是 B。所以释放锁的标准姿势是用 Lua 脚本保证“判断 删除”的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本的存在本质上就是在“谁加的锁谁才能释放”这个前提上加了一层保护。和过期时间配合构成了分布式锁的完整闭环过期时间防止死锁owner 标识防止误释放。6. 那些反直觉的坑过期时间相关的常见问题排查实录6.1 我设的过期时间明明很长为什么 key 还是消失了这类问题我见过好几次。先按优先级排查这样几个方向内存淘汰策略执行CONFIG GET maxmemory-policy看是不是allkeys-lru或allkeys-random。如果是redis 内存达到上限时会从所有 key 里淘汰数据不区分你有没有设过期时间。解决办法调整策略为volatile-lru或者增大内存上限或者优化数据存储、去掉冗余 key。key 被主动删除查代码看有没有定时任务、MQ 消费逻辑、运营后台手动删除。很多时候不是 redis 的问题是上层业务动了手。系统时间跳变redis 里的过期时间是基于服务器时间戳判断的。如果服务器因为 NTP 校时等原因回拨或前跳可能导致 key 集体提前过期或无限延长。这种情况比较隐蔽可以用INFO server看 uptime 和系统时间比对一下。6.2 key 过期了但内存没降为什么答案就是上面讲的惰性删除机制。过期 key 在访问之前不会立刻释放内存只有定期删除的那轮扫描才会清掉一部分。如果你压测时往 redis 里塞了几百万个设置了 1 分钟后过期的 key然后什么也不做等 10 分钟后看INFO memory你会发现 used_memory 可能几乎没变。敲一下SCAN会触发 key 的惰性删除但要注意大规模 SCAN 对线上 redis 的影响。如果想让过期 key 更快释放可以调定期删除的频率相关参数但一般不建议手动去调默认行为在绝大多数场景下是合理的。6.3 大量 key 同一秒过期导致请求抖动这也是经典问题。原因和上面说的雪崩一样批量设置缓存时过期时间没加随机偏移。排查时需要看监控——redis 的慢查询日志、数据库的连接数曲线、接口的 RT 曲线三者对齐以后能明显看出周期性尖峰。解决方案就不要只盯着 redis 了要在代码层解决加随机偏移、热点 key 不设过期改逻辑过期、对回源数据库做限流/加锁。下面整理出一张速查表方便你遇到问题时直接对着查现象可能原因排查命令/手段解决方案key 提前消失内存淘汰CONFIG GET maxmemory-policy改用 volatile-lru、增大内存key 全部消失系统时间跳变比对服务器时间与 NTP 状态校正时间、缩短 key 过期范围过期后内存不降惰性删除机制INFO memorySCAN验证按需访问触发删除或用 unlink 异步清理同一时间大量请求打库缓存雪崩观察慢查询与 DB 连接数尖峰过期时间加随机偏移锁提前失效多人进入临界区过期时间短于业务耗时日志记录锁持有时间续期机制 / 逻辑延长过期时间锁释放了别人的锁没有 owner 校验查看锁 value 是否是固定值用 Lua 脚本校验 删除6.4 关于 key 过期删除对主从的影响几句话讲明白很多人不知道redis 的过期删除在主从架构里有一个需要留意的行为。在主节点上key 过期后会主动执行 DEL 命令并同步给从节点但如果从节点自己收到的读请求访问到一个已过期的 key它是不会直接删除自己的副本的在旧版本中只返回空结果但数据还留在内存里。这会导致一个局部的内存浪费但不会影响读的一致性。Redis 4.0 之后从节点也会维护自己的过期键表在收到读请求时多做一次过期判断。另外一个坑是主从切换时如果旧主节点上有大量尚未被清理的过期 key切换后新的主节点原来的从节点需要重新依赖自身的过期检测机制可能会导致少量过期 key 短暂存活极端情况下产生读脏数据。这个属于架构层面需要考虑的问题一般的业务系统不用过度纠结但如果你在做强一致需求就要在代码层多做一次过期判断不能完全依赖 redis 的删除时机。7. 实操总结给初学者的几条落地建议如果把上面所有的内容浓缩成几条可以立刻上手执行的原则我觉得是这些第一设置过期时间时优先用SET key value EX seconds这种原子写法不要把“设置值”和“设过期”拆成两步。拆两步不但多一次 RTT还会在中间窗口留下一个没有过期时间的 key万一进程崩了那个 key 就成了永久垃圾。第二批量生成缓存 key 时过期时间必须加随机偏移。代码里一句new Random().nextInt(60)就能把雪崩的尖峰削平一大半。这是成本最低、收益最明显的一个优化。第三分布式锁必须设置过期时间且必须配合续期机制。不要把过期时间当业务的“最大执行时长”它只是一个兜底防线。锁的 value 必须携带唯一标识释放锁必须用 Lua 脚本校验。第四排查问题时先看 TTL再看内存淘汰策略再查代码里的 DEL 调用。按这个顺序走80% 的“key 莫名其妙消失”问题都能找出根因。第五监控比什么都重要。把 redis 的慢日志、内存曲线、命中率以及业务侧的回源数据库 QPS 都接上监控。很多和过期时间相关的问题不是一夜之间出现的而是在曲线的某个尖峰里酝酿了好几天。你有了监控才能把问题和“过期时间设置不合理”这件事快速关联起来。我在实际项目中踩过的坑印象最深的就是缓存雪崩那次。上线前代码 review 时所有人都觉得“600 秒过期没问题”结果一到零点大促数据库连接池被打满应用网关超时率飙升。查到最后发现罪魁祸首就是一批 key 的过期时间全是整 600 秒同一时刻全部失效。后来在缓存工具类里统一加了随机偏移和互斥锁重建同样的流量模式下数据库的负载曲线几乎是一条平线。所以不要小看这个基础功能它的每一个参数背后都对应着线上真实的数据形态和资源水位设计得好是少花钱多办事设计得糙分分钟变成事故导火索。
