一条故障短信引发的思考:过期时间不是“锦上添花”
大概半年前,我们一个运营后台在晚高峰突然慢得像幻灯片,接口响应从几十毫秒飙到两秒以上。查了一圈,数据库慢日志里全是同一个大表的查询,而这个表的数据在 Redis 里明明有缓存。诡异的是,缓存命中也正常,但就是有几台机器在兜底查库。后来定位到原因:缓存 key 的过期时间设成了“固定值”,凌晨某个时间点一过,上万个 key 在同一秒过期,缓存系统瞬间被打穿,流量全数打到数据库上。
这个事故让我对“redis设置过期时间”这件事有了完全不一样的认识。很多人觉得 SETEX 一条命令的事,有什么好研究的?但真正在线上跑过、被坑过之后就明白:过期时间绝不是一个键值对的“生存倒计时”,而是 Redis 内存治理、缓存一致性、高可用设计里最核心的枢纽之一。
这篇文章不打算复述文档,而是从我实际排查和设计过的场景出发,把设置过期时间这件事掰开揉碎:命令怎么选、底层删除机制怎么走、哪些隐藏坑是文档不会告诉你的、以及分布式锁、缓存雪崩、验证码这类真实场景里过期时间该怎么用。无论你是刚接触 Redis 的新手,还是已经在线上维护 Redis 集群的工程师,这篇都能给你一些可以“抄作业”的经验。
1. 五种设置过期时间的方式,各有各的脾气
先捋一遍最基础的命令。表面上看都是“让 key 过期”,但每条命令的语义、适用场景、潜在问题完全不一样。
1.1 EXPIRE 与 PEXPIRE:最直白的“剩余生存时间”
EXPIRE key seconds # 单位:秒 PEXPIRE key ms # 单位:毫秒用法很简单,给一个已经存在的 key 设置生存时间。比如你用一个 hash 存用户购物车,操作过程中不想再用 SET 覆盖它,就可以单独调用 EXPIRE:
redis> SET cart:1024 "item_a,item_b" OK redis> EXPIRE cart:1024 1800 (integer) 1返回 1 表示设置成功,返回 0 说明 key 不存在,Redis 压根没给你设置。一个很容易犯的错是:对不存在的 key 调用 EXPIRE,Redis 不会报错,但也不会做任何事。有些人在代码里先逻辑判断再逐字写,如果 key 已经被并发删了,EXPIRE 表面成功实际失败,后续这个 key 就变成永不过期,直到内存被打爆才发现。
PEXPIRE 就是毫秒版。普通业务用秒级就够了,但像限流、秒杀、分布式锁这种需要精细控制的场景,毫秒级反而更顺手。后面讲分布式锁时会专门提到。
1.2 SETEX 与 SET EX:把“值”和“过期时间”一起托付
SETEX key seconds value SET key value EX seconds SET key value PX milliseconds这两条是整个 Redis 里最推荐日常使用的写法。理由很简单:一个命令完成“写值 + 设过期”两件事,原子性天然有保障。
设想一下用 SET + EXPIRE 分两步写:
SET verify_code:13800138000 "482913" EXPIRE verify_code:13800138000 60如果程序在 SET 之后、EXPIRE 之前崩了,这个验证码就成了永不消失的常驻内存垃圾。虽然重启能清掉,但线上 Redis 哪能随便重启?相比之下 SETEX 一条线到位,少了中间态,也少了出问题的窗口。
另一个实用技巧是SET key value EX seconds NX,这在分布式锁里几乎是标配写法。NX 表示“只有当 key 不存在时才能设置成功”,直接解决了“加锁 + 过期时间”两步操作的原子性问题。通常项目里我会建议:能用一个命令解决的,绝不用两个命令;能用 SETEX 的,绝不用 SET + EXPIRE。
1.3 EXPIREAT 与 PEXPIREAT:指定“过期时刻”的另类方案
EXPIREAT key unix-timestamp-seconds PEXPIREAT key unix-timestamp-milliseconds这两个命令接收的是 Unix 时间戳,而不是相对时长。比如让某个 key 在今晚 23:59:59 过期:
EXPIREAT activity:banner 1702396799用得少,但也不是没用。比如运营活动在固定时间点结束,用时间戳直接表达业务语义,好处是过期时刻不受设置时刻影响。即使你在 23:00 设置,业务方要求 23:59:59 失效,调用的语义非常清晰。
不过这里有个天然的坑:Redis 服务器时间必须和业务服务器时间保持一致。如果 Redis 所在机器的时钟被 NTP 校准或者被人为拨快了,那这个 key 可能会提前消失。分布式环境下,我会优先用相对时间(EXPIRE/SETEX),少用绝对时间戳,避免“时间不同步”这类玄学问题。
1.4 PERSIST、TTL 与剩余时间的观测方法
有设置自然有取消和查询。
TTL key # 返回剩余秒数 PTTL key # 返回剩余毫秒数 PERSIST key # 移除过期时间,key 变为永久TTL 返回负数时很多人会搞混:
- TTL 返回 -1:key 存在,但没有设置过期时间(永久有效)
- TTL 返回 -2:key 不存在或已经过期被删除
排查线上问题的时候,我习惯先 TTL 一把看看是不是“忘记设过期时间”了。如果一个核心缓存 key 的 TTL 常年是 -1,那基本可以断定代码里漏了 SETEX,或者用了 SET 之后再没调用 EXPIRE。这种“隐形内存泄漏”比显式写错逻辑更难抓,因为你光看业务功能一切正常,要等到内存报警才意识到问题。
1.5 一个容易被忽略的细节:EXPIRE 对“正在被操作”的 key 的影响
如果某个 key 当前正被 Lua 脚本、事务(MULTI/EXEC)操作,此时执行 EXPIRE,Redis 会等这些操作完成后再应用过期时间。这个行为对大多数人透明,但在高并发场景下,如果逻辑依赖“这个 key 立即失效”,要注意它可能比你预期晚几毫秒才真正过期。
此外,对一个已经有过期时间的 key 再次执行 EXPIRE,新值会直接覆盖旧值。这本身不是问题,但我在踩坑时就遇到过:缓存预热任务每 5 分钟跑一次,每次都重新 SETEX 一个 10 分钟的过期时间,导致 key 永远删不掉,因为预热频率比过期时间短。所以在设计缓存时,要先想清楚谁是“设置过期时间的主人”——是写入方负责,还是读取方负责。
2. 过期之后的清理机制:Redis 不是一个到点就删的闹钟
很多新手的误解是:设了过期时间,Redis 会在那一刻“叮”一下把 key 删掉。但真实情况是,Redis 的删除策略分三层,而且没有一层是“精确到毫秒的定时删除”。
2.1 惰性删除:不查就不删,省事但留隐患
惰性删除的核心逻辑是:当客户端访问一个 key 时,Redis 先检查它是否过期,过期就删除,返回空;没过期就正常处理。
这个策略的好处是节省 CPU,不浪费任何资源去扫描根本不会被访问的 key。坏处也很明显:如果一个 key 设了过期时间但从此没人再访问,它就会一直躺在内存里。比如一个优惠券 key 设了 7 天过期,但用户没再打开过页面,那这个 key 在 7 天后也不会被主动删掉,而是继续占着内存,直到某次被访问才被发现“哦你过期了”。
所以光靠惰性删除,内存迟早会出问题。Redis 需要第二个机制。
2.2 定期删除:后台巡检团,但不会全量扫
定期删除是 Redis 内部一个周期任务(默认每秒 10 次,由hz参数控制)会抽查一部分设置了过期时间的 key,如果发现有过期的就直接删掉。它不会遍历全库,而是采用随机抽查的方式,每次默认检查 20 个 key,如果其中过期的比例超过 25%,就继续再抽一批,直到比例降下来。
这样设计的聪明之处在于:定期删除只针对有过期时间的 key,而且让清理速度跟上写入速度。我在实际运维中遇到过的情况是:某个业务一次性往 Redis 里写了几十万个短时 key(比如每 30 秒刷新一次的埋点数据),如果都是 30 秒过期,那 Redis 的定期删除任务会在过期集中爆发时忙一阵,但也能扛住。
2.3 内存淘汰策略:过期时间的最后一道闸门
即使惰性删除 + 定期删除都没来得及清理,Redis 还有一层兜底:内存淘汰策略。当你设置了maxmemory,Redis 内存用满后会根据maxmemory-policy来淘汰数据,其中几种策略和过期时间直接相关:
| 策略 | 含义 |
|---|---|
| noeviction | 内存满了直接报错,不淘汰任何 key |
| volatile-lru | 只在“设置了过期时间”的 key 里按 LRU 淘汰 |
| volatile-ttl | 只在“设置了过期时间”的 key 里按剩余 TTL 从短到长淘汰 |
| allkeys-lru / allkeys-lfu | 在所有 key 里淘汰,不管有没有过期时间 |
这个设计有一个很微妙的含义:设置的过期时间不仅是业务逻辑,也是 Redis 内存治理的一部分。在内存快满时,volatile-ttl 策略会优先把 TTL 最短的 key 淘汰掉,这跟业务希望“短命 key 先走”的目标完全一致。我见过不少团队把 maxmemory-policy 设成 allkeys-lru,导致有些本应保留 1 小时的临时 key 被淘汰,而真正该淘汰的缓存却因为刚被访问过而存留下来。
2.4 大 key 过期时的阻塞风险:一个很容易踩的线上坑
这是全文最重要的一个实战警告:如果一个大 key(比如一个存了几十万个元素的 hash 或 list)到了过期时间,Redis 在大幅删除它时会造成主线程短暂阻塞。
原因很简单,Redis 是单线程模型,删除一个巨型对象需要遍历其中的所有元素来释放内存,这个遍历过程会占据主线程,其他所有命令都得排队等。我碰上过一次真实事故:一个 list 类型的 key 积攒了大几十万条数据,到点过期,结果 Redis 突然抖了一下,所有读写请求毛刺明显,业务方直接收到超时告警。
Redis 4.0 之后提供了解决方案:lazy-free 机制。开启lazyfree-lazy-expire yes后,过期 key 的删除动作会放到后台线程异步执行,主线程不会被卡住。但要注意,异步删除也有代价:内存释放不及时,瞬间内存占用会短暂升高。所以最根本的做法,还是别让 key 长得太大,超过几 MB 的对象就该考虑拆分或者压缩。
3. 反直觉的冷知识:过期时间在持久化、主从、集群里的表现
这部分内容平常人不遇到线上事故不会在意,但一旦遇到就是大事。
3.1 主从复制里,过期时间怎么传?
在主从架构下,从库不会自己“独立思考”去删除过期 key。主库负责执行删除,然后把 DEL 命令同步给从库。这意味着从库在内存里可能短暂存在“已经在主库过期但还没被删”的 key。
但这带来一个有意思的细节:客户端读从库时,如果读到这么一个 key,从库会返回什么?Redis 3.2 之后,从库在返回数据之前会先按自己的逻辑判断 key 是否过期,如果过期会返回空结果,即便内存里还有这个 key。所以主从不会出现“主库删了从库还返回旧数据”的不一致问题。不过这个判断机制依赖从库的时钟,时钟漂移依然可能造成边界差异。
3.2 RDB 与 AOF:持久化的文件里,过期 key 会被怎么处理?
先说 RDB:生成 RDB 快照时,Redis 会跳过已经过期的 key;加载 RDB 时,如果是主从架构的从库,不会删除过期 key,而是等主库的 DEL 命令同步,这个逻辑是为了防止从库加载过快导致主从数据不一致。
再来说 AOF:当 Redis 以 AOF 方式持久化时,key 过期不会立刻产生一条“删除”日志,但等到它真正被删除(惰性或定期),Redis 会追加一条 DEL 日志到 AOF。用aof-use-rdb-preamble开启混合持久化后,重写 AOF 文件时同样会把已过期的 key 过滤掉。
这些细节在灾难恢复演练中尤其重要。如果你用 RDB 文件恢复数据,一定要知道:RDB 到点保存的瞬间,那些“即将过期但还没过期”的 key 会被一并持久化,恢复之后它们依然带过期时间继续倒计时,而不是恢复成永久 key。
3.3 集群模式下对 TTL 的一致性与时钟依赖
Redis Cluster 里,key 的过期时间本质上还是沿着主从链复制,逻辑和单机主从一样。但要小心的是:不同节点的系统时钟如果偏离过大,“相对时间”会随着这次时钟偏差产生误差。相对时间在 Redis 内部转换成绝对时间戳来比较,服务器时钟回拨会让一批 key 生命周期整体拉长或缩短。我在云环境里就遇到过宿主 NTP 同步导致 Redis 节点时钟小范围跳跃,结果一批短时 key 早过期了几秒,限流策略突然全部失效。
所以无论单机、主从还是集群,都建议把服务器的时钟同步(NTP)纳入巡检项。这听起来和“Redis 过期时间”没关系,但在分布式环境下它就是有直接关系。
4. 真实场景拆解:过期时间在项目里怎么设计才科学
命令和原理只是底料,真正见功力的是场景设计。我挑三个最常见的项目场景展开说。
4.1 缓存雪崩防御:给过期时间加一点“抖动”
回到文章开头那个事故。全部缓存 key 在固定时长的同一秒到期,请求同时穿透到数据库,这就是经典的缓存雪崩。
防御做法不是“不要设过期时间”,而是给过期时间加一个随机的偏移量。比如基础过期时间 10 分钟,实际设置时随机增加 0 到 60 秒:
SETEX product:detail:1024 (600 + random.randint(0, 60)) "product_json"这样所有 key 的失效时间被摊开,数据库压力就不会瞬间集中。理论上随机偏移越多,分散效果越好,但对过期精度要求高的场景要控制抖动范围。
另一个思路是多级过期:热点数据用一个较短的 TTL + 后台定时刷新,非热点用长 TTL。我给一个数据报表系统做过这个方案:核心报表缓存 30 秒过期、冷门报表 10 分钟过期,配合后台每 20 秒预热一次核心报表,数据库负载非常平稳。
4.2 分布式锁:过期时间设多少才不翻车?
分布式锁是“过期时间”最容易翻车的场景。常规写法:
SET lock:order:1024 "unique_id" NX EX 30这里 EX 30 意思是:锁最多持有 30 秒,到期自动释放。这个 30 秒怎么定?
- 设太短:业务还没执行完锁就没了,另一个线程拿到锁,两份逻辑同时跑,数据错乱。
- 设太长:如果持有锁的节点崩溃了,其他线程要等很久才能拿到锁,系统可用性变差。
网上讨论很多,但实际项目里不能拍脑袋。我的经验是分三步:
- 先评估业务临界耗时(正常情况下完成临界区的最长时间)。比如写一条订单 + 扣库存,线上 P99 耗时是 800ms,那锁 3~5 秒就非常安全。
- 再考虑意外情况。如果下游数据库抖动,临界区执行时间拉长,锁就不够用。
- 完整的方案是“自动续期”,Redis 官方推荐的 Redisson 里的看门狗(Watchdog)就是这个思路:默认锁 30 秒,如果业务没执行完,后台每 10 秒自动续期 30 秒。锁的持有者挂了,看门狗也随之停止,锁会在原过期时间之后自动释放。
所以**“设置过期时间”在分布式锁里不是一成不变的参数,而是“超时保护”和“业务时长”之间的一场持续博弈。** 我见过太多团队把锁时长设成 10 秒、30 秒就再也不管,结果业务偶发慢查询导致锁提前释放,线上出现重复下单。如果有条件,优先引入 Redisson 这类自带续期方案的客户端;如果自己实现,一定记得加续期循环逻辑。
4.3 验证码与限流:短过期时间的“精度战争”
验证码、短信防刷、接口限流,这三类场景对过期时间的要求是“短、准、不容易并发穿透”。
短信验证码我建议用 SETEX + 有效期 60~120 秒,并配合每设备/每号码的发送间隔锁,间隔锁用 60 秒过期。这里有个容易漏的细节:客户端可能在验证码过期的最后一秒提交请求,服务端校验时 key 刚好没了,导致验证失败。优化做法是:校验接口允许 TTL 剩余 3 秒之内的验证码“再宽限一次”,或者生成验证码时把过期时间从 60 秒拉长到 65 秒,提前预留给网络传输的耗时。
接口限流的滑动窗口,Redis 官方示例用的是 ZSET 按时间戳记录请求,过期时间设置成窗口长度的两倍,例如每分钟限流 100 次,就存储 120 秒内的记录:
ZREMRANGEBYSCORE key 0 (now - 60) ZADD key now request_id EXPIRE key 120这样窗口外的记录会被清走,窗口本身的清理逻辑依赖过期时间兜底。如果 EXPIRE 漏了,ZSET 里会堆积无穷无尽的旧时间戳,内存会缓慢上涨。这里 TTL 的作用从“业务时效”变成了“内存上限保护”,本质是一致的:所有有限生命周期的数据,都应该显式设置过期时间。
4.4 数据预热与延迟删除:过期时间的高级玩法
还有一种玩法是把“设置过期时间”当成一个轻量级任务调度器。比如延迟通知:把任务内容写进一个 key,设置 60 秒过期,然后订阅 Redis 的过期事件(keyspace notifications),在收到过期消息时执行异步操作。
这种方案可以替代部分简单的延迟队列,不过要非常注意:
- Redis 过期事件不是精确的,可能延迟几百毫秒甚至更久
- 大 key 用这个方案,删除和事件触发时机不可控
- 集群模式下,过期事件只在 key 所在节点触发,消费端要做好分布式处理
我建议:延迟任务场景如果要求精确,直接使用专业消息队列;如果只是“大致过了 5 分钟后要做个清理”,用 key 过期事件做辅助没问题。
5. 实操复盘:一次缓存 key 不定期丢失的排查过程
最后分享一个让我学到最多的排查案例,完整链路走一遍,你会对过期时间有更立体认知。
现象:线上有些商品详情页偶尔出现“缓存失效、回源数据库”的日志,但频率不高,查缓存命中率也没明显下降。我们最初以为只是正常过期,后来发现商品 key 的过期时间设的是 1 小时,怎么可能每隔 20 分钟就丢一次?
排查第一步:看 key 是否存在。通过 Redis 客户端连接直接 GET,发现 key 有时存在有时不存在,而且不存在的时刻和业务日志时间对不上。
排查第二步:检查有没有别的程序删 key。用MONITOR命令跟踪一段时间,发现不是我们项目代码产生的 DEL,而是另一个团队做了“全量缓存清理”的定时任务,他们会按业务前缀批量删除“他们认为可清理”的 key。你设置的过期时间,在别人眼里可能一文不值。
排查第三步:检查内存淘汰。执行INFO stats,查看evicted_keys指标,发现持续增长。说明 Redis 内存接近 maxmemory,触发了淘汰,而淘汰策略正好是不管 TTL 长短的 allkeys-lru。有些商品 key 虽然在有效期内,但因为没有被近期访问,就被当作 LRU 牺牲品清理了。
修复方案分两层:一是和那个团队约定清理白名单,不允许跨领域批量删 key;二是把 Redis 内存策略改成 volatile-lru,确保只淘汰带过期时间的 key,而且业务核心缓存全部改成 SETEX,保证它们都在“可淘汰池”里,但优先级相对靠后。
这个案例给我的教训就是:在共享的 Redis 实例里,“过期时间”不只属于你,还属于整个集群的治理体系。设置过期时间之前,至少要确认三件事:有没有人会主动删我的 key、内存淘汰策略是什么、我的 key 是否是淘汰优先级偏高的对象。
6. 把过期时间用明白之后,我的一些体会
踩过这些坑之后,我对 Redis 过期时间的态度从“一条 SETEX 搞定”变成了“一套系统设计”。如果只留三条最核心的经验,我会写:
第一,所有写入 Redis 的数据,要么设置过期时间,要么有明确的永久语义 + 定期清理机制。不要让任何 key 在无意中变成“永久数据”,这是内存治理最基本的底线。
第二,过期时间既是业务逻辑,也是运维策略。设计时要考虑缓存雪崩、内存淘汰、主从复制、大 key 阻塞这些看起来离业务很远、实际上随时会反噬业务的底层机制。
第三,分布式环境里,过期时间的可靠性依赖时钟和服务协作。同一套代码在不同机器上表现不同,优先用相对时间,确保 NTP 同步,并对“提前过期”“延迟过期”都有容错。
如果你现在正要给 Redis key 设置过期时间,先别急着手写命令,花十分钟想清楚这几个问题:这个 key 生命周期应该多长?到点后删除会不会造成缓存雪崩?如果 Redis 内存满了,它会不会被提前淘汰?分布式锁场景下,锁过期了业务没跑完怎么办?想清楚这些,你再回来写 SETEX,心里会踏实很多。