Redis 这个系列我打算按自己的学习节奏来写,不追求面面俱到,而是把每个阶段真正卡住我的点讲透。这是基础篇的第二篇,上一篇聊了安装和五种基本数据结构的大致轮廓,这一篇要啃的是 Redis 里最容易被低估、也最容易用错的一块——键的生命周期管理与过期策略。很多人觉得EXPIRE不就是设个过期时间嘛,一行命令的事,但真正在生产环境里踩过坑的人都知道,过期时间的设置方式、淘汰策略的选择、大 key 的清理节奏,每一个细节都可能让内存悄悄涨满或者让请求突然变慢。这篇内容适合已经能跑起 Redis、写过几个SET/GET命令,但还没系统梳理过键管理和内存控制的朋友,也适合那些遇到过“明明设了过期时间内存却没降下来”这类困惑的同行。
1. 为什么键的过期管理值得单独拎出来讲
1.1 一个反直觉的现象:设了过期时间,内存不一定降
我刚接触 Redis 的时候有个很天真的想法:给键设了EXPIRE,时间一到它就该消失,内存自然就回来了。后来在一个缓存场景里发现,明明大量键都过了期,INFO memory里的used_memory却纹丝不动,当时第一反应是“过期没生效?”,排查了半天才发现是自己对过期机制的理解有偏差。
Redis 的过期键删除并不是“到点即删”这么简单。它采用的是惰性删除加定期删除的组合策略。惰性删除的意思是:只有当某个键被访问时,Redis 才顺便检查它是否过期,过期了就删掉。定期删除则是 Redis 每隔一段时间随机抽取一部分设置了过期时间的键来检查清理。这两者结合的结果是:过期键的删除是分散的、渐进的,不是瞬时的。所以如果你设了一大批键在同一时刻过期,内存不会在那个时刻整齐地释放,而是会在后续的访问和定期扫描中慢慢回收。
这个认知很关键,因为它直接决定了你该怎么设计过期时间。如果业务上依赖“过期即释放”来做内存规划,那大概率会失望。正确的做法是把过期时间打散,比如给同一批缓存加上一个随机偏移量,避免大量键在同一秒集中过期,既平滑了内存回收曲线,也避免了集中过期带来的删除压力。
1.2 过期时间到底存在哪里
要理解过期行为,得先知道过期信息是怎么存的。Redis 内部维护了一个专门的过期字典(expires dict),它的键指向数据库里的实际键对象,值是一个毫秒级的 Unix 时间戳,记录这个键的到期时刻。注意,这个时间戳是绝对时间,不是“还剩多少秒”。这意味着如果你用EXPIRE key 100,Redis 会立刻算出“当前时间 + 100 秒”存进去。
这里有个容易被忽略的细节:修改键的值不会自动清除过期时间。比如你对一个设了过期时间的键执行SET,在旧版本里SET会清掉过期时间,但如果你用的是SET的KEEPTTL选项,或者用APPEND、INCR这类命令修改值,过期时间会保留。这个行为差异在实际开发中很容易埋雷——你以为重新赋值后它变成永久键了,结果它还是会在原定时间悄悄消失。我现在的习惯是,凡是涉及覆盖写的地方,都显式确认一下过期时间的预期行为,必要时用TTL命令验证。
1.3 过期与淘汰是两套不同的机制
新手最容易混淆的一点:过期(expire)和淘汰(eviction)不是一回事。过期是你主动给键设定的“保质期”,到点该走;淘汰是内存达到上限后,Redis 为了腾空间被动赶走一些键。前者是业务逻辑层面的控制,后者是内存保护层面的兜底。
为什么必须区分?因为它们的触发条件和后果完全不同。过期键如果没被及时删除,它占的内存还在,但逻辑上已经“失效”;而淘汰是内存真的不够了,Redis 按照配置的策略挑一些键删掉,被淘汰的键可能是还没到期的、业务上正需要的。如果你的maxmemory-policy配成了allkeys-lru,那 Redis 在内存紧张时会毫不犹豫地淘汰任何键,包括那些你精心设置了长过期时间的键。所以生产环境里,过期时间负责业务语义,淘汰策略负责内存安全,两者要配合着设计,不能指望其中一个包打天下。
2. 过期命令族:不只是 EXPIRE 那么简单
2.1 设置过期的几种姿势与它们的差异
设置过期时间的命令有好几个,用哪个取决于你的场景:
| 命令 | 作用 | 适用场景 | 注意点 |
|---|---|---|---|
EXPIRE key seconds | 秒级设置过期 | 常规缓存 | 键不存在返回 0 |
PEXPIRE key ms | 毫秒级设置过期 | 需要精细控制 | 精度更高 |
EXPIREAT key timestamp | 指定秒级时间戳过期 | 定时失效场景 | 时间戳是绝对时间 |
PEXPIREAT key ms-timestamp | 指定毫秒时间戳过期 | 精确到期 | 同上 |
SET key value EX seconds | 设值同时设过期 | 原子操作首选 | 推荐方式 |
SETEX key seconds value | 设值加过期 | 老式写法 | 等价于 SET EX |
我现在的习惯是:能用SET ... EX就不用先SET再EXPIRE。原因很简单,两条命令不是原子的,中间如果客户端崩溃或者网络抖动,就可能出现“值写进去了但过期没设上”的永久键。这种键在缓存场景里就是内存泄漏的种子。SET key value EX 3600一条命令搞定,原子性有保障,少一次往返,何乐而不为。
EXPIREAT这类指定绝对时间戳的命令,适合“到某个固定时刻失效”的需求,比如活动页面的缓存,活动结束时间点是确定的,用绝对时间戳比算相对秒数更直观,也不受服务器时钟漂移之外的因素影响。但要注意,如果多台机器时钟不同步,绝对时间戳的语义会出问题,所以时间同步是前提。
2.2 查看和清除过期时间
查看用TTL(秒)或PTTL(毫秒),返回值有三种情况:正数表示剩余时间,-1表示键存在但没有设过期(永久键),-2表示键不存在或已过期。这个-1和-2的区分很重要,排查问题时能快速判断是“键没了”还是“键还在但没设过期”。
清除过期时间用PERSIST key,把键变回永久。这个命令我用得不多,但在一种场景下很有用:某个键本来设了短期过期做临时缓存,后来业务逻辑变了,需要它长期存在,与其删了重建,不如直接PERSIST。不过要谨慎,永久键意味着它永远不会因为过期而释放,内存规划上要心里有数。
还有一个容易踩的坑:对不存在的键执行EXPIRE返回 0,不会报错。这意味着如果你写了个脚本批量设过期,其中某些键因为前面逻辑已经删掉了,命令会静默返回 0,脚本不会中断。这本身不算错,但如果你依赖返回值做判断,得知道 0 代表“没设上”。
2.3 过期时间的精度与时钟问题
EXPIRE是秒级,PEXPIRE是毫秒级,但实际精度受限于 Redis 的定期删除频率和系统时钟。Redis 的过期判断基于系统时钟,如果服务器时间被手动调整或者 NTP 同步出现大幅跳变,过期行为可能不符合预期。比如时钟往回调,原本该过期的键可能“复活”一段时间;时钟往前跳,键可能提前失效。
这个在容器化环境里尤其要注意,容器的时钟通常继承宿主机,如果宿主机做了时间调整,容器里的 Redis 也会受影响。我的经验是,对时间敏感的业务不要过度依赖精确到毫秒的过期语义,把过期时间当作“大致在这个时间点之后失效”来用,而不是“精确在这一毫秒失效”。真需要精确控制失效的,应该在应用层用时间戳字段自己判断,Redis 的过期只作为兜底清理。
3. 过期键的删除机制:惰性、定期与它们的配合
3.1 惰性删除:访问时才检查
惰性删除的逻辑很直白:每次读写一个键之前,先检查它是否过期,过期就删掉再返回“不存在”。这个策略的好处是对 CPU 友好,不需要额外的后台线程一直扫描;坏处是如果过期键一直不被访问,它就永远占着内存。想象一下你有一批冷数据缓存,设了过期时间但后续再也没人查,这些键就成了“僵尸”,内存只增不减。
这也是为什么前面说“设了过期内存不一定降”——惰性删除只处理被访问到的键,没被访问的过期键要等定期删除来兜底。
3.2 定期删除:抽样扫描的取舍
定期删除是 Redis 的一个后台任务,默认每秒运行若干次(受hz配置影响,默认 10,即每秒 10 次),每次从设置了过期时间的键里随机抽取一部分检查,删掉其中过期的。注意关键词是“随机抽取”,不是全量扫描。全量扫描在键多的时候会阻塞主线程,代价太大,所以 Redis 选择了抽样。
抽样的具体逻辑大致是:每次取一定数量的键(默认 20 个),检查过期比例,如果过期比例超过 25%,就继续再抽一批,直到比例降下来或者达到时间上限。这个设计保证了在过期键密集时能较快清理,同时用时间上限防止单次任务占用过多 CPU。
理解这个机制的意义在于:过期键的清理是概率性的、有延迟的。如果你的场景对内存回收的及时性要求很高,比如内存本来就紧张,那不能完全指望定期删除,得配合合理的淘汰策略,或者在业务层主动清理。
3.3 主从架构下过期行为的特殊性
这一点很多人不知道:从库不会主动删除过期键。从库的过期键删除依赖于主库的删除动作同步过来。主库删掉一个过期键时,会生成一条DEL命令传播给从库,从库收到后才删。在此之前,从库上的这个键虽然逻辑上已过期,但数据还在。
更微妙的是读行为:在旧版本里,从库读到过期键会返回数据(因为它自己不判断过期),这可能导致主从数据不一致的观感。较新的版本对此做了处理,从库读过期键时会返回空,但底层数据仍在,等主库的删除命令到达才真正清除。所以如果你在主从架构下做缓存,要意识到从库的内存回收会比主库滞后一些,容量规划时留点余量。
4. 内存淘汰策略:内存满了之后怎么办
4.1 八种淘汰策略的选择逻辑
当used_memory达到maxmemory上限,Redis 就会按maxmemory-policy配置的策略淘汰键。策略大致分几类:
| 策略 | 淘汰范围 | 挑选依据 | 适用场景 |
|---|---|---|---|
noeviction | 不淘汰 | 写入直接报错 | 数据不能丢的场景 |
allkeys-lru | 所有键 | 最近最少使用 | 纯缓存,推荐默认 |
allkeys-lfu | 所有键 | 访问频率最低 | 热点区分明显的缓存 |
allkeys-random | 所有键 | 随机 | 访问分布均匀 |
volatile-lru | 设了过期的键 | 最近最少使用 | 混合存储,保护永久键 |
volatile-lfu | 设了过期的键 | 访问频率最低 | 同上,按频率 |
volatile-random | 设了过期的键 | 随机 | 同上,随机 |
volatile-ttl | 设了过期的键 | 剩余时间最短 | 优先清理快过期的 |
选策略的核心问题是两个:你的数据能不能丢,以及你的键是不是都设了过期。如果 Redis 纯粹当缓存用,数据丢了能从数据库重建,那allkeys-lru是最省心的选择。如果 Redis 里混着一些不能丢的持久数据(比如某些配置、计数器),那必须用volatile-*系列,并且确保那些不能丢的键不设过期时间,这样淘汰只会发生在设了过期的键里。
noeviction要特别小心,它意味着内存满了之后所有写命令直接返回错误,读命令还能用。如果你的应用没处理好这个错误,可能表现为写入失败、数据丢失。除非你非常确定内存不会满,或者有完善的监控和告警,否则不建议在生产用noeviction。
4.2 LRU 和 LFU 的实际差异
LRU(最近最少使用)和 LFU(最不经常使用)听起来像,实际行为差别不小。LRU 看的是“多久没被访问”,一个键哪怕历史上被访问了一万次,只要最近一段时间没碰,就可能被淘汰。LFU 看的是“访问频率”,访问次数少的优先淘汰,能更好地保护长期热点数据。
Redis 的 LRU 不是精确实现,而是近似 LRU:它给每个键维护一个 24 位的时间戳字段(记录最近访问时间),淘汰时随机采样一批键,挑其中最久没访问的删掉。采样数量由maxmemory-samples控制,默认 5,调大能提高精度但增加 CPU 开销。LFU 则是给每个键维护一个访问计数器(带衰减机制,避免老数据一直占着高频位置),同样用采样近似。
我的经验是:大多数缓存场景allkeys-lru够用,实现简单、行为可预期。只有当你能明确区分“少数热点键承担了大部分访问”时,LFU 才有明显优势。盲目上 LFU 反而可能因为计数器维护带来额外开销,收益不明显。
4.3 maxmemory 该设多少
maxmemory不设的话,Redis 会一直吃内存直到系统 OOM。生产环境必须设。设多少取决于你的部署形态:如果是 Redis 独占一台机器,通常设成物理内存的 60% 到 75%,留出空间给系统、fork 时的写时复制(做持久化时 fork 子进程会额外占内存)、以及内存碎片。如果是容器里跑,要结合容器的内存限制来设,且要留出余量,因为容器 OOM 杀进程比 Redis 自己淘汰更粗暴。
还有个细节:maxmemory统计的是 Redis 自己认为的数据内存,不包括内存碎片和某些开销。实际进程占用的 RSS 可能比used_memory高不少。所以设maxmemory时不能贴着物理内存上限设,得留缓冲。我一般会观察一段时间的used_memory_rss和used_memory的比值,如果碎片率高,要么调整activedefrag相关配置,要么把maxmemory调低一些。
5. 大 key 与批量过期的实战处理
5.1 大 key 为什么是过期管理的隐形炸弹
大 key 指的是单个键对应的值特别大,比如一个几百万元素的 Hash、一个几十 MB 的 String。这类键在过期删除时会出问题:删除大 key 是耗时操作,如果是惰性删除,删除动作发生在访问路径上,会直接拖慢那次请求;如果是定期删除碰到大 key,可能造成主线程短暂阻塞。
更麻烦的是,如果你用DEL删除大 key,这个命令是同步的,删除期间 Redis 主线程被占住,其他请求全部排队。我见过一个场景,一个存了几十万字段的 Hash 到期被删,正好赶上流量高峰,直接导致几百毫秒的延迟抖动。
解决办法是用UNLINK代替DEL。UNLINK是异步删除,它只把键从键空间摘除,真正的内存回收交给后台线程慢慢做,主线程几乎不阻塞。从 Redis 4.0 开始支持,现在应该成为删除大 key 的默认选择。对于过期场景,虽然过期删除本身不受你控制,但你可以通过避免产生大 key来规避——把大 Hash 拆成多个小 Hash,用字段前缀或者哈希分片的方式分散。
5.2 批量设置过期时的打散技巧
前面提过集中过期的问题,这里给个具体的打散方法。假设你要缓存一万条商品信息,统一设 1 小时过期,可以这样处理:
import redis import random r = redis.Redis() def cache_product(product_id, data): base_ttl = 3600 # 在基础 TTL 上叠加 0 到 300 秒的随机偏移 ttl = base_ttl + random.randint(0, 300) r.set(f"product:{product_id}", data, ex=ttl)这个偏移量取多大,取决于你的键数量和能容忍的过期延迟。键越多,偏移范围可以越大,让过期时刻尽量均匀分布在一个时间窗口内。代价是部分键的实际存活时间比预期长一点,对缓存来说通常可以接受。
还有一种场景是“整批数据同时失效”,比如某个配置版本升级,旧版本的缓存要全部清掉。这时候不要用KEYS pattern去匹配删除,KEYS会全量扫描,键多时直接阻塞。正确做法是用SCAN游标分批遍历,配合UNLINK删除:
def batch_delete(pattern, batch_size=100): cursor = 0 while True: cursor, keys = r.scan(cursor=cursor, match=pattern, count=batch_size) if keys: r.unlink(*keys) if cursor == 0: breakSCAN的count是提示值不是精确值,实际返回可能多也可能少,但保证遍历完整。分批删除给主线程留了喘息空间,比一次性DEL安全得多。
5.3 监控过期与内存的关键指标
光会设过期不够,还得能看出来过期机制是否在正常工作。几个我常看的指标:
expired_keys:累计过期删除的键数量,持续增长说明过期在生效。evicted_keys:累计被淘汰的键数量,这个数在涨说明内存压力大,淘汰在兜底。keyspace_hits/keyspace_misses:命中率,命中率骤降可能是大量键提前过期或被淘汰。used_memory与used_memory_rss:前者是逻辑内存,后者是实际占用,两者差距大说明碎片多。
如果发现expired_keys不怎么涨但内存居高不下,可能是过期键没被访问也没被定期删除抽到,或者压根没设过期。如果evicted_keys持续增长,说明maxmemory设小了或者数据量超预期,得扩容或者优化存储结构。
6. 几个我踩过的坑和对应的处理思路
6.1 覆盖写导致过期时间丢失或残留
前面提过SET会清过期时间的问题,这里展开说。老版本里SET key value会清除该键原有的过期时间,变成永久键。如果你有个逻辑是“更新缓存值”,用SET覆盖,那原本设的过期就没了,这个键从此永久存在。修复方式是用SET key value KEEPTTL(Redis 6.0+)保留原过期时间,或者干脆每次覆盖都重新带上EX。
反过来,用INCR、APPEND、HSET这类命令修改值时,过期时间是保留的。所以如果你想让某个键“续期”,不能靠重新赋值,得显式再执行一次EXPIRE。这个不对称行为是很多 bug 的来源,我的做法是封装一个统一的缓存写入函数,把过期时间的处理收敛到一处,避免各处散落不一致的逻辑。
6.2 从库读到“已过期”数据的困惑
在主从架构下,如果读请求打到从库,而主库还没来得及同步删除命令,从库可能返回一个逻辑上已过期的值。这在旧版本里是明确的行为,新版本虽然读时做了判断返回空,但底层数据还在。如果你的业务对“读到过期数据”零容忍,要么强制读主库(牺牲扩展性),要么在应用层用数据自带的时间戳做二次校验。我倾向于后者,缓存值里带一个写入时间,读取时判断是否超过业务容忍的陈旧阈值,这样不依赖 Redis 的过期精度。
6.3 内存碎片让 maxmemory 判断失真
有段时间我发现used_memory离maxmemory还有距离,但系统内存已经很紧张了。查了used_memory_rss才发现实际占用远高于逻辑内存,碎片率很高。原因是频繁的增删导致内存分配器产生了大量碎片。处理方式有两种:开启activedefrag yes让 Redis 后台整理碎片(会消耗一些 CPU),或者重启实例(治标不治本)。更根本的是优化键的设计,避免频繁创建删除大小差异很大的键。碎片这个问题在长期运行、写入删除频繁的实例上尤其明显,值得定期关注mem_fragmentation_ratio这个指标。
6.4 过期时间设太短引发的缓存雪崩
这个坑很经典但依然常踩:大量键设了相同的短过期时间,到点集体失效,请求全部穿透到数据库,数据库扛不住。除了前面说的打散过期时间,还可以配合互斥重建:缓存失效时不是所有请求都去查库,而是让一个请求去重建,其他请求短暂等待或者返回旧值。Redis 层面可以用SET key value NX EX做简单的分布式锁来控制重建并发。这个思路在基础篇先点到为止,后面进阶篇会展开讲缓存穿透、击穿、雪崩的完整应对。
7. 把过期管理纳入日常运维习惯
7.1 上线前的过期策略检查清单
我现在每次涉及 Redis 的新功能上线,都会过一遍这个清单:
- 每个写入的键,是否明确了过期时间?永久键是否真的需要永久?
- 覆盖写的地方,是否确认了过期时间的预期行为(保留还是重置)?
- 批量写入的键,过期时间是否做了打散?
maxmemory和maxmemory-policy是否与业务的数据重要性匹配?- 是否有监控覆盖
expired_keys、evicted_keys、内存使用率和碎片率? - 删除大 key 的地方是否用了
UNLINK而非DEL?
这份清单看着琐碎,但每一条背后都是真实踩过的坑。尤其是第一条,很多内存泄漏的根源就是“忘了设过期”或者“以为设了其实没设上”。
7.2 用 TTL 做健康巡检
我习惯定期抽样检查键的 TTL 分布。如果发现大量键的 TTL 是-1(永久),而业务上它们本该是缓存,那就是泄漏信号。可以用SCAN抽样一批键,逐个查TTL,统计永久键的比例。这个巡检脚本很简单,但能提前发现很多问题。另一个思路是在写入层做约束,比如封装一个缓存客户端,默认强制带过期时间,只有显式声明“永久”才允许不设,从源头减少误操作。
7.3 过期不是万能的,容量规划才是根本
最后说个观念上的体会。过期机制和淘汰策略都是“事后补救”,真正决定 Redis 稳不稳的是容量规划。你得清楚自己的数据量级、增长速度、访问模式,据此设定合理的maxmemory和过期策略,而不是等内存报警了再手忙脚乱地调参数。我见过太多团队把 Redis 当无限容量的黑盒用,键只增不减,最后靠淘汰硬扛,命中率越来越低,性能越来越差。把过期管理当成数据生命周期设计的一部分,而不是一个随手加的配置项,这个转变比记住多少条命令都重要。
这一篇把键的过期、删除、淘汰这条链路捋了一遍,核心就一句话:过期时间是业务语义,淘汰策略是内存兜底,两者要配合设计,且都要建立在理解其底层机制的基础上。下一篇基础篇我打算聊持久化,RDB 和 AOF 的取舍、fork 的代价、以及怎么在数据安全和性能之间找平衡,那块的内容同样坑不少。