1. 过期时间的基础用法:别只盯着EXPIRE
1.1 基础命令族:8个命令一次理清
先说结论:Redis里设置过期时间,最常用的确实是EXPIRE key seconds,但这只是冰山一角。整个命令族一共8个成员,各自适用不同场景,我按使用频率从高到低逐个说。
EXPIRE key seconds是最基础的,单位是秒。比如EXPIRE token:123 300,意思是让这个key在300秒后失效。与之对应的是PEXPIRE key milliseconds,单位是毫秒,适合对时间精度要求高的场景,比如秒杀接口的防重复提交,毫秒级过期能更精确控制。
EXPIREAT key timestamp和PEXPIREAT key milliseconds-timestamp接受的是绝对Unix时间戳,不是相对时间。什么叫绝对时间?就是“在2024年12月31日23:59:59过期”,而EXPIRE是“从现在开始30秒后过期”。实际业务中绝对时间用得少,一般只在定时任务、活动倒计时这种明确知道截止时间的场景才用。需要注意,绝对时间依赖服务器时钟,如果机器时间被NTP校正过,可能有偏差,后面我会专门讲这个坑。
除了设置,还有三个常用的查询和取消命令。TTL key返回剩余秒数,PTTL key返回剩余毫秒数,返回值有个容易混淆的点:
- 返回
-1表示key存在但没有设置过期时间,也就是永不过期 - 返回
-2表示key已经不存在了 - 返回其他正整数才是真实的剩余时间
最后是PERSIST key,用来移除过期时间,让key变成永不过期。这个命令在排查问题时很有用,比如线上发现某个key过期时间设错了,先PERSIST去掉,再重新设置正确的。
还有一个很多人忽略的SETEX key seconds value,它把SET和EXPIRE合成一步完成,是原子操作,不会出现“值写进去了但过期时间没设置上”这种中间状态。这个命令在处理验证码、临时Token这类场景时特别好用,因为代码写起来短,也不容易漏掉过期时间。
1.2 变体命令与实战选型
除了上面那些,Redis 6.2版本引入了GETEX key,这个命令的设计思路很巧妙:它可以在获取一个key值的同时,顺便设置或取消它的过期时间。比如GETEX cache:user:1001 EX 60,既把值取回来了,又把过期时间重置成60秒,一步到位。这种“读取即续期”的操作,在实现滑动过期时间(比如用户连续操作时自动延长登录态)时非常方便,不用先GET再EXPIRE两步走,省了一次网络往返。
还有一个命令组合,是分布式锁场景的核心,我后面会展开讲,这里先提一下:SET key value NX EX seconds。这条命令把“不存在才设置”和“30秒后过期”两件事合并成一个原子操作,是现在实现分布式锁的标准姿势。在Redis 2.6.12版本之前没有这个能力,大家只能用SETNX加EXPIRE两条命令配合,中间一旦进程崩溃,锁就永远不释放了,这也是很多老项目分布式锁有坑的根源。
选型上我个人的习惯是:
- 普通的缓存数据过期,用
SETEX或SET key value EX seconds - 需要读取并续期的,用
GETEX - 分布式锁,用
SET key value NX EX seconds - 需要精确到毫秒的,用
PEXPIRE相关命令
1.3 常见误区:SET会清掉过期时间
这一节是重点,我在实际代码评审里反复提醒过:对一个已经设置过过期时间的String类型key执行SET命令,会把这个key的过期时间直接清掉,变成永不过期。原理是SET属于全量覆盖操作,Redis内部的setKey函数会把这个key的元信息一并重置,过期时间自然也没了。
举个例子,用户信息缓存user:1001设了10分钟过期,某次代码里执行了SET user:1001 "{\"name\":\"张三\"}"去更新用户昵称,这个key就变成了永不过期。一次两次没关系,但高频更新的key被这么搞几次,内存就悄悄涨起来了,而且你很难排查到。
还有两个类似的坑:GETSET(设置新值并返回旧值)、APPEND(追加字符串)这些操作,并不会清除过期时间,只有SET、GETSET这类“覆盖式”写操作才会。而像INCR、HSET、LPUSH这种对value的修改操作,不会影响过期时间——也就是说,如果一个Hash类型的key设了过期时间,你往里加字段并不会续期,过期时间还是按原计划走。
另外一个常见的认知偏差,是对不存在的key执行EXPIRE会返回0(表示失败),而不是报错。很多人以为EXPILE一个不存在的key会抛异常,实际上Redis只是安静地返回0,什么都不会发生。
2. 过期Key是怎么被删除的:三种机制与底层细节
2.1 惰性删除:平时不管,访问时才检查
Redis删除过期key的第一道机制叫惰性删除,英文是lazy deletion。思路很简单:一个key过期了,但只要没人访问它,Redis就假装它不存在,不主动去物理删除。当客户端来读这个key时,Redis内部会调用expireIfNeeded函数检查一下,发现已经过期,就先把它删掉,然后返回nil。
这个机制的优点是非常省CPU,因为每个key的删除成本只会在被访问时产生,不会平白多出一次扫描。但缺点也明显:一个过期key如果一直没人读,它就会一直占着内存。如果你的业务里大量key“写入后再也不访问”,那这些key全都会变成内存垃圾。这也是为什么要引入下面的定期删除。
2.2 定期删除:Redis的“巡逻队”
为了解决惰性删除的内存泄漏问题,Redis在后台跑了一个定时任务,默认每100毫秒执行一次(这个频率由配置项hz控制,hz=10表示每秒执行10次)。这个任务会做两件事:
- 从所有设置了过期时间的key里随机抽一批
- 检查这批key里过期的比例,如果超过25%,就继续再抽一批删除,直到比例低于25%,或者这次清理的总耗时超过了25毫秒
注意几个细节:它是随机抽样,不是遍历所有key,因为Redis是单线程模型,遍历全量key会阻塞主线程,代价太高。每次清理的耗时上限是25毫秒,这个时间窗口对绝大多数业务来说是可以接受的——一次普通的Redis命令执行耗时才不到1毫秒,25毫秒意味着最多可能让十几个请求排队,但这是最坏情况下的兜底,正常很少触发。
2.3 内存淘汰策略:兜底方案
就算有惰性删除和定期删除,如果业务代码出了问题,比如大量key忘了设过期时间,内存一样会被撑爆。这时候就需要内存淘汰策略兜底。在Redis的配置文件里,有两个关键参数:maxmemory设定Redis能用的最大内存,maxmemory-policy设定内存满了之后怎么办。
常见的策略有这么几种:
| 策略 | 作用范围 | 淘汰规则 |
|---|---|---|
| noeviction | - | 不淘汰,直接返回OOM错误 |
| allkeys-lru | 所有key | 淘汰最近最少使用的key |
| volatile-lru | 仅设置了过期时间的key | 淘汰最近最少使用的key |
| allkeys-lfu | 所有key | 淘汰访问频率最低的key |
| volatile-lfu | 仅设置了过期时间的key | 淘汰访问频率最低的key |
| volatile-ttl | 仅设置了过期时间的key | 优先淘汰剩余存活时间最短的key |
看到volatile-开头的策略了吗?它们只从“设置了过期时间”的key里淘汰。如果业务里所有key都没设过期时间,那volatile-lru策略等同于noeviction,内存满了照样OOM。所以我一直跟团队强调:给key设过期时间不只是为了数据一致性,它还直接影响内存淘汰策略能不能正常发挥作用。
2.4 为什么设计成“惰性+定期”组合
很多初学者会问:为什么不搞一个后台线程,把所有过期key都扫一遍删掉?原因很简单:假设有1000万个key,每秒完整扫一遍,哪怕每次检查一个key只花1微秒,一轮下来就是10秒,这还是在单线程模型下,期间所有读写请求都得排队,完全不可接受。
所以Redis选择了“懒汉+随机抽查”的组合拳:惰性删除保证“访问时一定是有效的”,定期删除保证“不访问的过期key也不会永远占着内存”。两者配合,既控制了CPU开销,又把过期key的内存占用控制在一个可接受的范围。这套设计思路,本质上是在CPU和内存之间找了平衡点,理解了这一点,很多Redis的淘汰行为就能解释通了。
3. 分布式锁场景下的过期时间:别让锁把你坑了
3.1 为什么分布式锁必须配过期时间
用Redis实现分布式锁,最经典的命令就是SET lock_key unique_value NX EX 30。这里面的EX 30(30秒过期)不是可选项,而是必选项。原因很简单:如果锁没有过期时间,持有锁的客户端一旦宕机或者网络异常断开,它永远不会主动释放锁,其他客户端就只能干等,整个系统就“锁死”了。过期时间就是一道兜底的保险,保证最坏情况下锁也能自动释放,让系统有机会自愈。
3.2 过期时间怎么定:一个经验公式
那过期时间设多少才合理?这是我在团队里被问得最多的一个问题。设短了,业务还没执行完锁就释放了,其他线程趁乱进来,并发问题依旧;设长了,持有锁的节点真的宕机时,其他节点要白白等很久才能抢到锁。
我的经验做法分两步。第一步,压测出业务在最坏情况下的执行时间。比如一个下单接口,正常100毫秒,在慢查询、GC停顿、网络抖动叠加时压测到500毫秒。第二步,锁的过期时间取这个最坏耗时的2倍左右,也就是1秒。这个“2倍”是经验值,核心思想是留足缓冲,宁可让锁死的时间长一点,也不能让锁提前释放。
还有一个更精细的公式:如果业务平均耗时是T,锁过期时间设为T * 5 + 1秒,5倍平均耗时基本能覆盖绝大多数波动,再加1秒兜底。但不管用哪个公式,锁内的业务代码一定要尽量精简,不要让大查询、IO操作长时间卡在锁里面,否则过期时间再长也救不了你。
3.3 续期机制:看门狗原理
分布式锁的过期时间还有个很烦人的问题:业务执行时间是不可控的,你设了1秒过期,但业务这次跑了2秒怎么办?业界成熟的解法是“自动续期”,Java生态里Redisson的看门狗(Watchdog)机制就是干这个的。
Redisson的默认实现是这样的:加锁成功后,锁的leaseTime默认是30秒,同时后台起一个定时任务,每隔10秒(也就是leaseTime的三分之一)检查一次,如果锁还在,就用Lua脚本把过期时间重置回30秒。业务正常执行完,释放锁时顺便关掉定时任务;业务所在节点宕机了,后台线程也跟着没了,锁最多30秒后自动过期释放。
这套机制解决的核心矛盾是:过期时间既不能太长(宕机时要尽快释放),又不能太短(业务没跑完不能提前释放)。如果你用的不是Redisson,也可以自己实现:加锁后启动一个定时任务,每leaseTime/3执行一次EXPIRE续期,业务结束后取消定时任务并释放锁。实现不复杂,但这里有一个关键细节:续期前要先确认锁还是自己的,否则你续的是别人的锁。
3.4 误删别人的锁:value必须唯一
生产环境里最常见的分布式锁事故之一,就是误删锁。场景是这样的:A线程拿到锁后,业务执行超过了锁的过期时间,锁自动释放了。B线程拿到锁开始执行。A线程终于执行完了,走释放锁逻辑——如果它写的是简单的DEL lock_key,那它把B线程的锁给删了。这时候C线程一看锁没了,也抢到锁,于是B和C同时执行,分布式锁形同虚设。
正确的释放姿势是:value必须用唯一标识(UUID或者业务单号),释放前先GET一下,对比value是不是自己的,是才DEL。这个“对比+删除”必须是一个原子操作,不能用两条独立的命令,要用Lua脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这段脚本的意思是:如果当前key的值等于我加锁时写入的唯一标识,才执行删除,否则不做任何操作。这样就算A线程在B线程持有锁时来释放,也会因为value不匹配而失败,不会误删。
4. 缓存治理与过期时间的“玄学”:设置多大才合理
4.1 过期时间设计的三个原则
把过期时间用好,不只是会敲命令那么简单,它直接关系到缓存系统能不能健康运行。我在实际项目里总结出三个原则。
第一,能短则短。过期时间越短,数据一致性越好,但缓存的命中率会下降,DB压力增加。所以“短”的前提是满足业务容忍度。比如用户会话,业务上允许5分钟无操作后失效,就设5分钟;商品库存,必须秒级一致,那就别用Redis缓存,或者用很短的过期时间加实时扣减。
第二,加随机扰动。这是防雪崩的关键。假设某个时间点有1000个商品做活动,都从0点开始缓存,过期时间都设为1小时,那1小时后这1000个缓存同时失效,一瞬间所有请求都打到数据库上,DB大概率扛不住。正确做法是给每个key加一个随机偏移量,比如TTL设为3600 + random(0, 300)秒,让它们“错峰过期”。
第三,区分数据类型。String、Hash、List的过期行为一样,都是整key过期,但要注意:Redis里的Hash、Set、ZSet,里面的元素不能单独设置过期时间,只能整体给key设置。有些人误以为可以对Hash里的某个field单独设TTL,这是做不到的,这个认知在很多业务设计里会踩坑。
4.2 缓存穿透、击穿、雪崩与过期时间的关系
这三个词是Redis面试的常客,也是缓存治理的三大难题,它们和过期时间的关系值得单独捋一遍。
穿透,查一个不存在的key,缓存和数据库都没有,请求绕过缓存直接打在DB上。过期时间本身解决不了穿透,但可以用两种手段配合:布隆过滤器提前拦截不存在的ID;或者对空结果也做缓存,就是给一个空值设60秒过期,短时间内相同的查询直接命中空缓存。
击穿,某个热点key正好在过期的那一刻,大量请求同时涌入。比如一个爆款商品的详情页,平时缓存扛住99%的流量,结果缓存在0点过期了,正好上万用户同时刷新,DB瞬间被打爆。两个主流解法:互斥锁,让第一个请求去查DB重建缓存,其他请求等一等;或者叫“逻辑过期”的方案,物理上不给key设过期时间,但把过期时间写进value里,异步去刷新。
雪崩,大量key在同一时间集体过期。前面说的加随机扰动就是最简单有效的预防手段,原理上是把集中压力打散成持续一段时间的均匀压力。
4.3 分页查询慢怎么用Redis优化:一个实际案例
热搜词里有个“分页查询慢怎么用redis优化”,我正好以此为例说明过期时间怎么在真实场景里落地。假设一个订单列表接口,每次请求都要执行SELECT COUNT(*)和SELECT * FROM orders WHERE user_id=? ORDER BY id DESC LIMIT ?,?,数据量一上来,扫描和排序都很慢,接口耗时可能到几百毫秒甚至一秒以上。
第一次优化可以这样做:
- 缓存分页结果,key设计为
order:page:{userId}:{pageNo}:{pageSize},value是JSON格式的订单列表,过期时间60秒加随机10秒 - 缓存总数,key设计为
order:count:{userId},过期时间60秒 - 用户下单成功后,删除该用户的所有分页缓存键
这个方案实现简单,响应能降到几毫秒,但问题也很明显:分页缓存的一致性很难维护。用户下了新单,不只是某个特定页面的缓存失效,所有和这个用户相关的分页缓存都过期了才最合理,但这样一级缓存键很多,直接删比遍历删成本低的做法是让它们自然过期,所以TTL不能设太长。
更优一点的方案是用ZSET。把订单ID按时间戳作为score存进ZSET,分页时用ZRANGEBYSCORE order:user:1001 0 +∞ LIMIT 0 20取出ID集合,再批量查订单详情并各自缓存。订单详情key设30分钟过期,ZSET本身设较长过期时间,比如90天,定期用ZREMRANGEBYSCORE清理历史订单ID。这个方案的优势是分页排序由Redis内存完成,速度极快,但需要注意ZSET里元素多时内存占用会上升,过期时间要配合清理策略一起设计。
5. 生产环境里的坑:过期时间引发的血泪教训
5.1 大Key过期导致阻塞
这是我在生产环境遇到的最隐蔽的坑之一。一个list里存了1000万条消息,整体设置了5分钟过期,到期后Redis主线程删除这个key时,需要遍历整个list结构,这个操作会阻塞主线程好几秒,期间所有读写请求全部卡住。更麻烦的是,你能看到的现象只是“Redis突然卡了一下”,监控延迟飙高,但很多人第一时间不会想到是过期key删除引起的。
尤其要注意的是:无论是惰性删除还是定期删除,Redis删除过期key用的都是同步删除,不是异步的。Redis 4.0加了UNLINK命令可以异步删除一个大key,但过期key的自动删除并不走UNLINK路径,主线程该卡还是卡。
解决方案要从源头做起:大key能拆就拆。比如把一个大list拆成按时间分片的小list,每个小list单独设过期时间;数据量大时还可以设计成分批写入、分批过期的策略,避免所有数据在同一时刻变成一个大块删除的负担。
5.2 主从复制中的过期Key问题
Redis主从架构下,过期key的处理有一个容易忽略的逻辑:从库不会主动删除过期key,也不会参与定期删除,它只能等主库把key过期后发送DEL命令过来同步。
但Redis 3.2之前有个漏洞:从库在读到逻辑上已过期但物理上还没被主库DEL通知的key时,会把过期的数据返回给客户端。3.2版本之后修复了这个问题,从库读请求时会先做一次逻辑过期判断,过期就返回nil,但物理上仍然保留着等主库来删。
还有一个更严重的场景:主库宕机,从库晋升为新主库,此时旧主库上“已经过期但还没发DEL”的key,会被新主库当作正常key保留下来。Redis在3.2之后做了一件事:从库晋升主库时会对这类key做一次过期清理,但如果使用了绝对时间戳EXPIREAT,主从时钟不一致时还是会有偏差。
5.3 时钟漂移与EXPIREAT
绝对时间戳命令EXPIREAT看起来很方便,但生产环境里用它要非常小心。所有依赖绝对时间的过期判断,底层都基于服务器本地时钟。如果系统时间因为NTP校准、人工调整等原因发生跳变,可能出现两种意外:
- 时间往后跳,key的过期时间延后,本应过期的缓存继续存活
- 时间往前跳,key提前大面积过期,大量请求瞬间打到DB
解法很简单:能用相对时间EXPIRE/PEXPIRE,就不要用绝对时间EXPIREAT/PEXPIREAT。绝对时间只在“业务明确要求某个时刻必须失效”的场景(比如秒杀活动结束)才使用,并且要做好监控告警,关注服务器时钟状态。
5.4 key没设过期时间导致的内存爆炸
这类事故在各类技术社区里反复出现:明明设计了TTL,但代码里少写了一个EXPIRE,或者不小心用SET覆盖清掉了过期时间,key变成了永不过期,然后内存持续增长直到OOM,Redis直接拒绝写入,线上业务全挂。
自查和预防有四个手段。第一,代码Review时重点检查SET之后是否跟了EXPIRE,或者是否用了SETEX、SET带EX参数。第二,统一封装Redis工具类,提供必然带TTL的set方法,从框架层面避免遗忘。第三,配置maxmemory-policy allkeys-lru作为兜底,就算有key真的没设过期时间,内存满了也能自动淘汰。第四,监控Redis内存使用率和key总量,超过阈值就告警,别等OOM了才发现。
6. 面试八股:过期时间相关的常见问题
6.1 为什么Redis不主动删除所有过期key
这个问题考核的是对Redis单线程模型的理解。如果每分钟遍历所有key检查过期,假设有1000万个key,这个扫描过程会占用主线程,期间所有请求都会被阻塞。Redis的单线程模型最怕的就是阻塞,一个偶发的性能毛刺就可能引发全局超时。
所以Redis设计成“惰性删除+定期抽样删除”的组合:惰性删除保证不在主线程上额外花时间,定期删除用极小的时间成本(每轮最多25ms)控制过期key的内存占用。这是一种刻意的取舍——用少量的CPU开销换取稳定的主线程响应时间。
6.2 Redis的LRU是精确的LRU吗
不是,Redis的LRU是近似LRU。真正的LRU需要记录每个key的最近访问时间,并在内存满时找到最久未使用的那一个,这在单线程模型下代价太高。Redis的做法是:内存快满时,随机采样若干个key(默认5个,由maxmemory-samples配置),淘汰其中最久没被访问的一个。
如果maxmemory-samples调大(比如10或者20),淘汰结果更接近精确LRU,但采样本身会消耗更多CPU。实际调优时,一般从默认的5开始,如果发现淘汰效果不理想再逐步调大。
6.3 RDB和AOF对过期Key怎么处理
这个问题看起来偏底层,但涉及Redis持久化和数据恢复,生产环境排查数据丢失问题时会用到。
RDB生成时,Redis会过滤掉已经过期的key,所以持久化文件里不会出现过期数据。RDB加载时,主库会再检查一遍并过滤过期key,从库加载RDB时不过滤——这跟主从复制中“从库等主库DEL”的逻辑是一致的。
AOF方面,key过期但还没被删除时,AOF文件里不会有任何对应操作;当key真正被删除时(无论是惰性删除还是定期删除触发),Redis会追加一条DEL命令,保证重放AOF时能正确删除。AOF重写时也会把过期key过滤掉,只保留有效数据的写入命令。
6.4 大量key同时过期,Redis会怎样
这个问题我在4.1节讲随机扰动时埋了个伏笔,这里从机制上再解释一层。大量key同时过期,定期删除任务会被反复触发:第一次抽查发现过期比例超过25%,继续删,再抽查还是超过,继续删,直到比例降下来或者25ms时间到了。如果过期key实在太多,这个清理过程会持续好几轮,CPU占用率会明显上升。
与此同时,如果这些过期key又是热点key,客户端一访问就触发惰性删除,阻塞时间也会累加。这就是为什么大家都说“过期时间要加随机值”——它不是锦上添花,而是预防雪崩的关键手段。
最后再分享一个小技巧:排查线上Redis的内存问题时,redis-cli --bigkeys是个好工具,它会扫描并列出最大的key类型和大小,配合INFO keyspace看每个库的key数量和过期key数量,能快速定位内存异常增长的方向。我自己每次接手一个新项目的Redis,第一件事就是跑一遍这个命令,把大key和没设过期时间的key都过一遍,真能省下不少后面排障的时间。