news 2026/10/12 3:35:09

Redis内存淘汰机制深度解析:从近似LRU到生产调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis内存淘汰机制深度解析:从近似LRU到生产调优

有一回凌晨两点,我正睡得迷糊,手机突然被项目群里的告警刷屏。Redis内存使用率顶到 100%,业务接口开始大面积报错,数据层的连接池被打满。等我把服务捞回来再看了一眼配置,maxmemory-policy赫然还是默认值noeviction。也就是说,内存满了之后所有写操作直接失败,而不是丢掉几个“不太重要的 key”那么简单。那次事故之后,我把 Redis 的内存淘汰机制从头捋了一遍,才发现平时大家挂在嘴边的 LRU,在 Redis 里其实是一套相当精巧的近似实现,和教科书里的经典 LRU 完全是两码事。这篇文章就把 Redis 的 LRU 以及它周围的淘汰策略讲透,包括原理、配置、源码逻辑、生产参数调优,以及我踩过的几个比较典型的坑。适合正在使用 Redis 做缓存、或者准备深入理解 Redis 内存管理的开发者阅读。

1. 内存满之前,Redis 其实什么都没做

很多同学对 Redis 淘汰机制的理解是从“内存满了之后开始删 key”开始的,但 Redis 真正的内存管控链路比这个要早得多。我们常说的 LRU 只是maxmemory这整套机制里的一个决策器:先有“内存上限”这个前提,才会触发后面的“选谁淘汰”。

1.1 maxmemory 才是整个淘汰机制的起点

Redis 默认情况下maxmemory是 0,表示不限制内存使用。生产环境我们一般会在 redis.conf 里显式配置:

maxmemory 4gb

也可以运行时动态调整:

redis-cli CONFIG SET maxmemory 4gb

设置完之后,Redis 会监控自身的used_memory,一旦达到这个值,后续命令的处理流程就会进入“是否需要释放内存”的判断逻辑。注意这里有个经常被忽略的细节:used_memory计算的并不只是 key 和 value 的大小,还包括 Redis 自身的数据结构开销、客户端输出缓冲区、复制积压缓冲区、Lua 脚本缓存等。所以一台机器上你估算的“数据量”和 Redis 实际占用的内存往往有出入,特别是小 key 特别多的时候,内存碎片率和 dict 结构开销会明显拉高 used_memory。

1.2 过期删除和内存淘汰是两回事

这是我在排查问题时发现很多人的混淆点。Redis 里有两套完全不同的“删除”机制:

  • 过期删除:针对设置了 TTL 的 key,时间到了之后通过惰性删除或定期删除来清理。它的目的是“让过期的数据别占着内存”,跟内存是否紧张没关系。
  • 内存淘汰:指 reached maxmemory 之后,为了继续服务写请求,被迫删除一些 key。它不关心 key 是否过期,只关心“怎么腾出内存”。

LRU 属于后者。很多人会以为“我设置了 expire,内存满了 Redis 就会自动把过期的删掉来缓解压力”,这其实是一个危险的想法。如果内存里大部分 key 都没有设置过期时间,expire 机制根本帮不上忙;而淘汰策略又会按自己的一套规则去选 key,两者并不是一回事。

这套机制还有一个触发时机上的特点:内存检查发生在处理命令之前,并且只针对可能增加内存的写命令。也就是说,内存已经满了的时候,读命令照常执行,而写命令会因为无法腾出内存而直接报OOM command not allowed when used memory > 'maxmemory'。这一点在生产事故中非常致命——用户读请求还行,写入全部失败,表现看起来就像服务挂了。

2. 标准 LRU 为什么没有直接被 Redis 使用

如果你第一次接触 LRU,教科书上的经典实现大概是这样的:一个哈希表负责 O(1) 定位 key,一个双向链表维护访问顺序。每次访问一个 key,就把它从链表中摘出来放到头部;链表尾部就是最久没被访问的 key;内存不够时直接把尾部干掉。

这套方案在理论上是完美的,在 Redis 的场景里却几乎不可行。

2.1 全量维护顺序的内存代价太高

Redis 的定位是“内存数据存储”,它对内存的敏感程度远超常规应用。单实例存上亿个 key 在生产里并不罕见,如果每个 key 都要维护一个双向链表节点,每个节点至少要有前驱、后继两个指针,额外的开销会非常大。有人估算过,经典 LRU 在一个大 key 集合下的维护开销可能让 Redis 的内存占用上涨一个可观的比例,这对一个“用内存换速度”的组件来说太奢侈了。

而且 Redis 是单线程事件循环模型,每次访问 key 都要做一次指针搬移操作,等于在热路径上加了一堆不必要的步骤。采样淘汰的思路则完全避开了这个开销:平时不动任何顺序结构,只有内存真正满了、要删 key 的时候,才临时去找“相对最久没访问的那个”。Redis 选择了用空间换决策成本的策略——我对 LRU 的理解是:淘汰质量可以稍微打折,但每个 key 的额外内存绝对不能膨胀。

2.2 24bit 里藏着访问时间

Redis 是怎么知道某个 key 有多久没被访问的呢?每个 redisObject 对象头里有一个lru字段,原本固定是 24bit。在 LRU 模式下,它保存的是 server 端的lruclock——一个按分钟更新的近似 unix 时间戳。key 被访问时,就把当时的lruclock写进自己的lru字段;之后想判断它老不老,用当前lruclock减去这个值就能得到“空闲时长”。空闲时间越长,说明这个 key 越可能短期内不再被访问,也越应该被淘汰。

因为只存了 24bit,时钟精度有限,而且时钟回绕时 Redis 采用了近似算法处理。不过淘汰决策本身只需要相对顺序,不需要精确到秒级时间。这也是 Redis 号称“近似 LRU”的原因之一——从时间度量上它就已经往“够用”靠拢了。

2.3 Redis 3.0 的抽样池改革

早期的近似 LRU 就是简单的随机抽样:内存不够时随机取maxmemory-samples个 key,默认 5 个,比较谁的 idle 时间最长,然后淘汰最旧的那个。问题很明显:样本数太少,选出来的“最旧 key”跟全局真正最久未访问的 key 相差可能非常大。5 个里面挑一个,运气不好的时候全挑到“年轻 key”。

Redis 3.0 做了一个很重要的改进:引入了一个淘汰候选池,默认容量 16。每次采样得到的 key 先放进池子里,池子满了就用 idle 时间更长的 key 替换掉池子中较新的 key。最终从池子里挑 idle 最长的 key 淘汰。这样即使单次只采样 5 个 key,经过多轮淘汰之后,池子里的候选也会慢慢逼近全局最久未访问的那批 key。官方的数据是,调整好采样数和池子大小后,近似 LRU 的淘汰结果已经能非常接近标准 LRU。这也是后来我们调参时最值得关注的一个设计。

3. 七个淘汰策略的定位:别只看名字就选 allkeys-lru

Redis 4.0 之前,maxmemory-policy有六种:noeviction、allkeys-lru、volatile-lru、allkeys-random、volatile-random、volatile-ttl。4.0 之后又加入了allkeys-lfu和volatile-lfu。名字长得很像,实际语义差别很大。

策略作用范围淘汰依据典型使用场景
noeviction不淘汰无宁可写失败也不能丢数据的场景
allkeys-lru所有 key空闲时间最长者优先通用缓存场景,最常用
volatile-lru设置了 TTL 的 key空闲时间最长者优先只希望淘汰带过期时间的会话类数据
allkeys-random所有 key随机访问分布非常均匀、无热点场景
volatile-random设置了 TTL 的 key随机带 TTL 且访问分布均匀的数据
volatile-ttl设置了 TTL 的 key剩余存活时间最短者优先理论上合理,实际误差大,慎用
allkeys-lfu所有 key访问频率低者优先热点集中、访问频率倾斜明显的场景
volatile-lfu设置了 TTL 的 key访问频率低者优先带 TTL 且需要按热度的场景

3.1 volatile 系列的一个隐藏陷阱

volatile-*策略只会在“设置了过期时间的 key”里选淘汰对象。这听起来很安全——没设置 TTL 的 key 怎么也不会被动,适合用来保护一些重要数据。但反过来,如果你的业务里大部分 key 都没有设置过期时间,那就意味着可淘汰的集合非常小,内存满的时候可能根本挑不出几个能删的 key,淘汰能力形同虚设。

我之前接手过一个服务,内存眼看要爆,团队说“已经开了 volatile-lru 应该没问题”。结果一查,整个实例里带 TTL 的 key 只占了不到 5%,剩下全是常驻数据。淘汰策略每次都只能从那 5% 里挑,内存还是在持续上涨,直到写请求 OOM。所以选策略之前,先统计一下自己的 key 里有多少是设置了 expire 的,这决定了 volatile 系列到底有没有用。

3.2 LFU 是 LRU 的有力补充

LRU 有一个天生的盲区:一个 key 可能曾经很热,被频繁访问过,但最近一段时间没人碰它。按 LRU 的空闲时间来判断,它会很容易被淘汰。可实际上这类 key 一旦活下来,下一次查询可能又是高频率的。LFU(Least Frequently Used)统计的是访问频率而不是最近访问时间,它能更好地保留这种“低频但重要”的 key。

Redis 4.0 的 LFU 实现比较巧妙。前面说的 24bit 的lru字段,在 LFU 模式下会被拆成两部分:高 16bit 存上次衰减时间,低 8bit 存一个对数计数器。计数器不是简单的线性加一,而是按概率递增,这样高频热点的计数值增长会逐渐变慢,避免了溢出;同时lfu-decay-time控制热度随时间衰减的速度,默认 1 分钟衰减一次。这样既记录了“多久没访问”,又记录了“历史访问频率”,对热点数据的保护比 LRU 更贴合实际。

4. 从源码视角看一次淘汰是如何发生的

理解了策略选择,我们再往底层走一层。Redis 的淘汰逻辑主要集中在freeMemoryIfNeeded这个函数里,它在每次可能增加内存的命令执行前被调用。理解这段逻辑,很多看似玄学的表现就能解释清楚了。

4.1 触发链路:processCommand 到 freeMemoryIfNeeded

Redis 在处理客户端命令时,大部分命令都会走processCommand。这里会检查server.maxmemory是否配置,以及当前used_memory是否超过限制。一旦超限,Redis 会进入释放内存循环:

  1. 计算当前需要释放的内存差额。
  2. 根据maxmemory-policy分批采样候选 key。
  3. 从候选里挑一个“该淘汰”的 key,删除并释放内存。
  4. 重新计算内存,如果还不够就继续循环。

这里有个关键行为:淘汰过程是同步的、在主线程里进行的。也就是说,当内存超限、正在腾空间的时候,Redis 是会短暂阻塞在那里的。如果一次淘汰的 key 很大,这个阻塞时间会被拉长。这也是后面要讲到的大 key 坑的根源之一。

4.2 采样随机性与池子维护

采样环节对应的是dictGetRandomKeys,它是基于哈希表随机桶来取 key 的。哈希表本身是链式结构,随机取桶可能顺带把桶内冲突的几个 key 一起带出来,所以每次采样得到的不仅仅是严格的 N 个独立随机 key,而是一小撮“互相关联”的 key 集合。这种随机性在绝大多数情况下不构成问题,但在极端热点场景下会影响淘汰公平性,属于非常细节的边界情况。

拿到的样本会进入淘汰候选池。池子在 key 较多、多次淘汰的场景下帮助很大:第一次淘汰可能没找到足够“旧”的 key,但第二次、第三次采样时,池子里留着之前淘汰剩下的“潜力选手”,配合新样本不断更新,最终挑出的往往是很接近全局最久未使用的那个 key。这也是为什么建议在淘汰频繁的实例上适度调大maxmemory-samples,能让池子里的候选质量整体提升。

4.3 淘汰和过期清理之间微妙的关系

在自由内存的过程中,如果目标 key 已经到期但还没被惰性删除,Redis 也会顺手把它清掉。这带来一个现象:某些实例明明没到内存上限,可INFO stats里的expired_keys和evicted_keys数字同时在涨,原因就是两者在同一个内存压力场景下发生了重叠。不过要注意,过期 key 的内存必须等实际删除动作发生后才被回收,单纯“时间到了”并不会立刻降低 used_memory。所以如果你的实例里堆积了大量已过期但还未被清理的 key,它们在内存里的占用会继续参与淘汰计算,导致内存看起来比实际活跃数据更高。

5. 生产配置建议与验证手段

理论讲完,接下来是我在实际环境里验证过的配置和监控方案。任何调参都不能拍脑袋,你要先有监控数据,再动配置。

5.1 一套通常不错的起步配置

maxmemory 4gb maxmemory-policy allkeys-lru maxmemory-samples 10 lfu-decay-time 1 lfu-log-factor 10 lazyfree-lazy-eviction yes

对大多数缓存类业务来说,allkeys-lru是最省心的:不用管 key 有没有设置过期时间,统一按访问热度淘汰。maxmemory-samples我建议从默认的 5 提到 10,淘汰质量会明显改善,付出的 CPU 代价在绝大多数场景可以忽略。但如果你的写 QPS 特别高,每一步都要注意,实测下来 10 通常是个安全的上限。

lazyfree-lazy-eviction yes这个很多人会忽略。前面说过淘汰是同步的,开启这个配置后,如果淘汰的大 key 来自大的 value,Redis 会把内存释放动作放到后台线程去执行,避免主线程卡顿。前提是 value 结构本身可以异步释放,这一点 Redis 内部做了判断,所以我们尽量开启它作为保险。

5.2 动态调整而不是直接改配置文件

Redis 的好处是大部分配置都能在线修改,不需要重启。我习惯先在测试环境用CONFIG SET调好一套参数,观察一段时间,再在生产上CONFIG SET一次,最后通过CONFIG REWRITE把有效参数写回 redis.conf。这里特别提醒:直接改配置文件再重启,风险远高于动态调整;尤其是 maxmemory-policy 这种直接影响数据存活的配置,改完立刻生效可能瞬间触发大规模淘汰,要确保业务能接受。

5.3 两级监控指标

我一般把 Redis 内存淘汰的监控分成两个层级:

  • 第一级看内存水位本身:used_memory与maxmemory的比值,超过 80% 就要关注,超过 90% 需要预警。
  • 第二级看evicted_keys的变化速率。单看淘汰总数意义不大,重点看它是否在短时间内突然增长。如果某个时段evicted_keys曲线出现陡增,说明写入流量突然变大,或者某些 key 的访问模式发生了变化。

命中率指标也要一起看:

redis-cli info stats | grep keyspace

keyspace_hits和keyspace_misses的比值就是整体缓存命中率。命中率下降搭配 evicted_keys 上涨,基本可以判断是淘汰策略在选择上出了问题。通过这三类指标,我才能在调参时知道是往 sample 上调,还是往 LFU 上迁移。

6. 我踩过的淘汰策略坑:从误删会话到大 key 阻塞

最后一部分,分享几个我在真实项目里遇到过的和淘汰相关的问题,基本都是经验之谈。阅读的时候你可以对照自己的场景,看看有没有类似的隐患。

6.1 默认 noeviction 不是“安全”,而是“硬崩溃”

文章开头的事故就是 noeviction 造成的。当时我们以为 Redis 里全是缓存数据,丢了也就丢了,但实际内存满之后 Redis 的表现不是“开始丢缓存”,而是“拒绝所有写操作”。如果上游没有做降级,缓存写入失败会一路引发连锁反应。

经过那次事故我给团队的结论是:如果你的 Redis 定位是缓存,maxmemory-policy必须显式配置成 allkeys-lru 或 allkeys-lfu,绝对不能停在默认值。反过来,如果你的 Redis 里存了不能丢的数据,宁可用 noeviction 也别用 allkeys-lru,因为后者会在内存压力时把“重要 key”和“普通 key”一视同仁地淘汰掉。关键是想清楚你的 Redis 是什么角色,再决定要不要给淘汰开门。

6.2 volatile-lru 在“没设过期时间的 key 占多数”时相当于白配

前面已经提到过这个场景,这里我用一个更精确的描述:volatile-lru 只会从“带着 expire 的 key”里筛选。假设你的 key 总量 100 万,带 TTL 的只有 5 万,内存满时 Redis 只能在 5 万里挑,即便全部淘汰也不一定够腾出足够内存。

我见过一个项目就是因为这个策略配了跟没配一样,内存持续缓慢上涨,直到触发 OOM。排查方法很简单:用redis-cli --bigkeys或者写个脚本扫一遍各种 key 的 TTL 分布,先搞清楚“可淘汰集合”有多大,再去选 volatile 系列。

6.3 小心把 sample 调到过大

某次大促前,我把maxmemory-samples从 5 调到了 20,想着淘汰质量更高。结果在压测阶段就发现,高并发写场景下 CPU 比原来明显高了几个百分点。因为每次释放内存循环都要随机取 20 个 key 并比较 idle 时间,这个动作是同步执行的,写请求越频繁,代价就越明显。

后来我在压测环境做了一组对比:samples=5、10、20 三组,分别记录 evicted_keys 和命中率。结论是 10 在“淘汰质量提升”和“CPU 开销”之间最平衡;20 的淘汰质量提升已经非常有限,但 CPU 开销明显上升。如果你手头有压测环境,我建议也做一次类似的实验,毕竟你的 key 分布和访问模式可能跟我不一样。

6.4 热点 key 被淘汰后的缓存击穿

有一次活动页的配置 key 被淘汰了,结果一瞬间所有请求全部绕过 Redis 直接打到数据库。这个 key 平时的访问量极高,按理说 LRU 不应该淘汰它,但问题出在它被“批量更新”过:运营修改配置后我们会主动删除缓存,删除后大量请求还没把新数据写回的时候,恰好碰到内存紧张,轮询时这个 key 的 idle 时间并不短,就被选中了。

这是 LRU 的天然软肋——它只能依赖“最近访问时间”,而“即将被访问”这件事它看不到。遇到这种场景,我们后来采用的方案有两个:一是对这类 key 不设置过期时间,并把它排除在淘汰范围之外;二是改用 LFU,因为访问频率能更好体现热点价值,不会因为一次短暂的空窗期就被误杀。如果你也有“主动删缓存后马上又被大量请求回填”的模式,建议重新审视一下淘汰策略的选择。

6.5 大 key 淘汰导致的主线程阻塞

这是最隐蔽的一个坑。有一次我们监控里发现 Redis 的延迟偶尔会飙到几百毫秒,排查了网络、慢查询都没有明显问题,最后通过统计才锁定到是淘汰事件导致的:一个 5MB 大小的 Hash value 被选中淘汰,同步释放这个对象内存时,主线程被卡住了几十毫秒。Redis 单线程模型下,这几十毫秒对全局请求都是可见的。

解决方向有三条:

  • 核心是把 value 拆小,别让超大对象进 Redis。
  • 开启lazyfree-lazy-eviction yes,让大 key 的释放走异步线程。
  • 对已知的大 key 做特殊保护,比如调整 hash 的 ziplist 转 hashtable 阈值,或者定期扫描拆分。

这里也建议你日常跑一跑redis-cli --bigkeys,对实例里最大的 key 做到心里有数,而不是等出问题再抓。

6.6 内存快满时还叠加了系统 swap

最后说一个经常被忽略的关联问题。当 Redis 的内存接近物理上限时,操作系统可能把一部分 Redis 内存页面换到 swap。一旦发生 swap,Redis 的读写性能会断崖式下跌,而且往往伴随着大量淘汰计算,表现上像是策略出了问题,实际上内存页交换才是主因。

我的经验是:maxmemory一定要低于机器物理内存,预留出足够余量给操作系统页缓存和其他进程;同时监控vmstat里的 si/so,以及 redis 进程的 swap 占用。如果发现在 swap,首先要做的是扩容或者迁移,而不是继续调淘汰策略参数。

最后再分享一个我个人的操作习惯:每次调整完淘汰相关的配置,我不会只盯着内存曲线看,而是把evicted_keys的变化速率和keyspace_hits/keyspace_misses放到同一个时间轴里对比。只要淘汰数量没有异常爬升、命中率没有同步下滑,那说明策略调整的方向基本是对的。Redis 的 LRU 机制确实不是教科书里那个完美的 LRU,但在理解了采样、池子、LFU 这些设计之后,你会发现它在“省内存”和“够用”之间做了非常聪明的一笔交易,而这个交易的边界在哪,正是我们需要在生产环境里慢慢摸清楚的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 3:34:26

别再只记List和Set的区别,它们的共性才是重点

很多人在学习集合框架时,第一反应是“List是有序可重复的,Set是无序不可重复的”,然后就把这两大类集合当作完全对立的两种东西来记。但在实际项目里待久了,我越来越觉得,真正需要先搞清楚的反而是它们的相似性。因为日…

作者头像 李华
网站建设 2026/10/12 3:32:26

LSI3008 RAID卡驱动与固件匹配实战指南

简介:本资源为LSI 3008 SAS RAID阵列卡官方兼容驱动合集,面向服务器运维工程师、系统集成人员及企业级存储管理员,解决Windows/Linux等主流平台下RAID控制器识别异常、性能受限或功能缺失等关键问题。压缩包共97个文件,涵盖28个核…

作者头像 李华
网站建设 2026/10/12 3:32:09

缠论程序化实战:从K线合并到买卖点信号的全链路实现

简介:这是一份面向股票量化与缠论研究者的Python程序化实践项目,以《缠中说禅博客》中的交易方法为蓝本,实现了K线包含处理、顶底分型识别、画笔划分等核心流程,并在TODO中规划线段划分、均线选股、均线轮动与板块强弱指标、每日走…

作者头像 李华