【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 value+EXPIRE key 60是两条命令,中间如果断线,键就成了"永生"的。所以能用SET EX就别拆开写。
查询剩余时间:TTL和PTTL
想知道一个键还能活多久?用TTL家族命令:
TTL key# 返回剩余秒数PTTL key# 返回剩余毫秒数返回值的含义需要特别注意:
| 返回值 | 含义 |
|---|---|
| 正整数 | 剩余生存时间(秒/毫秒) |
| -1 | 键存在,但没有设置过期时间("永生"键) |
| -2 | 键不存在(已经死了或者从未出生) |
127.0.0.1:6379>SET temp_key"hello"EX60OK127.0.0.1:6379>TTL temp_key(integer)58127.0.0.1:6379>PTTL temp_key(integer)57832127.0.0.1:6379>TTL permanent_key(integer)-1# 永生键127.0.0.1:6379>TTL 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*val=dictFind(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_expired=0;num_checked=0;while(num_checked<ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP){// 随机抽取一个设置了过期时间的键key=randomKeyFromExpiresDict(current_db);if(isExpired(key)){deleteKey(key);num_expired++;}num_checked++;}// 计算过期比例ratio=num_expired/num_checked;}while(ratio>0.25&&time_not_exceeded());踩坑提示:定期删除是随机抽取的,这意味着如果你有大量键设置了相同的过期时间(比如半夜12点统一过期),定期删除可能在过期发生时清理不及时,惰性删除又没人访问这些键,导致内存短暂飙升。这就是缓存雪崩的诱因之一。
PERSIST命令:赋予"永生"
如果一个键突然想开了,不想死了怎么办?PERSIST命令可以移除键的过期时间,让它变成永久键:
127.0.0.1:6379>SET temp"I will expire"EX60OK127.0.0.1:6379>TTL temp(integer)58127.0.0.1:6379>PERSIST temp(integer)1127.0.0.1:6379>TTL 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: ttl=86400+ random.randint(-3600,3600)# ±1小时随机偏移EXPIRE coupon:{id}$ttl为所有缓存键设置TTL
即使你不确定一个键该活多久,也给它设一个合理的上限(比如7天)。没有TTL的键就像冰箱里没有标注日期的剩饭——你永远不知道它什么时候开始变质,但它一定会变质。
# 即使是长期缓存,也设个兜底TTLSET user:10086"{data}"EX604800# 7天兜底监控过期键情况
使用INFO keyspace命令查看各数据库的键空间统计:
127.0.0.1:6379>INFO keyspace# Keyspacedb0:keys=100000,expires=80000,avg_ttl=3456789keys:总键数expires:设置了过期时间的键数avg_ttl:平均剩余TTL(毫秒)
如果expires/keys的比例很低,说明大部分键没有过期时间,需要警惕内存泄漏风险。
OBJECT IDLETIME的局限
OBJECT IDLETIME命令可以查看一个键的空闲时间(多久没被访问),常用于实现类似LRU的淘汰逻辑:
127.0.0.1:6379>OBJECT 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/复制中的过期处理
