1. 先搞清楚:为什么要给 Redis 数据加过期时间
我刚开始用 Redis 的时候,其实没太把过期时间当回事,觉得无非就是存个值、读个值,顶多再清一清。直到有一次线上服务半夜告警,内存快被打满,我上去一查,堆了上千万个没有任何业务价值的临时 key,那一刻才意识到:expire 不是锦上添花,是保命用的。
从业务视角看,Redis 里很多数据本质上都是"临时存在":
- 登录验证码、短信验证码,有效期 5 分钟,过期就扔;
- 会话 token、临时授权票据,最多存几小时;
- 一个活动页的配置,拉取后想在 1 小时内复用,过了就不许用旧的;
- 秒杀接口的防重标记,30 秒内不许同一个用户重复点;
- 分布式锁,锁必须有个持有上限,否则客户端崩了锁就永远解不开。
这些场景都有一个共同点:数据只在某个时间段内有效。如果只靠程序里手动去删,删漏了、忘记删了,Redis 里的垃圾数据只会越积越多,内存迟早被拖垮。所以,让"数据自己消亡"才是正确的路子——这就是过期时间存在的意义。
从运维角度看,给 key 设置过期时间还有一个隐性作用:它是数据生命周期管理的一部分。一个完整的 Redis 使用规范,通常要求所有非永久业务 key 都必须设置 TTL,并且要有合理的上限。因为 Redis 本身是纯内存数据库,内存不像磁盘可以随便堆,它是有物理成本上限的。如果一个 key 永远不删除,那它占用的内存就永远不会还给你,等于给你整个集群背了一个隐性包袱。
另一个容易被忽略的原因:数据新鲜度。缓存最怕的不是缓存没有,而是缓存里有脏数据,却没人知道它是脏的。如果你缓存了一份商品库存,不设置过期时间,那数据库里库存已经扣了 20 件,Redis 还告诉你还剩 50 件,这就是事故。过期时间本质上是给数据定了"保质期",过了保质期强制作废,强制重新去源头拉新数据,这是保障一致性的兜底手段。
所以写这本书的第一节,先给大家定个调:不是所有数据都该永驻内存,凡是只在一段时间内有效的数据,都要设置过期时间。这个习惯养成之后,你的 Redis 内存会稳定很多,出问题的概率也会小很多。
2. 两种核心路径:建 key 时设置 vs 建完之后再设置
2.1 创建 key 时直接带上过期时间
如果你在业务代码里写的是这种套路:
SET login_code "8848" EXPIRE login_code 300第一步存值,第二步单独设置过期时间,逻辑上当然没错,但是中间间隔的那几毫秒里,如果服务挂了,这个 key 就永远没有过期时间了,变成永久 key。更稳妥的做法是把两步合成一步,用SET命令的扩展参数:
SET login_code "8848" EX 300这条命令的语义是:写入login_code,值等于"8848",同时在当前时间基础上加上 300 秒作为过期时间,写入和设置过期是原子的,不存在中间态。
SET的过期参数家族其实有四个:
EX seconds:以秒为单位设置过期时间;PX milliseconds:以毫秒为单位设置过期时间;EXAT timestamp:设置一个绝对的 Unix 秒级时间戳到期;PXAT timestamp:设置一个绝对的 Unix 毫秒级时间戳到期。
日常用的最多的是EX和PX,EXAT和PXAT适合那种"固定到某个具体时间点失效"的需求,比如"这个优惠券今晚 0 点作废"。还有一个小细节:SET命令在 Redis 2.6.12 版本之前其实不支持这些参数,不过现在主流的 Redis 版本早就超过这个底线了,所以可以放心用。在SET里,还有幂等的场景是用NX参数,即"只有当这个 key 不存在时才写入",这套组合我们后面讲分布式锁会再提到。
2.2 给已有 key 单独设置过期时间
如果你拿到的数据是从别的地方写入的,或者你需要动态调整一个已有 key 的生存时长,那就得用专门处理过期时间的命令族。这一族命令包括:
EXPIRE key seconds:按秒设置;PEXPIRE key milliseconds:按毫秒设置;EXPIREAT key timestamp:设一个绝对时间戳(秒);PEXPIREAT key timestamp:设一个绝对时间戳(毫秒)。
这四个命令的核心逻辑完全一致,只是时间单位不同。用法上,它们可以覆盖已有 key 的过期时间,也可以给一个原本永不过期的 key 加上一个 TTL。它们的返回值也很有意思:
- 返回
1,说明设置成功,key 的过期时间已经生效; - 返回
0,说明这个 key 根本不存在,或者你给的时间参数不合法(比如传了负数),设置失败。
有个很实用的记忆技巧:如果你想快速设置一个 "10 秒后过期",写成EXPIRE somekey 10;如果你想设置成"到某个具体时间点过期",就先用date命令算出对应的时间戳,再用EXPIREAT。Redis 里时间戳统一是 Unix 时间戳,这点和很多系统 API 一致。
在很多语言客户端里,比如 Java 的Jedis或者Lettuce,对应的 API 也封装了这些命令,大家在业务层可以直接调用,不需要去裸敲命令行。
2.3 一个很多人忽视的坑:非字符串类型过期的是整个 key
这句话要划重点:Redis 的过期时间作用在 key 上,不是作用在 key 里面的元素上。
举个例子,你有一个hash,里面存了用户购物车信息,每个字段是一个商品;又比如你有一个list,里面存了一串消息记录。你对这个 key 执行了EXPIRE cart 300,那么 300 秒后,整个 key 连同里面所有的字段和值,全部被 Redis 删除,不存在说"哪个字段先过期,哪个字段后过期"。
这听起来很简单,但实际开发里很多人会踩坑:想把 hash 里某个旧字段自动清理掉,于是对这个字段对应的 key 再加一个独立的 key 去控制时间,结果 hash 本身越积越大。Redis 的过期机制,是针对整个 key 维度的,不是像数据库的行级过期。如果你需要让某个聚合结构里的元素单独过期,那么通常有两条路:
- 把每个元素拆成独立的 key,各自设置 TTL;
- 用程序做定时扫描清理。
前者思路更符合 Redis 的哲学——一个 key 就是一个独立的小对象。比如你要存"文章 A 的评论 ID 列表",同时又想让每条评论 10 分钟后过期,那就别用一个大 list 去存,而是把每条评论单独做 key。这个取舍,直接影响你后面内存的利用率和清理的复杂度。
2.4 TTL 命令的返回值:三个状态的含义
设置好过期时间之后,你总得知道它还剩多久吧?TTL命令就是干这个的。它的用法简单:
TTL somekey返回值根据情况分三种:
-2:这个 key 不存在,或者它已经过期被删掉了;-1:这个 key 存在,但没有设置过期时间,也就是永久 key;大于等于 0:剩余存活秒数。
对应还有一个PTTL,返回的是剩余毫秒数。这两个命令是排查问题的利器。比如你怀疑某个 key 是不是忘了设置过期时间,敲一下TTL,返回-1就说明中招了;如果你发现某个 key 的 TTL 一直不变,那要警惕是否有人反复给它续期。
我个人的习惯是:每次部署完一个缓存相关的功能,都会在测试环境用TTL抽查几个 key,验证一下过期时间是否真的生效。别看这只是个不起眼的小检查,它曾经帮我发现过"错把 600 秒写成了 6000 秒"这种低级但致命的配置错误。
3. 深入原理:Redis 到底是怎么把过期 key 清掉的
3.1 惰性删除:只要没人访问,就先留着
很多人以为 Redis 有个后台线程,像闹钟一样一到点就把过期的 key 全部清除。这是个常见的误区。实际上 Redis 采用的是惰性删除 + 定期删除的组合策略,而且默认配置下还有一个内存淘汰作为兜底。
惰性删除的机制,通俗讲就是"你用到的这一刻才给你判断死活"。当你请求一个 key 时,Redis 会先去检查这个 key 是否设置了过期时间,如果设置过并且判断当前时间已经超过了过期时间戳,那这个 key 就当场被判定为已过期,立刻删除,然后对你返回"查无此 key"。
这种策略的好处是对 CPU 的消耗很小,因为你只检查你访问的那些 key,不会去扫描全库;缺点是如果大量过期的 key 一直没人访问,它们就占着内存不走,等内存不够用的时候再统一爆发清理。
3.2 定期删除:一种妥协的平衡方案
为了不让惰性删除造成"过期 key 堆积成山",Redis 还搞了一个定期删除机制:每 100 毫秒左右,随机抽取一部分设置了过期时间的 key,检查它们是否过期,过期就删掉。注意"随机抽取"这四个字,它不是全量扫描,否则 Redis 的 CPU 早就扛不住了。
抽多少、怎么抽,这个比例是 Redis 内部根据 server 状态动态算的,不用你去调。定期删除的目的就是在 CPU 消耗和内存清理之间找一个平衡点:既不给 CPU 太大压力,又能定期回收一部分内存。
3.3 内存淘汰策略:最后的兜底方案
再往下走,如果惰性删除和定期删除都没能顶住内存压力,内存满了怎么办?答案是内存淘汰策略,也就是maxmemory-policy。这个配置决定了 Redis 在内存达到上限时,按什么规则强制挤掉一些 key 来腾空间。
常见的几个策略:
volatile-lru:从已设置过期时间的 key 里,淘汰最近最少使用的;allkeys-lru:从所有 key 里,淘汰最近最少使用的;volatile-random:从已设置过期时间的 key 里随机淘汰;allkeys-random:随便淘汰;volatile-ttl:从已设置过期时间的 key 里,挑剩余存活时间最短的优先淘汰;noeviction:内存满了直接报错,不淘汰任何 key(默认策略,但在生产环境通常不建议)。
这里给一个重要的实战建议:如果你给大部分 key 都设置了过期时间,volatile-lru是比较推荐的策略,因为它优先保证有 TTL 的数据能被公平处理,而没有设置过期时间的"重要数据"不容易被误清。如果你是作为纯缓存使用、允许任何 key 被清掉,allkeys-lru也很常见,因为它淘汰选择面更大,命中率往往更高。
3.4 大 key 过期的时候,真的会卡住吗
这是很多人关心的问题。一个 key 如果存了几百 MB 的数据,当确定它已过期需要删除时,删除动作本身是要消耗资源的。如果这个删除是同步的,那 Redis 主线程就可能会阻塞一小段时间,期间所有请求都排队,表现就是"卡了一下"。
好在 Redis 4.0 以后引入了异步删除的机制,通过unlink命令以及对应的lazyfree配置,可以把大 key 的释放放到后台线程里去做。特别是对于过期删除的行为,有个配置叫lazyfree-lazy-expire,默认是no,也就是说默认情况下过期 key 的删除仍然是同步的。如果你的业务里存在大 hash、大 list、大 set 这类超大 key,并且会被设置过期时间,建议把lazyfree-lazy-expire设成yes,让过期删除变成异步,能显著降低主线程阻塞风险。
不要问我怎么知道的,线上大zset一过期,整个服务抖动几秒钟,那种感觉谁经历谁知道。
3.5 关于懒删除的一些补充
在 Redis 的演进中,很多细节在逐步改进。比如 Redis 7.0 之后,针对 expire 相关的异步释放机制又做了进一步优化。但不管怎样,核心原理不变:主线程尽量只做轻量级的判断,重活交给后台线程。如果你在做集群规划和容量设计,记得把大型缓存对象的过期策略一并考虑进去,不要只盯着命令本身。
4. 业务实战:过期时间在几个典型场景里的落地
4.1 验证码 / 短信验证码
验证码这个场景,核心需求就是两个:不能重放(同一验证码只能成功消费一次)、必须在一定时间内有效。代码上最常见的做法是:
SET sms_code:13800138000 "246810" EX 300存 300 秒,用户输入之后立刻GET,校验通过马上DEL。这里有个经验教训:千万不要只设置过期时间,而不设置"一次性"逻辑,否则验证码可能会被多次使用。就算过期时间给了 5 分钟,攻击者如果 1 分钟之内拿到验证码并反复重复使用,也是有风险的。所以正确姿势是:过期时间是兜底,业务校验靠主动删除。
如果用户频繁请求发送验证码,还要额外加一个"防刷"标志位。比如用一个sms_send_limit:13800138000key,设置为 60 秒过期,存在就拦截请求。这种防刷 key 往往也需要精确的过期时间控制。
4.2 分布式锁
Redis 实现分布式锁的经典套路是:
SET lock:order_123 "uuid-xxxx" NX EX 30NX表示只有这个 key 不存在时才写入,也就是抢锁;EX 30表示锁在 30 秒后自动释放,防止持锁客户端崩溃后死锁。拿到锁的客户端在业务处理完后,应该用DEL主动释放。这里有个经典陷阱:锁的过期时间设得比业务执行时间短,导致业务还没做完锁自己先释放了,另一个客户端又拿到了锁,两个客户端同时操作共享资源,这就出大问题了。
所以使用分布式锁时,除了设置合理的过期时间,还应该引入"续期"机制——比如看门狗线程在原锁快过期时自动续期,保证业务执行期间锁一直有效。而且在删除锁时,必须校验"这个锁是不是自己当初抢到的那个",一般用 Lua 脚本比较 value,相同才删,避免误删别人的锁。
4.3 限流场景
限流的本质是"单位时间内最多允许多少次请求"。Redis 里可以这样设计:用户每一次操作,对某个 key 做INCR,如果是第一次操作,同时给它设置一个窗口期的过期时间,比如:
MULTI INCR rate_limit:user_123 EXPIRE rate_limit:user_123 60 EXEC这个套路有一个经典 bug:如果第 1 次 INCR 是 1,执行了 EXPIRE;然后第 10 次请求时,又调了一次 INCR,虽然 key 已经存在且过期时间没有重置,但如果你每次请求都执行EXPIRE,就会把过期时间不断往后推,导致窗口期被无限延长。所以正确做法是:只有第一次 INCR 时才设置过期时间,后续请求只做INCR。常见的标准写法是用事务或者 Lua 脚本保证"如果计数器不存在,则初始化为 0 并设置过期时间"。
限流和过期时间的关系非常微妙,稍不留神就会把"固定窗口"做成"滑动窗口"的无效版本。
4.4 缓存穿透、击穿、雪崩里的过期时间调校
这三个概念是缓存系统中的高频名词,每一个都和过期时间有直接关系。
缓存穿透:查了一个根本不存在的数据,每次请求都打到数据库。解决思路之一是把空结果也缓存下来,并且给它设一个很短的过期时间,比如 30~60 秒。这样同一个"不存在的数据"短时间内不会反复穿透到数据库。过期时间要短,因为空结果的时效性本身不长,业务上可能很快就产生了新数据。
缓存击穿:某个热点 key 过期的一瞬间,有大量请求同时涌向数据库重新加载。解决办法是互斥锁,或者让这个 key 的过期时间设置得尽量不集中、不统一,例如给过期时间加一个随机偏移量。比如原本 300 秒,偏成 300 + random(0, 30) 秒。
缓存雪崩:大量 key 在同一时间段集体过期,导致请求全部打到数据库。解决办法和击穿类似,让过期时间在基础值上做随机抖动,把集体过期的峰值打散,DB 的压力就能明显缓和。具体做法很简单:
SET cache:config "value" EX (300 + random(0, 60))或者统一用EXPIRE再叠加一个随机数。不要小看这个抖动,在几十万 key 的规模下,它能让数据库的峰值 QPS 直接降一个量级。
4.5 一个实战小技巧:用 GETEX 同时取值和续期
Redis 6.2 版本之后,GETEX命令能在取值的同时,重新设置一个过期时间。这特别适合"滑窗续期"的场景:比如用户访问了购物车,你希望购物车里的数据再延长 30 分钟有效期,那么直接:
GETEX cart:user_123 EX 1800一条命令就把值取出来、把过期时间续上了,不用先GET再EXPIRE两条命令。少一次网络往返,效率和安全都更高。
在实际工程里,还有很多类似的复合命令,比如SET带过期参数、GETEX带过期参数、INCR加过期等等。掌握这些复合命令,能让代码更优雅,也能避免"先做 A 再做 B"的中间态。
5. 我踩过的坑:过期时间的常见问题与排查手册
5.1 坑一:SET 覆盖写入导致过期时间被清掉
这是新手最容易踩的坑。你原来设置了一个 key,给它设了 300 秒过期;过了一会,你又用普通的SET key value去覆盖它的值。这里有个关键细节:** 如果SET没有带任何过期参数,覆盖之后这个 key 就变成永久的了**,原来的过期时间直接丢失。
这个问题的隐蔽性在于:你不查 TTL 根本发现不了。等到某个半夜内存告警,你一个个排查,才会发现这些本该过期的 key 全变成了"永久居民"。
避免的方法很简单:所有覆盖 key 值的操作,要么使用带过期参数的SET,要么在覆盖之后再单独执行一次EXPIRE。另外在审查代码时,全局搜索所有SET命令,凡是用于业务缓存的,都要确认其过期参数。
5.2 坑二:RENAME 命令会让过期时间被覆盖
RENAME命令也会影响过期时间。如果目标 key 原本不存在,那重命名后的 key 会保留源 key 的 TTL;但如果目标 key 已经存在,重命名后目标 key 的过期时间会被源 key 的覆盖,而原本目标 key 的过期时间直接丢失。
这种情况一般出现在枚举、刷数据、临时切换数据源等操作中。如果你们代码里有RENAME,记得检查它会不会破坏其他数据的过期策略。最好的习惯还是:重命名之后的 key,重新EXPIRE一下,确保设定符合预期。
5.3 坑三:大 key 过期导致主线程阻塞
前面提过,如果一个大 key 被判定过期,同步删除会阻塞 Redis 主线程。我在一次线上事故中就遇到过:一个购物车 hash,里面塞了几十万个字段,过期时间到了,Redis 在做删除时瞬间阻塞了 1 秒多,整个集群请求全部超时。
排查手法是看redis.log里的延迟告警,以及SLOWLOG里是否有expire相关的慢命令。解决方向就是开启lazyfree-lazy-expire yes,并把大 key 拆小,别让单个 key 无限膨胀。
5.4 坑四:服务器时间漂移导致过期时间不准确
EXPIREAT和PXAT依赖服务器的时间戳。如果你们 Redis 集群的服务器系统时间不同步,个别机器快了或慢了几秒,在分布式锁、限流这类对时间敏感的场景里,就可能出现"锁提前失效"或者"过期时间被无限拉长"的怪现象。
排查方法很简单:在所有 Redis 节点上统一跑date命令看时间差。生产环境一定要配置好网络时间同步服务,这个看似基础的东西,一旦出问题排查起来非常烧脑。
5.5 坑五:大量 key 同时过期引发的雪崩
这不是一个 bug,而是设计疏忽。比如系统启动时统一往 Redis 里写几万个 key,都设成"当天有效",到了午夜零点,上万 key 同秒过期,数据库瞬间被一波读写打懵。这就是典型的缓存雪崩。
解决办法前面已经说了,加随机过期时间、做分组分批缓存预热。另外可以观察监控里过期 key 的分布情况,如果发现有"波峰状"的集中过期曲线,就要及时调整过期时间策略。
5.6 排查工具与命令速查
最后整理一个排查过期问题时常用到的命令表,方便大家按图索骥:
| 需求 | 命令 | 返回值/说明 |
|---|---|---|
| 查看 key 剩余秒数 | TTL key | -2 不存在,-1 永久,>=0 剩余秒数 |
| 查看 key 剩余毫秒数 | PTTL key | 同上,单位毫秒 |
| 设置秒级过期 | EXPIRE key seconds | 返回 1 成功,0 则 key 不存在或参数非法 |
| 设置毫秒级过期 | PEXPIRE key millis | 同上 |
| 设置到某个时间点过期 | EXPIREAT key timestamp | timestamp 为 Unix 秒级时间戳 |
| 创建时直接设过期 | SET key val EX seconds | 原子操作 |
| 取并续期 | GETEX key EX seconds | 6.2+ 版本支持 |
| 取消过期时间 | PERSIST key | 返回 1 表示取消成功,0 表示原本没有过期时间 |
| 查看内存淘汰策略 | CONFIG GET maxmemory-policy | 返回当前策略 |
| 查看异步过期配置 | CONFIG GET lazyfree-lazy-expire | 返回 yes/no |
5.7 监控和预防建议
除了会排查,更重要的是预防。强调几个我养成习惯的做法:
一是所有缓存 key 的 TTL 设计要在需求评审阶段就确定下来,不要上线之后才补。补 TTL 意味着要走一次发布流程,而且很容易遗漏某些分支。
二是线上环境定期做 key 扫描,用SCAN配合TTL找出那些"永久 key",评估它们是否应该设置过期时间。Redis 的SCAN是增量迭代,别用KEYS *,否则主线程会被拉爆。
三是监控体系里加一个"过期 key 删除量"的指标,观察是否有瞬间激增。如果有,大概率是某批 key 同时过期,这时候要回头看业务代码是否设置了过于集中的过期时间。
四是在代码层面做统一封装,给缓存中间件配置一个默认 TTL 和统一续期方法,避免每个人各写各的,规则不一致。
写在最后
从让我交了不少学费的"大 key 过期抖动",到后来逐步摸清的惰性删除、定期删除、内存淘汰三者关系,再到分布式锁、限流、缓存雪崩这些场景里过期时间的精妙设计,我最大的体会是:Redis 里的每一个参数都不是孤立存在的,过期时间表面上只是一个数值,背后却牵动着内存成本、访问延迟、数据一致性和系统稳定性。
在实践里,我会给自己定几条铁律:新增缓存必须默认带上过期时间;覆盖值时必须考虑是否要重置 TTL;大 key 必须拆小并且开启异步淘汰;所有过期时间宁可设短一点,也不要在该失效的时候还赖着不走。Redis 是内存里的生意,过期时间就是这门生意的止损线,你给它一份主动权,它还你一份安稳。
希望这篇关于"Redis 设置过期时间"的实操梳理能帮你少踩几个坑。如果你也在项目中遇到过什么诡异的过期时间问题,不妨按着上面的排查思路再走一遍,大概率能在 TTL 的返回值里找到答案。