做后端开发这几年,谁没被 Redis 内存逼疯过几回。线上缓存明明设了过期时间,内存却一路飙到 maxmemory,紧接着就是写入报错、告警轰炸;还有更诡异的,key 明明还没到期,却被“提前”清空了,业务拿回一个 null 查了半天数据。这两个问题,其实都指向 Redis 最基础也最容易被轻描淡写带过的两个机制:过期删除策略和内存淘汰策略。
很多人把这两件事混为一谈,以为“key 过期了就该被删、内存满了就该淘汰”,但对底层细节一无所知。这篇是 Redis 系列第十七篇,我把这两块一次性讲透:底层怎么运作、八种淘汰策略各自的应用场景、maxmemory 相关参数的配置逻辑,以及我在实际项目里踩过的坑和排查套路。不管你是刚接触 Redis 的新手,还是已经被内存告警按在地上摩擦过的老伙计,这篇都能给你一套可以直接落地的思路。
1. 先搞清楚:过期删除和内存淘汰不是一回事
1.1 两个机制的分工
很多人默认认为“过期 key 会被自动删除、不需要管”,这话没毛病,但删除的时机和方式大有讲究。Redis 对过期 key 的处理,靠的是两套独立机制:过期删除策略决定“到期的 key 什么时候真正被释放”,内存淘汰策略决定“内存打满之后,哪些 key 先被牺牲”。
过期删除只针对设置了 TTL 的 key;内存淘汰针对的是整体内存水位,只要达到 maxmemory 上限就会触发,跟 key 有没有设置过期时间没有必然关系——具体看策略是 volatile 系列还是 allkeys 系列。
拿生活场景类比一下就非常清晰:过期删除像冰箱里的保质期检查,菜坏了、过期了,你随手把它扔了;内存淘汰像冰箱容量不够时,你要决定把哪些东西先扔出去——是扔放了好几天没动的剩菜(LRU),扔不常吃的酱料(LFU),还是随手乱扔(Random)。两条规则同时生效,但决策逻辑完全不同。如果只懂其中一个,遇到内存问题一定是一头雾水。
1.2 你以为的“到期即删”并不存在
新手最容易踩的坑,就是以为 TTL 归零的那一刻,key 就从内存里消失了。实际上 Redis 并不保证“到期瞬间删除”,它只保证“到期之后这个 key 对你不可见”。也就是说,你去 GET 一个已过期的 key 会得到 nil,但这块内存可能还占着,直到惰性删除或定期删除真正把它清掉。
这个认知非常关键,否则排查内存问题时,你会被“明明 key 都过期了,used_memory 怎么还不降”这种事活活绕晕。Redis 的一切设计都在 CPU 开销和内存回收之间做折衷,过期删除也不例外。
2. 过期删除策略:Redis 如何清理到期的 key
2.1 惰性删除:访问时才动手
惰性删除的逻辑非常直白:每次在执行读写命令访问某个 key 之前,Redis 会先检查它是否在 expires 字典里,并且是否已过期。如果过期了,先删除这个 key,再继续执行后续逻辑。
这就像你冰箱里的菜,只有你打开冰箱想拿它的时候,才发现它坏了、扔掉。好处显而易见:CPU 开销极小,不访问就不处理,对性能几乎没有任何影响。坏处同样明显:如果一个过期的 key 长期不被访问,它就会一直占着内存,直到内存彻底吃紧。所以 Redis 不可能只靠惰性删除,必须配合主动清理机制。
这里还要注意一个细节:惰性删除不只是对 GET、SET 这类普通命令生效,像 EXISTS、TTL 这类元数据命令也会触发检查。如果你只是想知道一个 key 还在不在,用 EXISTS 也会顺带把它清理掉,这在排查线上问题时需要心里有数。
2.2 定期删除:后台巡逻队
为了弥补惰性删除的“懒”,Redis 的 serverCron(主循环定时任务)会周期性启动 activeExpireCycle,从 expires 字典里抽样扫描过期 key。整体流程是这样的:
- 从 expires 字典里随机抽取一批 key(默认 20 个);
- 逐个检查这批 key 是否已过期,过期的直接删除;
- 如果这批 key 里过期比例超过 25%,说明当前过期密度高,继续重复新一轮扫描;
- 如果比例低于 25%,本轮结束,等下一个周期再跑。
serverCron 的执行频率由 hz 参数控制,默认值是 10,也就是每秒执行 10 次。注意这里说的是“每秒最多 10 轮扫描”,每一轮里具体扫多少、扫多久,还要受 CPU 时间预算限制。所以定期删除并不是全量扫描,而是抽样式推进,目的就是把 CPU 开销摁在一个可控范围内。
Redis 7.x 对定期删除做了进一步优化,引入了更细粒度的 CPU 限流机制,整体比老版本平滑很多。如果你是 5.x 升级上来的老用户,能明显感受到大量 key 同时过期时的卡顿现象有所缓解。
2.3 双管齐下也有短板
有了惰性 + 定期这套组合,过期 key 基本能在可接受的时间窗口内被清理干净。但两个场景还是会出现问题。
第一个场景:大量 key 在同一秒过期。比如你缓存了一批数据,统一设置 1 小时过期,那么整点那一刻,Redis 要处理的过期 key 数量会瞬间激增。activeExpireCycle 会因此连续执行多轮扫描,CPU 出现明显尖峰,命令延迟也跟着抖。
第二个场景:过期 key 占比很大,而定期删除来不及清完。这时候过期 key 虽然逻辑上已经不可见,但物理内存迟迟不释放,导致 used_memory 虚高。如果此时内存水位又逼近 maxmemory,淘汰策略就会插手,把没来得及删除的 key 当成可牺牲对象处理掉。
所以实践里很反直觉的一点是:过期时间不是设得越整齐越好,反而要刻意加随机抖动,让过期时间分散开。这个操作听着简单,能少踩一半的缓存雪崩坑。
3. 内存淘汰策略:内存满了之后谁能活
3.1 触发前提:maxmemory 门槛
内存淘汰不是默认开启的。Redis 64 位系统的 maxmemory 默认值是 0,代表不限制内存使用。也就是说,如果你什么都不配置,Redis 不会主动约束自己,内存会一路吃上去,直到操作系统 OOM 把它杀掉。
生产环境必须显式设置上限,写在 redis.conf 里:
maxmemory 4gb也可以在线调整,不用重启:
CONFIG SET maxmemory 4gb有个经典误区特别值得说:很多人以为 maxmemory 的单位是 MB,实际上 Redis 配置允许带单位,4gb、4096mb都行,不带单位时就是裸字节。我见过有人写maxmemory 4096,结果 Redis 只允许用 4KB 内存,整个服务瞬间写不进去任何数据,现场直接雪崩。这种错误真不是段子,是真实生产事故。
3.2 八种淘汰策略逐一拆解
从 Redis 4.0 起,淘汰策略一共 8 种,由 maxmemory-policy 配置指定。先上总览表:
| 策略 | 作用范围 | 淘汰依据 | 典型场景 |
|---|---|---|---|
| noeviction | 无 | 不淘汰,写入报错 | 数据禁丢、强一致场景 |
| allkeys-lru | 全部 key | 最近最少使用 | 通用缓存,最常用 |
| volatile-lru | 仅设了 TTL 的 key | 最近最少使用 | 缓存 + 持久数据混合 |
| allkeys-random | 全部 key | 随机 | 访问均匀、无明显热点 |
| volatile-random | 仅设了 TTL 的 key | 随机 | 热点不明显的临时数据 |
| volatile-ttl | 仅设了 TTL 的 key | 剩余 TTL 最短 | 优先保留新写入数据 |
| allkeys-lfu | 全部 key | 最不常使用 | 热点集中、长尾明显的访问 |
| volatile-lfu | 仅设了 TTL 的 key | 最不常使用 | 热点集中的过期数据 |
逐个说清楚背后的逻辑,不然后面选型就是瞎蒙。
noeviction 是 Redis 出厂默认策略。内存达到上限后,所有会导致内存增长的写命令(SET、LPUSH、ZADD 等)都会返回OOM command not allowed when used memory > 'maxmemory',读命令照常服务。这个策略适合不允许任何淘汰、宁可报错也不能丢数据的场景,比如存分布式锁、存关键配置。代价是你必须严格控制写入量,不然服务会频繁拒绝写入,相当于给自己加了一道紧箍咒。
allkeys-lru 是目前最通用的缓存策略。所有 key 一视同仁,内存吃紧时优先淘汰“最近最少被访问”的 key。对大多数“缓存一切”的业务,这是最省心的选择,也是我在纯缓存场景下的首选。
volatile-lru 和 volatile-ttl 只针对设置了过期时间的 key。它们有一个非常隐蔽的隐患:如果业务里存在大量“永不过期”的 key,那么内存打满时,被淘汰的永远只是那部分设了 TTL 的缓存 key,持久 key 稳如泰山。反过来看,这也是一种保护手段,适合内存里混合存放“缓存数据”和“强一致数据”的场景。
allkeys-random 和 volatile-random 就更好理解了。访问模式特别均匀、没有热点时,随机淘汰能绕开 LRU 近似算法的误差,而且开销最小。说实话我日常几乎不用,但它确实在某些特殊负载下比 LRU 合理,因为 LRU 依赖采样近似,对“所有 key 访问频率差不多”的场景反而可能误判。
allkeys-lfu 和 volatile-lfu 是 Redis 4.0 加入的。LFU 统计的是“访问频率”而不是“最近访问时间”。这解决了 LRU 一个经典缺陷:某个 key 曾经被高频访问,但已经很久没用了,按 LRU 它会被优先淘汰;而按 LFU 它的历史热度很高,会被保留。反过来,如果一个 key 刚被访问一次,LRU 会认为它很“新”从而保它,LFU 则认为它频率太低、应该被淘汰。
这里有个关键细节:LFU 不是绝对计数,而是用了近似实现。它通过一个对数递增的计数器记录访问频次,配合衰减因子让老热度随时间下降,具体由 lfu-log-factor 和 lfu-decay-time 两个参数控制。前者控制计数器增长速度,后者控制多少分钟没访问就衰减一次。生产环境基本不需要动这两个参数,但你要知道它们存在,否则观察 LFU 表现时你会看不懂为什么某个高频 key 会被淘汰。
3.3 LRU 和 LFU 的“近似”不是玄学
需要澄清一个被反复误解的点:Redis 的 LRU 并不是教科书里那种严格 LRU。它不会维护一个全量的“访问时间排序链表”,而是采用采样近似。
每次需要淘汰时,Redis 从内存里随机抽取 maxmemory-samples 个 key(默认 5 个),比较它们的空闲时间(idle time),淘汰掉最久没被访问的那个。采样数量决定了近似精度:采样越多,淘汰结果越接近真实 LRU,但抽取和比较本身也有 CPU 开销。实测中把 maxmemory-samples 调到 10,淘汰准确度在绝大多数工作负载下已经很接近真实 LRU 了。
LFU 同理,靠的是记录每个 key 的访问频率字段,再用衰减和裁剪模拟出“近期使用频率”的排序。
所以网上有人争论“Redis 的 LRU 不准确”,这其实不是 bug,是设计上的故意折衷。全量排序的 LRU 需要大量额外内存和时间来维护,对 Redis 这种主打高性能的内存数据库来说不划算,抽样近似是性价比最优的解。理解这一点,你就不会在调优时钻牛角尖,也不会迷信某些“百分百精确 LRU”的第三方方案。
4. 配置参数与业务场景选型
4.1 核心配置项一览
除了 maxmemory 和 maxmemory-policy,还有几个配套参数会直接影响淘汰行为,整理成表:
| 配置项 | 默认值 | 作用 |
|---|---|---|
| maxmemory | 0(不限制) | 内存使用上限 |
| maxmemory-policy | noeviction | 淘汰策略 |
| maxmemory-samples | 5 | LRU/LFU/TTL 近似算法的采样数 |
| maxmemory-eviction-tenacity | 10 | Redis 7.0+,淘汰执行强度(0~10) |
| lfu-log-factor | 10 | LFU 计数器增速因子 |
| lfu-decay-time | 1 | LFU 计数器衰减周期(分钟) |
maxmemory-eviction-tenacity 是 Redis 7.0 新增的参数,控制内存到达上限后,淘汰循环对命令延迟的影响程度。值调低,Redis 每处理一批命令只做更克制的淘汰,命令延迟更稳定,但内存回落变慢;值调高,淘汰更激进,内存能快速降下来,但可能带来延迟抖动。默认 10 表示按原有的快速清退方式处理。如果业务对尾延迟极敏感,可以实验性调低,观察 evicted_keys 曲线和延迟指标再做决定。
在线查看当前生效配置用这几个命令:
CONFIG GET maxmemory CONFIG GET maxmemory-policy CONFIG GET maxmemory-samples INFO memory4.2 不同场景怎么选
我给自己定的选型原则很简单:先问一句“这个 Redis 里到底存的是什么”。
纯缓存场景,比如给数据库查询结果做缓存、给接口响应做缓存,选 allkeys-lru 或 allkeys-lfu。热点数据集中、访问频率差异很大、长尾明显的,用 lfu;热点随着时间迁移、今天热明天冷的,用 lru。
缓存和持久数据混合场景,比如缓存 + 分布式锁、缓存 + 队列任务,锁和队列数据不能丢,用 volatile-lru 或 volatile-ttl,把不可丢的数据设为永不过期,淘汰永远只落在缓存数据上。这种“无 TT L 保护伞”的思路,比在代码里加各种判断要干净得多。
数据绝对不允许丢、宁肯暂时不写入也不淘汰,用 noeviction,同时必须配套内存监控告警。这个策略本质上不是给正常业务用的,它是一个安全阀,防止错误配置导致数据大面积无声蒸发。
还有一个常见的组合操作:全局用 allkeys-lru,但对个别核心 key 可以用 OBJECT idletime 观察空闲情况,必要时用一次触摸或重写来刷新它的热度。注意,Redis 没有“给单个 key 设置不准淘汰”的属性,这是很多人的认知误区。
4.3 配置失误的典型后果
选错策略或者没设 maxmemory,后果是实打实的,我讲一个真实案例。
某个团队把 Redis 当成消息队列用,list 无限增长,maxmemory 没设,结果系统内存耗尽后,操作系统直接把 Redis 进程 OOM Kill 了。重启后 RDB 恢复瞬间内存就要打满,又触发新一轮淘汰,大量未消费的队列消息被随机策略无差别清掉,业务丢消息丢得莫名其妙。这个事故本质上犯了两层错:第一,没设 maxmemory,让 Redis 一路吃到 OOM;第二,恢复后的淘汰策略不区分业务数据,把队列消息也当缓存清掉了。如果当时用 noeviction + 显式告警,消息至少不会默默蒸发,业务能及时感知并介入。
另一个高频错误:选了 volatile-* 系列策略,但代码里习惯性给所有 key 都设过期时间,结果所有数据全被当成“可淘汰对象”。一旦内存打满、缓存集中失效,核心数据也跟着大面积被清掉。罪魁祸首就是“无脑全设 TTL”的坏习惯。正确做法是,写代码前先明确每个 key 是纯缓存还是持久数据,再决定要不要设置过期时间。
5. 踩坑记录与排查实战
5.1 高频问题与定位思路
问题一:key 明明还没到过期时间却查不到,或者过期了却偶尔还能查到。先说后者。主从复制架构下,主节点删除过期 key 时会向从节点发送 DEL 命令,所以从节点的删除在时间上是滞后的。如果客户端读的是从节点,就可能短暂读到已过期数据。Redis 5.0 起从节点返回过期 key 时,会为了逻辑一致性返回 nil,但物理删除仍依赖主节点的 DEL 命令。排查长尾过期数据时,一定要结合主从拓扑来看,别只盯着单节点。
问题二:设置了过期时间,内存却不释放。前面已经解释过,惰性删除 + 定期删除都不是即时全量清理,大量过期 key 没有被访问、定期抽样又没抽中,内存就“虚高”了。此时你可以手动排查,用 SCAN 配合 TTL 找出已过期但仍占内存的 key,再用 UNLINK 异步删除清理。UNLINK 不会阻塞主线程,适合大批量清理场景。
问题三:写入报OOM command not allowed。这是 noeviction 策略 + 内存打满的标准表现。先用 INFO memory 看 used_memory 和 maxmemory 的关系,再配合redis-cli --bigkeys扫大 key,优先处置大对象。千万别一上来就重启,重启不等于解决内存问题,RDB 加载回来,内存一样打满,问题只会换个姿势再来一遍。
5.2 监控与工具链
排查内存问题,我习惯按这个顺序来:
INFO memory INFO stats redis-cli --bigkeys redis-cli --memkeysINFO memory 里重点看 used_memory、used_memory_peak、maxmemory 和 mem_fragmentation_ratio。碎片率长期大于 1.5,说明内存碎片问题严重,要考虑开启 activedefrag 让它热整理。
INFO stats 里重点看 evicted_keys 和 expired_keys 两个计数。evicted_keys 持续快速上升,说明淘汰在频繁发生,要么内存不够用,要么缓存命中率在崩;expired_keys 平稳增长是正常现象,突然暴增则说明大量 key 在同一时刻过期,大概率是过期时间设置得太齐整了。
观察单个 key 可以用 OBJECT 命令:OBJECT freq key查看 LFU 访问频率,OBJECT idletime key查看空闲秒数。结合这两个值和当前策略,就能定位一个 key 是不是“淘汰嫌疑人”。
5.3 一次线上内存告警复盘
挑一次典型的线上排查过程说。当时一个 4G 的实例 used_memory 逼近上限,evicted_keys 每分钟上千,缓存命中率肉眼可见地往下掉。我第一反应不是扩内存,而是先搞清楚谁在写入。
用 INFO keyspace 看各库 key 数量,发现某个业务库 key 数量不算夸张,但 INFO memory 显示 used_memory 异常高。接着用--bigkeys扫了一遍,发现一个 value 高达 300MB 的 hash。顺藤摸瓜查代码,发现是那边把大报文直接塞进了 hash 的某个 field,一次写入就是几个 MB。在 allkeys-lru 策略下,大量小 key 频繁被淘汰、正常缓存不断失效,而那个大 key 因为体积太大,很难被完全淘汰出去,形成恶性循环。
处理方案分三步:第一,把大报文拆出去,改用独立对象存储并做分片;第二,给每个小 key 设置合理的过期时间并加随机抖动;第三,把 maxmemory-samples 从 5 调到 10,降低淘汰误判率。改完再观察,evicted_keys 掉到个位数,命中率恢复,内存水位也稳住了。
这个案例最值得记的一点:allkeys-lru 不是万能的,它只负责决定淘汰哪个 key,根本不管 key 的体量。业务上不控制 key 的大小,再好的淘汰策略都白搭。
6. 从实践里提炼的几条通用建议
6.1 过期时间别“一刀切”
批量设置过期时间时,我习惯在基础值上乘一个 0.8~1.2 的随机系数,或者加一个固定抖动区间。比如基础过期 3600 秒,实际设置就在 2880 到 4320 秒之间随机取。这样极端峰值出现的时间会被打散,从根源上缓解缓存雪崩和定期删除的 CPU 尖峰。这个技巧成本极低,收益非常明显。
6.2 删 key 优先用 UNLINK
很多人还在用 DEL 删大 key,这是真亏。DEL 是同步删除,一个几 MB 的 string 可能让 Redis 阻塞几十毫秒甚至更久;UNLINK 把内存释放动作丢到后台线程,主线程不卡。Redis 4.0 起 UNLINK 就可用,日常开发中凡是可能删大 key 的地方,我都建议直接用 UNLINK,几乎没有副作用。
6.3 配置变更要谨慎落地
线上调整 maxmemory-policy 之前,先确认 redis.conf 可写,再用 CONFIG SET 临时修改观察指标,确认没问题后执行 CONFIG REWRITE 固化。别直接在配置文件上改完就 restart,那会把 RDB 恢复、重启预热这些变量全搅进排查过程,问题定位难度直接翻倍。另外,CONFIG SET 是即时生效的,操作前要确认当前实例的配置基线,免得改错了还要花时间回滚。
7. 与过期、淘汰强相关的几个延伸话题
7.1 键空间通知的事件延迟
如果你的业务需要监听 key 过期事件,比如订单超时关单,用的是 keyspace notifications 的 expired 事件。但要注意,Redis 的过期事件不是 TTL 归零那一刻发布的,而是删除动作真正发生时才会发布。惰性删除发生在访问时,定期删除发生在扫描到的那一轮,所以事件天然有延迟。高精度关单业务别把所有希望押在这上面,延迟不可控。如果必须用,要评估事件延后、偶发丢失对业务的影响,最好配合其他补偿机制。
7.2 持久化与主从复制下的差异
RDB 持久化时,已过期的 key 不会写进 dump 文件;AOF 持久化时,key 过期后会在合适时机追加 DEL 命令。主从复制场景下,过期删除以主节点为准,从节点的清理依赖主节点发来的 DEL 命令。所以从 RDB 恢复数据时,理论上不会恢复已过期 key,但在主从切换、故障恢复这类边界场景里,从节点可能短暂保留过期数据。做灾备演练时,这类细节要提前设计好。
7.3 托管实例的默认策略陷阱
不少云厂商的托管 Redis 会把 maxmemory-policy 默认设成 allkeys-lru 或 volatile-lru,方便客户开箱即用。但如果你在同一个实例里混存了缓存数据和业务队列数据,默认的 allkeys 策略可能会把队列数据当普通 key 淘汰掉,造成消息丢失。拿到新实例的第一件事,就是确认 maxmemory-policy 到底是什么,而不是等线上丢数据了才回头查。
8. 最后说点实在的
我在实际维护 Redis 的过程中,最大的体会是:内存淘汰和过期删除这两个机制,拼的不是“背下八种策略的名字”,而是想清楚自己每一类数据的生命周期和重要程度。你给 key 设的每个 TTL、给实例设的每个 maxmemory 数值,本质都是在和 Redis 签一份“内存优先权契约”。谁优先、谁垫底,都应该在业务设计阶段就明确,而不是等告警来了再救火。我建议每个团队在 Redis 上线前,把策略选型、TTL 规范、大 key 治理这三件事定成铁律,能省掉无数个深夜。
最后再分享一个小技巧:每次处理完这类内存问题,我都习惯把 INFO memory、evicted_keys、expired_keys 这几个关键指标存档,配上时间点和处理备注。攒上几个月,你就能摸清自己业务的内存波动规律,很多问题还没变成告警,你就能提前预判到。这种手感,比任何监控平台里的花哨大盘都好使。