Redis键过期时间完全指南:从EXPIRE命令到内存删除机制与实战避坑
做后端开发的几乎没有人能绕开Redis。不管是做缓存、做登录态、做限流第一步按键值写进去第二步就想着这数据什么时候失效。给Redis键设置过期时间这件事看起来简单——一条EXPIRE命令的事但真正写代码时会遇到一堆问题为什么不设置过期时间内存会爆SET和EXPIRE到底该不该分开写TTL明明到了为什么内存占用没降重启之后过期键怎么又回来了这些坑我基本都踩过一遍。这篇文章把“Redis设置过期时间”从命令、原理到实战完整理一遍适合刚接触Redis的新手也适合用过一段时间但没深究过底层机制的同学。1. 为什么必须给Redis键设置过期时间场景与收益1.1 不加过期时间的代价内存泄漏还是设计缺陷很多人第一次接触Redis都是从“存缓存数据”开始的。写进去一条hash读出来的时候用看起来很美好但有个问题如果不设置过期时间这条数据会一直躺在内存里。Redis是纯内存数据库内存是有限资源一个8GB的实例存满了后续写入就会被拒绝或者触发内存淘汰策略把其他键给挤掉造成连锁反应。我见过不少线上事故最典型的就是登录token设计的时候只想着生成一个token存进去忘了设置过期时间结果每个用户每次登录都往里写一个时间一长上千万个无效token占满了内存最终把整个Redis打挂。这不是危言耸听是真实发生过的案例。从设计角度讲Redis里的键分两种一种是需要持久保存的业务数据比如配置信息、排行榜这类键可以长期存在另一种是有时效性的临时数据比如验证码、会话、限流计数器、临时token这类数据天生就有生命周期。给这类临时数据设置过期时间本质上是让Redis自己管理数据的生死而不是靠业务代码手动清理。1.2 四个典型场景缓存、验证码、会话、分布式锁第一个场景是缓存。缓存数据通常是从MySQL或者其他慢存储中加载出来的数据源头变了缓存就该失效。最常见的做法是设置一个合理的过期时间比如商品详情缓存15分钟、用户资料缓存30分钟避免缓存里一直存着旧数据也给冷数据留出内存空间。第二个场景是验证码。短信验证码、邮件验证码一般5分钟有效注册链接、重置密码链接一般30分钟或者24小时有效。如果不设置过期时间验证码永远有效那就失去了验证码本身的意义。第三个场景是会话。Session、Token这类东西有明确的过期策略比如固定过期、滑动过期两种模式。Redis原生的EXPIRE只能做固定过期但可以组合其他命令实现滑动过期的效果。Spring Session、Shiro这些框架底层都把Session存到Redis里设置过期时间来控制登录状态的有效期。第四个场景是分布式锁。Redis实现分布式锁有两种常见方案一种是SET key value EX seconds NX锁的超时时间就是锁的有效期防止持有锁的进程崩溃后死锁另一种是用Redisson的看门狗机制自动续期。不管是哪种方案底层都和过期时间强相关。2. 五种过期时间设置方式命令选型与避坑2.1 EXPIRE、PEXPIRE、EXPIREAT基础命令逐个拆解Redis里设置过期时间的命令最常用的是EXPIRE语法非常简单EXPIRE key seconds比如给key为goods:1001的键设置120秒过期 SET goods:1001 {\name\:\手机\} OK EXPIRE goods:1001 120 (integer) 1返回1表示设置成功0表示键不存在。除了秒级Redis还提供了毫秒级的PEXPIREPEXPIRE key milliseconds以及指定绝对时间点的EXPIREAT用Unix时间戳单位秒EXPIREAT key timestamp这三个命令各有使用场景但我想重点说一个容易忽略的细节EXPIRE系列命令只能给已经存在的键设置过期时间如果键不存在会返回0并不会报错。很多同学在业务代码里先写EXPIRE再写SET顺序搞反了日志里看到的都是0排查半天才发现是顺序问题。从Redis 7.0开始EXPIRE还增加了NX、XX、GT、LT四个选项用来控制设置行为NX仅当键没有过期时间时设置XX仅当键已有过期时间时设置GT仅当新过期时间大于当前过期时间时设置LT仅当新过期时间小于当前过期时间时设置这在实际业务中很有用比如你想确保已有的长缓存不被短过期时间覆盖可以用EXPIRE key seconds GT。2.2 SETEX与SET NX EX写入时直接设置过期时间比起分开写SET和EXPIRERedis更推荐在写入的同时设置过期时间这样避免了两条命令之间键状态的不一致。SETEX命令全称Set with Timeout语法SETEX key seconds value示例 SETEX verify_code:13800001111 300 8848 OK TTL verify_code:13800001111 (integer) 296等价于SET EXPIRE但执行方式是原子的。还有一个更强大的组合就是SET命令的扩展参数SET key value EX seconds [NX]示例 SET lock:order:1001 worker-a EX 30 NX OK这条命令同时完成了三件事设置键值、设置30秒过期时间、仅当键不存在时才写入。分布式锁的经典写法就是这一条命令。开头提到的“SET和EXPIRE到底该不该分开写”其实答案就是能用一条SET带过期参数解决的绝不要分开写。为什么因为如果SET成功之后、EXPIRE执行之前服务进程崩溃或者网络断掉这个键就成了永远不消失的“僵尸锁”后续所有拿锁的请求都会失败这是非常严重的线上故障。Redis 6.2之后SET还支持PX、EXAT、PXAT这些参数分别对应毫秒过期、绝对时间秒、绝对时间毫秒灵活度更高。2.3 更新过期时间的正确姿势PERSIST、NX/XX、滑动过期说完设置再说说更新和取消。如果需要取消键的过期时间让它永久保存用PERSIST PERSIST key (integer) 1执行后键就变成了永久键TTL会显示为-1。如果需要给一个已有过期时间的键重新设置新的过期时间直接再调EXPIRE即可新的过期时间会覆盖掉旧的 EXPIRE key 100 (integer) 1 EXPIRE key 300 (integer) 1这个逻辑很简单但有一个业务场景值得多说几句滑动过期。比如用户会话我们希望用户活跃时一直保持登录态15分钟不操作才自动退出。Redis本身没有原生滑动过期命令但可以自己实现当用户发起请求时除了读取token对应的值再给这个键重新执行一遍EXPIRE把过期时间刷新到15分钟后 GET session:token-123 user:1001 EXPIRE session:token-123 900 (integer) 1第2条EXPIRE就是刷新操作。这里注意GET和EXPIRE两条命令并非原子操作如果要在分布式环境下严格保证原子性可以用Lua脚本包裹或者直接用一个GETEX命令。Redis 6.2引入了GETEX它可以在取值的同时重新设置过期时间一步到位 GETEX session:token-123 EX 900 user:1001这也是我在生产环境比较推荐的做法既拿到值又完成了滑动过期。3. 过期键背后的删除机制为什么内存不是立刻释放3.1 惰性删除为什么到点数据还在很多人第一次测试过期时间时都会疑惑我设置了10秒过期用TTL查已经变成-2了为什么用DEBUG OBJECT查内存键还占着位置这是正常的Redis对过期键的删除并不是“秒杀式”的立刻物理删除而是采用了一种折中策略主要由惰性删除和定期删除两部分组成。惰性删除的意思是当客户端访问这个键时Redis会先检查它的过期时间如果已过期就立即删除并返回nil。也就是说一个过期键如果没有被任何请求查询它就会暂时留在内存里直到下次被访问或者被定期删除任务扫描到。这种设计是为了性能考虑。如果每次设置过期时间都立刻启动一个定时器去删除那大量短过期时间键的删除操作会带来很高的CPU开销和定时器管理成本。惰性删除把删除动作延后到访问时对正常操作路径几乎没有额外代价。但惰性删除有个问题如果一批过期键长期不被访问内存就会被一直占着甚至占满。所以Redis同时还有定期删除。3.2 定期删除与内存淘汰它们不是一回事定期删除是Redis内部一个周期性任务默认大概每100毫秒执行一次它会随机抽取一部分设置了过期时间的键进行检查发现过期就删除。这里注意“随机抽取”Redis并不会遍历所有键而是采用采样策略每次抽取一定数量的key删除其中的过期key如果过期的比例较高就重复执行几轮避免一次性扫描大量键影响性能。定期删除和内存淘汰策略是两个不同层面的机制。定期删除针对的是“到期的键”内存淘汰针对的是“所有键内存不够了怎么办”。Redis的maxmemory-policy有几种典型配置淘汰策略行为适用场景noeviction内存满了直接拒绝写请求返回错误不推荐会造成写入失败allkeys-lru从所有键中按LRU淘汰最近最少使用的键纯缓存场景volatile-lru仅从设置了过期时间的键中按LRU淘汰混合业务场景保留永久键allkeys-lfu按LFU淘汰访问频率最低的键缓存热点有明显冷热差volatile-ttl从设置了过期时间的键中优先淘汰剩余时间短的键有明确时效性数据在实际项目里我建议根据数据构成来选择如果Redis里大部分键都是临时数据用volatile-lru或volatile-ttl如果所有键都是可丢弃的缓存直接用allkeys-lru更省心。千万不要留下默认的noeviction配置内存满了Redis直接不干活生产事故就是这么来的。3.3 主从、持久化对过期键的影响主从复制环境下过期键的处理有一个非常经典的坑从库不会主动删除过期键它是等主库删除后收到主库的DEL命令才删除的。这就意味着在极端情况下从库读到的数据可能和主库不一致明明已经过期的键从库在收到DEL之前还能读到。Redis官方对这个问题的解释是从库在读请求时也会检查过期时间如果发现过期会返回nil但它不会触发物理删除而是等待主库的命令。所以在大多数场景下从库读不到过期键只是物理释放内存会有延迟。如果你做的是读写分离架构要意识到这一点。持久化方面RDB和AOF对过期键的处理也有讲究RDB生成快照时已经过期的键不会写入RDB文件。Redis加载RDB文件恢复数据时主库会过滤掉已过期的键从库不会主动过滤而是靠主库的DEL命令同步删除。AOF模式下当一个键过期后Redis会在追加写入时加一条DEL命令。如果AOF重写正在进行重写过程会过滤掉已过期的键。这些机制对运维排查非常关键尤其是“Redis重启以后过期键怎么又出现了”的问题后面会专门讲。4. 完整实操从设置、查看到验证过期时间4.1 实操准备如何快速起一个Redis实例这里我以本地Docker方式演示最省事docker run -d --name redis-demo -p 6379:6379 redis:7.2如果不想用Docker也可以直接下载Redis源码编译或者Windows下用官方发布的Redis版本但要注意Windows版本更新比较慢版本号可能停留在5.0或6.0一些新命令比如GETEX可能不支持。启动后用自带的redis-cli连上去redis-cli -h 127.0.0.1 -p 6379在弹出的命令行里就可以开始下面的实验了。4.2 命令实测设置、查询、取消、变更先造一个键并给它设置120秒过期127.0.0.1:6379 SET user:1001 zhangsan OK 127.0.0.1:6379 EXPIRE user:1001 120 (integer) 1 127.0.0.1:6379 TTL user:1001 (integer) 118TTL返回的数值是剩余秒数。注意它不是固定不变的每次查询都会递减所以两次查询看到不同的值是正常的。再测一下GETEX滑动过期127.0.0.1:6379 GETEX user:1001 EX 300 zhangsan 127.0.0.1:6379 TTL user:1001 (integer) 298可以看到剩余时间从118秒被刷新到了298秒说明滑动过期失败价值就体现在这里。再测试取消过期时间127.0.0.1:6379 PERSIST user:1001 (integer) 1 127.0.0.1:6379 TTL user:1001 (integer) -1TTL为-1代表这个键永不过期。4.3 不同数据类型的过期时间实测这里想强调一个很多人忽略的点过期时间设定的是整个键的过期时间不是某个字段、某个元素的过期时间。写一行SET是对key本身设置生命周期和这个key下面的数据类型无关。来看一个hash的例子127.0.0.1:6379 HSET cart:1001 apple 3 banana 2 (integer) 2 127.0.0.1:6379 EXPIRE cart:1001 60 (integer) 1 127.0.0.1:6379 HGET cart:1001 apple 3当过期时间到后整个hash都会被删除而不是单独的某个字段过期# 等待60秒后 127.0.0.1:6379 EXISTS cart:1001 (integer) 0 127.0.0.1:6379 HGET cart:1001 apple (nil)如果业务上有“列表里某个元素单独过期”的需求比如每个购物车条目有自己的失效时间Redis原生数据结构做不到只能把元素拆成独立的key每个key单独设置过期时间。这是我在实际项目中经常需要向团队成员解释的点。5. 常见坑与排查实录TTL值、持久化与主从同步5.1 TTL显示-1、-2的含义与处理很多同学第一次看到TTL返回负数就懵了这里整理一个速查表TTL返回含义处理方法正整数剩余过期秒数正常-1键存在但从未设置过期时间如需要过期执行EXPIRE/SETEX-2键不存在确认是否被DEL或者已经过期被删除实际排查中最尴尬的是用TTL查一个键返回-2一脸迷茫这个键到底是什么时候没的有没有办法追溯Redis本身没有直接的事件日志记录每次键删除操作但如果你开启了keyspace notifications可以订阅过期事件比如CONFIG SET notify-keyspace-events Ex然后在另一个终端订阅PSUBSCRIBE __keyevent0__:expired键过期被删除时就会收到对应的事件通知。这个功能在做延迟队列、定时任务、缓存击穿修复时非常有用生产环境建议按需开启注意它也会带来一定的额外消息开销。5.2 重启后过期键还在持久化机制解惑有同学遇到过这样的诡异现象明明给键设置了600秒过期运行几个小时后Redis重启这个键竟然还在而且TTL还在正确倒计时。这是RDB持久化的锅。默认配置下Redis会定期生成RDB快照比如save 900 1表示900秒内至少1个键变化就保存一次。保存的RDB文件里会包含当时还没有过期的键及其剩余过期时间。Redis重启加载RDB时会把这些键原样恢复包括它们还没走完的TTL。所以“重启后过期键还在”不一定是Bug而是它确实还没到期。如果加载RDB时键已经过期主库会直接忽略不会恢复。但如果是从库加载RDB行为略有不同它不会主动删除过期键只能等主库发来的DEL命令。这块前面已经说过所以在搭建主从架构时最好给从库也配置好内存淘汰策略比如volatile-lru防止从库加载过程中内存被过期键占满。5.3 主从复制与过期键的同步问题在主从模式下过期键的删除主线一般是主库发现键过期→主动删除→向从库发送DEL命令。从库收到DEL再删除。这里有一个时间窗口从库在收到DEL之前理论上可能还存着这个键但读请求会因为过期时间检查而返回nil所以对业务透明。真正要警惕的是主库宕机后从库晋升为新的主库如果此时从库还残留一些过期键数据客户端读取这些键时会被惰性删除兜底不会返回过期数据。但物理内存释放仍然滞后如果键特别多新主库的内存和CPU都会有一波小压力。另一个经典问题在分布式锁场景主库写入一个带过期时间的锁如果主库挂掉锁的key可能还没同步到从库从库晋升成主库后原锁信息丢失另外一台机器就能拿到锁导致“同一时刻多个线程持有同一把锁”。解决思路通常是用RedLock算法或者引入额外的协调服务这个展开讲就是另一个话题了。5.4 生产环境中的经验总结最后分享几点我在实际项目中积累的经验希望对你有帮助。第一给所有缓存类的键设置默认过期时间不要把“永不过期”当成默认值。Redis内存是宝贵资源宁可过期后重新加载也比内存被打爆强。第二过期时间的设置要结合业务容忍度。比如缓存能够接受10分钟内的数据延迟就设置600秒如果要求秒级一致就不能只靠过期时间要配合主动更新或消息通知。第三批量设置过期时间时避免大量键在同一秒内同时过期否则可能造成缓存雪崩数据库瞬间被打爆。解决办法是给过期时间加一个随机值比如基础时间加随机0-300秒把过期时间打散。第四排查性能问题时用SCAN代替KEYS去统计过期键SCAN 0 MATCH user:* COUNT 1000然后在业务低峰期对不需要的键执行UNLINKRedis 4.0。第五面试或团队分享中常被问到的“Redis过期时间的删除策略”记住一句话惰性删除为主定期删除为辅内存淘汰为兜底。把这三种机制放在一起理解就能应对大多数场景了。Redis设置过期时间这个功能表面上一行命令背后牵扯的是内存管理、数据一致性、高可用架构的一系列权衡。把这里面的逻辑摸透再去处理线上问题会顺手很多。