ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis过期时间全解析:从命令原理到分布式锁与缓存治理

Redis过期时间全解析:从命令原理到分布式锁与缓存治理 1. 过期时间的基础用法别只盯着EXPIRE1.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控制hz10表示每秒执行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淘汰最近最少使用的keyvolatile-lru仅设置了过期时间的key淘汰最近最少使用的keyallkeys-lfu所有key淘汰访问频率最低的keyvolatile-lfu仅设置了过期时间的key淘汰访问频率最低的keyvolatile-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 3030秒过期不是可选项而是必选项。原因很简单如果锁没有过期时间持有锁的客户端一旦宕机或者网络异常断开它永远不会主动释放锁其他客户端就只能干等整个系统就“锁死”了。过期时间就是一道兜底的保险保证最坏情况下锁也能自动释放让系统有机会自愈。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变成了永不过期然后内存持续增长直到OOMRedis直接拒绝写入线上业务全挂。自查和预防有四个手段。第一代码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都过一遍真能省下不少后面排障的时间。
返回列表