【Redis从入门到精通】第26篇:Redis过期键机制——TTL的生死时钟是怎么走的

发布时间:2026/6/1 21:03:16

【Redis从入门到精通】第26篇:Redis过期键机制——TTL的生死时钟是怎么走的 上一篇【第25篇】Redis数据库那些事——16个数据库的选择与键空间管理下一篇【第27篇】过期键的骨牌效应——AOF/RDB/复制中的过期处理每一个带TTL的键从出生那一刻起脑袋上就悬着一把达摩克利斯之剑。问题是——谁去拉那根绳子什么时候拉引言为什么需要过期键缓存数据总该有个保质期。就像冰箱里的酸奶过了期你还喝那不是勇敢那是作死。Redis作为缓存界的扛把子自然要提供一套完善的过期机制——让键在指定时间后自动阵亡释放内存给更需要的数据。但问题来了键过期了谁来负责收尸是到点就杀还是等到有人用的时候才发现已经过期这就是过期删除策略要解决的核心问题。过期字典Redis的死亡名单Redis在每个数据库中都维护了一个过期字典expires dict它和键空间字典dict形影不离。┌─────────────────────────────────────────────────┐ │ Redis DB 结构 │ │ │ │ ┌──────────────┐ ┌──────────────────────┐ │ │ │ dict (键空间) │ │ expires (过期字典) │ │ │ │ │ │ │ │ │ │ key → value │ │ key → expire_time_ms │ │ │ │ │ │ │ │ │ │ user:1 → │ │ user:1 → 1687700000│ │ │ │ {name:Tom}│ │ token:abc→1687700500│ │ │ │ │ │ │ │ │ │ token:abc→ │ │ 注意key指向同一个 │ │ │ │ secret123 │ │ 键对象节省内存 │ │ │ └──────────────┘ └──────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘过期字典有几个关键细节值得注意key指向同一个键对象过期字典的key和键空间字典的key指向的是同一个对象不会额外复制一份这样做的目的是节省内存。value是long long毫秒级时间戳过期时间以绝对时间戳的形式存储精确到毫秒。比如1687700000000代表的是2023年6月25日某个时刻。两个字典的关系一个键只有在过期字典中存在记录才算设置了过期时间。如果键被删除过期字典中对应的记录也会被清除。这种设计的好处是查询一个键是否过期只需要在过期字典中查一次时间复杂度O(1)非常高效。设置过期时间四兄弟命令Redis提供了四个设置过期时间的命令虽然看起来有四种但其实内部殊途同归# 方式1设置秒级相对时间EXPIRE key60# 60秒后过期# 方式2设置毫秒级相对时间PEXPIRE key60000# 60000毫秒后过期也是60秒# 方式3设置秒级绝对时间Unix时间戳EXPIREAT key1687700060# 在指定Unix秒时间戳过期# 方式4设置毫秒级绝对时间Unix时间戳PEXPIREAT key1687700060000# 在指定Unix毫秒时间戳过期内部转换规则不管你用哪个命令Redis内部都会将其转换为PEXPIREAT即毫秒级绝对时间戳然后存入过期字典。转换过程如下EXPIRE key 60 → PEXPIRE key 60000 # 秒转毫秒 → PEXPIREAT key (now_ms 60000) # 相对转绝对 → 存入 expires dict这种统一存储的设计非常优雅——无论用户用什么方式设置过期时间底层只需要一种存储格式。当然最方便的还是SET命令自带的过期参数SET key value EX60# 等价于 SET EXPIRESET key value PX60000# 等价于 SET PEXPIRESET key value EXAT1687700060# 等价于 SET EXPIREAT踩坑提示SET key value EX 60是原子操作但SET key valueEXPIRE key 60是两条命令中间如果断线键就成了永生的。所以能用SET EX就别拆开写。查询剩余时间TTL和PTTL想知道一个键还能活多久用TTL家族命令TTL key# 返回剩余秒数PTTL key# 返回剩余毫秒数返回值的含义需要特别注意返回值含义正整数剩余生存时间秒/毫秒-1键存在但没有设置过期时间永生键-2键不存在已经死了或者从未出生127.0.0.1:6379SET temp_keyhelloEX60OK127.0.0.1:6379TTL temp_key(integer)58127.0.0.1:6379PTTL temp_key(integer)57832127.0.0.1:6379TTL permanent_key(integer)-1# 永生键127.0.0.1:6379TTL never_existed(integer)-2# 不存在踩坑提示TTL返回的是秒级取整值可能看起来已经到期了但实际上还没到期。如果需要精确判断请用PTTL。比如TTL返回0但PTTL可能返回999毫秒——键还活着三种过期删除策略哲学之争过期键的删除策略本质上是CPU和内存之间的权衡。学术界提出了三种经典策略策略一定时删除Timer为每个设置了过期时间的键创建一个定时器到点立即删除。设置过期时间 │ ▼ ┌─────────────────┐ │ 创建定时器 │ │ timer[key] t │ └────────┬────────┘ │ 时间到 │ ▼ ┌─────────────────┐ │ 执行删除回调 │ ← CPU开销每个键一个定时器 │ 删除key │ ← 内存友好过期即释放 └─────────────────┘优点内存最友好过期键立刻被清理不会占用一秒多余的内存。缺点CPU极其不友好。如果同时有10万个键过期就会同时触发10万个删除操作CPU直接被打满。而且创建定时器本身也有开销Redis使用的是单线程模型定时器的实现和管理非常复杂。策略二惰性删除Lazy不主动删除只在访问键的时候检查是否过期过期了才删除。访问key │ ▼ ┌─────────────────┐ │ 检查是否过期 │ └────────┬────────┘ │ ┌────┴─────┐ │ │ 过期了 没过期 │ │ ▼ ▼ 删除并返空 正常返回值优点CPU最友好只在真正需要的时候才执行删除操作不会有无谓的CPU消耗。缺点内存极不友好。如果一个键过期了但从来没人访问它它就会一直躺在内存里——这就是传说中的内存泄漏。想象一下你有一堆过期的优惠券没人来查它们就永远占着抽屉的空间。策略三定期删除Periodic每隔一段时间随机检查一批设置了过期时间的键发现过期的就删除。定时触发如每100ms │ ▼ ┌─────────────────┐ │ 随机抽取一批键 │ │ 检查是否过期 │ │ 过期的删除 │ └────────┬────────┘ │ ┌────┴──────┐ │ │ 过期率25% 过期率≤25% │ │ ▼ ▼ 继续检查 本轮结束优点是前两种策略的折中方案既不会像定时删除那样疯狂消耗CPU也不会像惰性删除那样浪费内存。缺点策略的火候很难把握。检查太频繁CPU压力大检查太少内存回收不及时。而且随机抽取的方式无法保证所有过期键都被及时清理。三种策略对比维度定时删除惰性删除定期删除CPU消耗高到点必删低按需删中定期抽删内存友好好立即释放差可能泄漏中基本及时实现复杂度高定时器管理低访问时检查中需调参实时性强到点即删弱取决于访问中取决于频率Redis的实际选择惰性 定期Redis没有选择某一种策略而是采用了惰性删除 定期删除的组合拳惰性删除所有对键的读写命令GET、SET、HGET等在执行前都会先检查键是否过期过期则删除。定期删除通过serverCron中的activeExpireCycle函数周期性地清理过期键。这个选择非常符合Redis的设计哲学——在性能和内存之间找到最佳平衡点。惰性删除的实现惰性删除的逻辑嵌入在几乎所有的键操作命令中。当你执行GET key时Redis会先调用expireIfNeeded函数// 伪代码robj*lookupKeyRead(redisDb*db,robj*key){robj*valdictFind(db-dict,key-ptr);if(val!NULL){// 检查是否过期if(expireIfNeeded(db,key)){// 键已过期已被删除returnNULL;}// 更新LRU/LFU信息returnval;}returnNULL;}定期删除的实现细节定期删除由activeExpireCycle函数实现它在serverCron中每100毫秒被调用一次。核心逻辑如下activeExpireCycle 执行流程 │ ▼ ┌──────────────────────────────────┐ │ 每次遍历若干个DB默认16个 │ │ 每个DB随机抽取20个键检查 │ │ ACTIVE_EXPIRE_CYCLE_LOOKUPS │ │ _PER_LOOP 20 │ └──────────────┬───────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ 如果本轮过期的键占总检查键的比例 │ │ 超过25%则继续对该DB检查 │ │ 否则切换到下一个DB │ └──────────────┬───────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ 整个周期的时间上限 │ │ 默认为serverCron周期的25% │ │ 超时则强制退出避免阻塞 │ └──────────────────────────────────┘关键的25%阈值设计非常巧妙如果过期率很高25%说明有过期键堆积需要继续清理。如果过期率低≤25%说明当前DB比较干净可以去检查下一个DB。这保证了清理过程不会无限循环也保证了在过期键较多时能加大清理力度。// 伪代码activeExpireCycle核心逻辑do{num_expired0;num_checked0;while(num_checkedACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP){// 随机抽取一个设置了过期时间的键keyrandomKeyFromExpiresDict(current_db);if(isExpired(key)){deleteKey(key);num_expired;}num_checked;}// 计算过期比例rationum_expired/num_checked;}while(ratio0.25time_not_exceeded());踩坑提示定期删除是随机抽取的这意味着如果你有大量键设置了相同的过期时间比如半夜12点统一过期定期删除可能在过期发生时清理不及时惰性删除又没人访问这些键导致内存短暂飙升。这就是缓存雪崩的诱因之一。PERSIST命令赋予永生如果一个键突然想开了不想死了怎么办PERSIST命令可以移除键的过期时间让它变成永久键127.0.0.1:6379SET tempI will expireEX60OK127.0.0.1:6379TTL temp(integer)58127.0.0.1:6379PERSIST temp(integer)1127.0.0.1:6379TTL temp(integer)-1# 变成永生键了PERSIST的内部实现就是从过期字典中删除对应的记录intremoveExpire(redisDb*db,robj*key){// 从过期字典中删除该key的过期时间记录returndictDelete(db-expires,key-ptr)DICT_OK;}返回值1表示成功移除过期时间0表示键不存在或本来就没有过期时间。过期键被删除时发生了什么当过期键被删除无论是惰性删除还是定期删除Redis并不是简单地把键从字典中删掉就完事了。它还会触发一系列后续操作过期键被删除 │ ├──► 从键空间字典(dict)中删除键值对 │ ├──► 从过期字典(expires)中删除过期记录 │ ├──► 如果开启了AOF持久化 │ 追加一条DEL命令到AOF缓冲区 │ ├──► 如果开启了键空间通知 │ 发送expired事件通知 │ ├──► 更新键空间统计信息 │ (keyspace_hits / keyspace_misses等) │ └──► 触发内存回收相关逻辑 (如果配置了maxmemory策略)其中最值得关注的两个副作用AOF追加过期键被删除后AOF文件中会追加一条DEL key命令。这保证了AOF重放时也能正确删除过期键。键空间通知如果客户端订阅了过期事件我们将在下一篇文章详细讨论Redis会发送一个expired通知。生产实践合理设置TTL避免批量键同时过期这是最常见的缓存雪崩场景。假设你有一个活动所有优惠券的过期时间都设为活动结束的那一秒# 危险做法所有键同时过期forcouponincoupons: EXPIRE coupon:{id}86400# 统一24小时后过期当86400秒到来时大量键同时过期如果此时又有大量新请求涌入所有请求都会穿透到数据库造成缓存雪崩。正确做法在过期时间上加随机偏移# 安全做法过期时间加随机偏移importrandomforcouponincoupons: ttl86400 random.randint(-3600,3600)# ±1小时随机偏移EXPIRE coupon:{id}$ttl为所有缓存键设置TTL即使你不确定一个键该活多久也给它设一个合理的上限比如7天。没有TTL的键就像冰箱里没有标注日期的剩饭——你永远不知道它什么时候开始变质但它一定会变质。# 即使是长期缓存也设个兜底TTLSET user:10086{data}EX604800# 7天兜底监控过期键情况使用INFO keyspace命令查看各数据库的键空间统计127.0.0.1:6379INFO keyspace# Keyspacedb0:keys100000,expires80000,avg_ttl3456789keys总键数expires设置了过期时间的键数avg_ttl平均剩余TTL毫秒如果expires/keys的比例很低说明大部分键没有过期时间需要警惕内存泄漏风险。OBJECT IDLETIME的局限OBJECT IDLETIME命令可以查看一个键的空闲时间多久没被访问常用于实现类似LRU的淘汰逻辑127.0.0.1:6379OBJECT IDLETIME mykey(integer)3600# 空闲了3600秒但这个命令有一个重要局限LRU时钟精度有限。Redis的LRU时钟默认每100毫秒更新一次由server.hz控制所以OBJECT IDLETIME返回的值可能有最多100毫秒的误差。对于绝大多数场景来说这无所谓但如果你需要精确到毫秒的空闲时间那就没法靠这个命令了。此外OBJECT IDLETIME本身也会更新键的访问时间吗不会这是它和GET等命令的区别——OBJECT IDLETIME读取空闲时间但不更新LRU时钟否则它本身就变成了一个观察者效应的bug。总结Redis的过期键机制是一个精巧的设计过期字典以O(1)的复杂度管理所有过期时间key共享节省内存四个过期命令内部统一转换为PEXPIREAT用毫秒绝对时间戳存储惰性删除定期删除的组合策略在CPU和内存之间取得了良好平衡定期删除的25%阈值设计让清理力度自适应过期键的删除会触发AOF追加和键空间通知等副作用理解过期键机制不仅能帮你写出更合理的TTL策略还是理解Redis持久化和主从复制中过期键行为的基础——这是我们下一篇文章要讨论的内容。上一篇【第25篇】Redis数据库那些事——16个数据库的选择与键空间管理下一篇【第27篇】过期键的骨牌效应——AOF/RDB/复制中的过期处理

相关新闻