缓存命中率从 97% 掉到 61%,接口 P99 从 40ms 冲到 300ms,值班的同学甩过来一张内存曲线图,说"内存打满了,是不是有人在刷 key"。这是我这些年遇到过最典型的一类线上问题。翻配置一看,maxmemory是设了,但策略是noeviction,于是写请求开始报 OOM,业务方加了重试,重试又把读流量顶上去,最后整个链路雪崩。Redis 淘汰 key 这件事,听着像一句配置项,真拆开看是两条完全独立的链路:一条管"到点了该消失的键怎么删",一条管"内存装不下了该丢谁"。把这两条链路混在一起谈,是绝大多数误判的根源。这篇就围绕"Redis 是如何淘汰 key 的"这个主题,把过期删除、内存淘汰、近似 LRU 与 LFU、淘汰链路的可观测性、以及我实际踩过的坑,一层一层剥开讲。适合已经会用 Redis、但没系统梳理过它内存回收机制的同学,也适合正在排查内存告警、命中率抖动的一线同学。
1. 先拆开看:过期删除和内存淘汰是两套机制
很多人脑子里只有一句话——"Redis 会自己删 key"。这句话没错,但太粗。Redis 内部其实有两套触发条件完全不同的回收逻辑,它们的入口、执行者、可观测指标、对业务的影响全都不一样。你要是把它们当成一件事,排查时就会在错误的指标上看半天,最后得出"Redis 淘汰有 bug"这种结论。
1.1 一个把两套机制搞混的现场
我见过一个很典型的例子。业务给一批会话数据设了 30 分钟 TTL,同时把maxmemory设成 2GB、策略设成allkeys-lru。跑了两天发现内存稳稳停在 2GB,但used_memory里有一大堆早就该过期的键还在。同学的第一反应是"过期没生效",于是去查 TTL,发现TTL返回 -2,键确实没了;再去看内存,还是满的。他就卡住了。
真相是:键确实按过期逻辑被删了,expired_keys在涨;但同时新的写入又把内存顶到了上限,于是淘汰逻辑接管,evicted_keys也在涨。两个计数器同时在动,说明两套机制在同时工作。他觉得"内存该降没降",是因为他看的是used_memory的峰值曲线,而不是删除发生之后的即时值——大 key 的释放有延迟,后面讲 lazyfree 时会说。
所以第一件事,把两条链路的分工记住:
| 维度 | 过期删除(Expiration) | 内存淘汰(Eviction) |
|---|---|---|
| 触发条件 | key 的 TTL 到期 | used_memory超过maxmemory |
| 作用范围 | 只作用于设置了过期时间的键 | 由策略决定,可能是全库,也可能只是带 TTL 的键 |
| 执行时机 | 访问时惰性检查 + 后台周期性扫描 | 写命令执行前,同步触发 |
| 主要指标 | expired_keys | evicted_keys |
| 能否关闭 | 不能,只能控制扫描力度(hz) | 可以,noeviction就是拒绝写入 |
| 写满后的行为 | 不涉及 | 策略为noeviction时返回 OOM 错误 |
这张表我建议每个维护 Redis 的人都存一份。排查内存问题时,先看这两个计数器哪个在涨,基本就能定位到是哪条链路的锅。
1.2 两条链路为什么会互相掩盖
这里有个容易被忽略的细节:一个键可能既设置了 TTL,又身处一个内存吃紧、正在被淘汰的实例里。这时候它到底是被"过期"删的,还是被"淘汰"删的?答案是——先到先得。如果它在 TTL 到期前就因为内存压力被选中淘汰,那它计入evicted_keys;如果它先到期被删掉,那计入expired_keys。
这带来一个实际问题:你没法从expired_keys的下降推断"TTL 变长了",因为它可能只是被淘汰抢先了。反过来也一样。做容量规划时,我一般要求把这两个计数器分两条曲线画出来,再叠加used_memory和maxmemory的比值,四条线一起看,才能判断当前到底是"过期压力大"还是"内存压力大"。
还有一个更隐蔽的相互影响:过期键在被真正删除之前,仍然占着内存。Redis 的过期删除是"惰性 + 定期"的组合,不是精确的定时器。理论上存在一批已经过期但还没被扫到的键,它们依然计入used_memory,并且会成为淘汰的候选。这在高并发写入场景下会放大内存占用,也是为什么used_memory和"业务上认为的活数据量"之间经常对不上。
2. 过期键到底是怎么被清掉的
搞清楚了分工,先看第一条链路:TTL 到期之后,键是怎么没的。这块的设计非常克制,核心思路是"绝对不为了精确删除而牺牲主线程的响应时间"。理解了这个取舍,你就明白为什么 Redis 需要对过期做"抽样"而不是"全扫"。
2.1 惰性删除:访问那一刻才动手
惰性删除的入口在lookupKey系列函数里。任何一次对键的访问——读、写、判断存在——都会先检查这个键是否已经过期。如果过期了,就在主线程里当场把它删掉,然后按"键不存在"返回。这个过程对客户端是透明的,你GET一个过期键,拿到的是 nil,和这个键从来没存在过一样。
这个设计的价值在于零额外开销:不访问就不消耗 CPU。代价也很明显:如果一个键过期后永远不再被访问,它就永远不会因为惰性逻辑被删,会一直占着内存。这是纯粹的内存泄漏吗?不完全是,因为还有第二道防线。
惰性删除有个副作用需要注意。它是在主线程里同步执行的,删除本身是DEL语义,对一个几百万元素的集合类型来说,这一次"惰性删除"可能就是几十毫秒的阻塞。所以从 Redis 4.0 开始引入了lazyfree-lazy-expire配置,可以把这个删除动作丢到后台线程。大 key 场景我一般会打开这个开关。
2.2 定期删除:在 serverCron 里抢一点时间预算
第二道防线是主动扫描。Redis 的后台定时任务serverCron默认每 100ms 跑一次(由hz控制,默认 10),过期清理挂在它调用的databasesCron里。每次执行的逻辑大致是:从设置了过期时间的字典里随机采样一批键,检查是否过期,删掉过期的;如果这一批里过期键的占比超过 25%,说明"过期键还很多",那就继续下一轮采样;反之就结束这一轮,把 CPU 让出去。
这里有两个数字值得记住:单次采样的样本量是 20 个,续扫的阈值比例是 25%。它们共同保证了一件事——扫描不会无限循环下去,一定会在某个时间上限内退出。源码里还有时间预算控制,快慢模式不同,但核心思想都是"宁可这次少扫一点,也不能让主线程卡住"。
# 观察过期扫描的压力,可以先看这两个 redis-cli -h 127.0.0.1 -p 6379 info stats | grep expired_keys redis-cli -h 127.0.0.1 -p 6379 config get hzhz调高会让后台任务跑得更勤,过期键清理更及时、内存回收更平滑,但空转 CPU 也更多、单次serverCron占用的时间片也更长。我在线上一般维持在默认 10,只有在"过期键堆积导致内存虚高"的场景才会考虑调到 20 或 30,而且调之前一定先确认主线程的延迟没有抬头。
提示:
hz是全局配置,调整它影响的不只是过期扫描,还有客户端超时检测、复制心跳等一系列后台任务。改之前评估清楚,别只盯着过期这一个点。
2.3 从库和持久化文件里的过期键怎么处理
这是最容易踩雷的一块。从库本身不会主动根据 TTL 去删键——它对过期这件事是被动的,等主库删掉之后,通过复制链路把DEL传过来,从库才跟着删。这个设计的目的是保证主从数据一致:如果从库自作主张删了,主库还没删,两边数据就不一样了。
这个机制直接导致一个现象:从库上可能存在一批"逻辑上已经过期但实际还在"的键。读请求如果落到从库上,不同版本的行为细节有差异,稳妥的做法是不要把强一致的过期语义压在从库读上。业务上真依赖"读到的一定没过期",那就让读打到主库,或者自己在 value 里带时间戳做二次判空。
持久化文件这边也有讲究。RDB 生成的时候,已过期的键不会被写进去;AOF 重写的时候同理。但加载的时候行为不一样:主节点加载 RDB 会过滤掉已过期的键,从节点加载则会把它们保留下来,继续等着主库的DEL。AOF 加载也是类似的主从差异。
这块的实操结论就一条:做主从切换演练时,一定要带上"大量键同时临近过期"的场景。我见过切换之后从库升主,一批本该过期的键突然"复活",业务读到一堆脏会话数据的案例。事后复盘,根因就是切换前从库上积压了未同步的过期键。
3. maxmemory 打满之后,淘汰策略怎么选
第二条链路才是"淘汰"这个词的真正含义:内存超过maxmemory之后,Redis 必须丢掉一些键来腾地方。这里的决策质量直接决定了缓存命中率,也是策略选型的主战场。
3.1 八种策略的判定逻辑与适用场景
Redis 从 4.0 开始提供 8 种maxmemory-policy,分成三大类:不淘汰、按算法淘汰、按随机淘汰;再按"候选范围"分成"全库"和"只含 TTL 的键"。
| 策略 | 候选范围 | 选择依据 | 典型适用场景 |
|---|---|---|---|
noeviction | 无 | 不淘汰,写命令返回 OOM | 当存储用、数据不可丢、有外部持久化兜底 |
allkeys-lru | 全库 | 近似 LRU,淘汰最久未访问的 | 通用缓存,访问有局部性 |
allkeys-lfu | 全库 | 近似 LFU,淘汰访问频率最低的 | 有明显热点、且存在偶发全量扫描 |
allkeys-random | 全库 | 随机 | 访问分布均匀、无明显热点 |
volatile-lru | 仅带 TTL | 近似 LRU | 同一实例混用缓存和持久数据 |
volatile-lfu | 仅带 TTL | 近似 LFU | 同上,且热点明显 |
volatile-random | 仅带 TTL | 随机 | 同上,访问均匀 |
volatile-ttl | 仅带 TTL | 剩余 TTL 最短优先 | 希望快过期的先走,缓解内存突发 |
选型的第一个判断点不是算法,而是候选范围。volatile-*系列有个致命陷阱:如果实例里的键大部分没设 TTL,那候选集合几乎是空的,淘汰程序选不出东西来,实际行为就退化成noeviction,写请求直接报错。我在生产上见过不止一次这种情况——配置写着volatile-lru,看起来"有淘汰",实际一直在 OOM。
注意:用
volatile-*之前,先用INFO keyspace里的keys和expires两个数算一下比例。带 TTL 的键占比低于 80%,我基本不会考虑volatile-*。
第二个判断点是访问分布。有热点就用 LRU/LFU,均匀分布才考虑 random。真实业务里"完全均匀"几乎不存在,所以allkeys-random我基本只在压测环境用。
3.2 Redis 的 LRU 是"近似"的,采样池才是关键
这是标题里"淘汰"二字最核心的技术点,也是面试和实操都绕不开的地方。教科书上的 LRU 需要一个双向链表,每次访问都把节点挪到表头,淘汰时取表尾。这个方案内存开销大(每个键多两个指针),而且在高并发下维护链表本身有成本。Redis 没这么干。
Redis 的做法是给每个键维护一个访问时间戳,精度是秒级(内部用一个 24 位的时钟字段存),然后淘汰时随机采样若干个键,从里面挑出"最久没被访问"的那个删掉。这个采样数量由maxmemory-samples控制,默认是 5。
为了提升准确度,Redis 还维护了一个固定大小的候选池(16 个槽位),采到的键按空闲时间插进去排序,每次从池子里挑空闲时间最长的淘汰。这个池子跨多轮淘汰复用,效果相当于"用很小的内存开销,模拟出一个接近全局排序的 LRU"。
关键结论在这里:maxmemory-samples越大,近似度越高,但每次淘汰的 CPU 开销也越大。官方给的经验是,采样数从 5 提到 10,准确率提升很明显;再往上收益递减。我在内存特别大、键特别多的实例上会用 10,小实例保持默认 5。
# 查看和调整采样精度 redis-cli config get maxmemory-samples redis-cli config set maxmemory-samples 10 # 在线切换淘汰策略,无需重启 redis-cli config set maxmemory-policy allkeys-lfu3.3 LFU 解决的是什么问题:偶发扫描把热点冲掉
LRU 有个天然缺陷:它只看"最近一次访问时间"。设想一个场景,某天凌晨跑了一次全量数据校对,扫了几百万个冷键。这些冷键的访问时间瞬间变新,把真正的热点键挤到了"最久未访问"的位置。接下来内存一紧,淘汰程序开开心心地把热点删了,命中率直接跳水。
LFU 就是冲着这个场景来的。它不看到访时间,看访问频次。实现上给每个键 8 个 bit 的计数器(最大 255),外加 16 个 bit 记录最后一次衰减的时间。频次不是每访问一次加一,而是概率性增长——访问得越多,继续加一越难,这样既能区分"访问一次"和"访问一万次",又不会让计数器很快饱和。
计数器增长的概率近似为:p = 1 / ((counter - 初始值) * lfu-log-factor + 1)lfu-log-factor默认 10,值越大计数器涨得越慢,区分度越高但需要更多访问才能拉开差距。另一个参数lfu-decay-time默认 1 分钟,意思是每过一分钟,计数器减一。这个衰减机制很重要——没有它,一个历史热点会永远霸占位置,新热点永远上不来。
LFU 相关的实操命令:
# 前提:策略必须是 allkeys-lfu 或 volatile-lfu redis-cli object freq mykey # 查看当前频次 redis-cli object idletime mykey # 查看空闲时长 # 需要开启 LFU 策略后才能用 redis-cli --hotkeys # 采样找出热点 key排查"为什么这个 key 被淘汰了"的时候,OBJECT FREQ是我最常用的工具。看一眼频次是不是低得离谱,基本能判断是业务访问模式的问题,还是策略配错了。
3.4 内存占用该怎么算,maxmemory该设多少
这里有个常被忽略的细节:maxmemory判断的不是物理内存,也不是used_memory_rss,而是 Redis 自己统计的used_memory,并且会扣除复制积压缓冲区(repl backlog)和 AOF 缓冲区的一部分。也就是说,你设 2GB,并不意味着进程 RSS 就是 2GB。
实际部署时,maxmemory的推荐值是物理内存的 60%~75%,剩下的留给三样东西:fork 做 RDB 或 AOF 重写时的写时复制(COW)峰值、内存碎片、以及连接缓冲区。我踩过一次坑,把maxmemory设到了物理内存的 90%,平时很稳,某天触发全量重写,COW 直接把物理内存吃满,系统层把进程干掉了。
maxmemory-clients这个参数(较新版本才有)也值得关注,它专门限制客户端输出缓冲的总量。有过一次事故,某个客户端用MONITOR挂在线上,输出缓冲涨到几个 G,把used_memory顶到上限,触发了大规模淘汰。事后我们就加了这条限制。
4. 淘汰链路的完整走向与可观测性
知道策略怎么选之后,还得知道它什么时候执行、执行了什么、留下了什么痕迹。很多"淘汰看起来没生效"的疑问,根源都在对执行时机和观测指标的理解偏差上。
4.1 一次写命令触发的淘汰流程
淘汰不是后台线程慢慢做的,它是在命令执行路径上同步发生的。具体来说,客户端发来的写命令(带有"可能增加内存"标记的那一类)在处理前会先做一次内存检查,如果需要淘汰,就在主线程里把该删的键删够,再执行这次写。
这个流程大致是:
- 计算当前内存使用量,扣掉允许扣除的缓冲区部分;
- 与
maxmemory比较,没超就直接执行命令; - 超了,进入淘汰流程:检查策略是否为
noeviction,是则直接返回 OOM 错误; - 否则按策略采样、选键、删除;
- 若被删的键设置了 TTL,或处于主节点,将删除动作作为
DEL传播给从库和 AOF; - 删完重新计算内存,还没降下来就继续淘汰,直到满足条件或用尽尝试次数。
这里有三个值得注意的点。
第一,读命令不触发淘汰。只有可能增加内存的写命令才会走这个检查。所以一个只读实例即使内存超了,读请求也不会报 OOM,但内存也不会因此下降。这解释了"为什么内存超了但没报错"这类困惑。
第二,淘汰是同步的,会占用命令的响应时间。一次写命令如果触发了上百次键删除,那它的耗时里就包含了这些删除的开销。大 key 尤其明显,删一个几百万元素的 hash,同步删可能要几十毫秒。这也是lazyfree-lazy-eviction存在的理由。
第三,淘汰有尝试次数上限。如果一轮下来实在腾不出空间(比如策略是volatile-*但候选集为空),Redis 不会无限重试,而是放弃并返回错误。所以"内存超了但没人被淘汰"是完全可能发生的,不要以为配置了淘汰策略就一定不会 OOM。
4.2 用 INFO 和慢日志把淘汰事件看清楚
排查内存和淘汰问题,我有一组固定动作,顺序不能乱:
# 1. 先看内存全貌 redis-cli info memory # 2. 再看淘汰和过期的计数 redis-cli info stats | grep -E "evicted_keys|expired_keys|keyspace_hits|keyspace_misses" # 3. 看键空间分布,评估 volatile 策略是否可行 redis-cli info keyspace # 4. 看当前策略和上限 redis-cli config get maxmemory redis-cli config get maxmemory-policy几个指标的含义和判读方式,我做成了表:
| 指标 | 含义 | 异常信号 |
|---|---|---|
used_memory | Redis 自己统计的分配内存 | 长期贴着maxmemory,说明一直在淘汰 |
used_memory_rss | 操作系统看到的进程内存 | 远高于used_memory,说明碎片严重 |
mem_fragmentation_ratio | 上面两者的比值 | 超过 1.5 考虑整理碎片;小于 1 说明有内存被换出 |
evicted_keys | 累计淘汰键数 | 持续快速上涨,说明容量不足或策略不当 |
expired_keys | 累计过期键数 | 突然暴涨,可能是大批键 TTL 设成同一时刻 |
keyspace_hits/misses | 命中统计 | 命中率骤降往往和淘汰同步发生 |
mem_fragmentation_ratio小于 1 这个情况值得单独说一句。它意味着 Redis 统计的内存比操作系统给的还多,通常说明有部分内存被交换到磁盘上了。这是性能杀手,一旦出现必须立刻处理,比如检查容器的内存限制或者系统的交换配置。
另一个重要工具是慢日志。如果慢日志里出现大量写命令耗时突然抬升,同时evicted_keys在涨,那基本可以确定是淘汰动作在拖慢命令。这时候要去看是不是有超大 key 被淘汰了。
redis-cli config set slowlog-log-slower-than 10000 # 记录超过 10ms 的命令 redis-cli slowlog get 20 # 取最近 20 条4.3 大 key 淘汰引发的延迟毛刺,以及 lazyfree
前面反复提到大 key,这里集中说一下。删除一个集合类型的大 key 不是一个 O(1) 操作,它需要遍历并释放所有元素。如果这个 key 有几百万元素,同步删除可能造成几十甚至上百毫秒的主线程阻塞,延迟毛刺就是这么来的。
Redis 4.0 引入了异步删除,核心是把"释放内存"这个动作从主线程挪到后台线程。相关配置有四个:
| 配置项 | 作用 |
|---|---|
lazyfree-lazy-eviction | 淘汰键时异步释放内存 |
lazyfree-lazy-expire | 过期键删除时异步释放 |
lazyfree-lazy-server-del | 服务端内部删除(如 rename 覆盖)时异步 |
replica-lazy-flush | 从库全量同步前清空数据时异步 |
另外还有UNLINK命令,语义和DEL一样,但内存释放放到后台做。我做数据清理脚本时一律用UNLINK,DEL只在确定是小 key 的时候用。
# 推荐在大 key 场景全部打开 redis-cli config set lazyfree-lazy-eviction yes redis-cli config set lazyfree-lazy-expire yes redis-cli config set lazyfree-lazy-server-del yes redis-cli config set lazyfree-expensive-client-output-buffer-limit yes提示:
CONFIG SET改的配置在重启后会丢失。线上一定要配合CONFIG REWRITE写回配置文件,否则某次重启之后策略又回到了默认值,排查起来极其痛苦。
5. 那些年踩过的坑与上线自检清单
理论讲完,说点只有真踩过才知道的东西。这一节里的事故都是同一个模式:配置看起来没问题,但组合起来产生了意外的行为。
5.1 五个高频且容易误判的坑
坑一:volatile-lru配了一个几乎没有 TTL 的实例。前面提过,这里给一个具体判断方法:INFO keyspace输出里每个库有两个数,keys和expires。expires/keys低于 0.8 就别用volatile-*。我见过一个实例这个比值是 0.02,配置写着volatile-lru,监控上evicted_keys一直是 0,运维以为"没触发淘汰,很健康",实际上写请求早就开始报 OOM 了,只是被业务层的重试掩盖了。
坑二:策略在线改了但没写回配置文件。CONFIG SET是运行时的,重启失效。更麻烦的是主从两个节点的配置文件可能不一致,主从切换之后策略跟着变了,行为出现差异。我的做法是把maxmemory、maxmemory-policy、maxmemory-samples、lazyfree-*这组参数写进统一的配置模板,上线时校验实际生效值,而不是只看配置文件。
坑三:把maxmemory设成物理内存的 90% 以上。平时无事,一旦碰上 RDB 保存或者 AOF 重写,fork 出来的子进程靠写时复制工作,父进程在淘汰的同时子进程在复制页面,物理内存瞬间打满。这个坑的特点是"偶发、难复现、后果严重"。留够 25%~40% 的余量,是成本最低的保险。
坑四:大量 key 的 TTL 设成同一个时刻。比如每天零点批量刷新缓存,全部设成 24 小时过期。结果第二天零点,几百万个键同时过期,定期删除扫不过来,惰性删除又因为没被访问而不触发,内存短时间内虚高,接着触发淘汰,命中率掉一大截。解决办法是给 TTL 加随机抖动,比如base + random(0, 600)秒,把过期洪峰摊平。这个小改动效果非常明显。
坑五:把淘汰当成"数据丢了"来追责。淘汰是缓存设计的正常组成部分,不是故障。真正该追问的是"为什么容量规划没留出余量"和"为什么热点键会被选中淘汰"。我曾经花了半天时间去证明"淘汰逻辑没问题",其实一开始就该去看OBJECT FREQ,确认被淘汰的确实是低频键,然后直接扩容或者优化 value 体积。
5.2 上线前的自检清单
这套清单我现在每次新实例上线都会过一遍,五分钟的事,能省掉后面几天的排查。
| 检查项 | 判断标准 | 处理动作 |
|---|---|---|
maxmemory是否设置 | 必须显式设置 | 设为物理内存的 60%~75% |
| 淘汰策略是否匹配数据形态 | 有热点用 LRU/LFU,混合数据用volatile-* | 按业务调整,改完CONFIG REWRITE |
volatile-*的 TTL 覆盖率 | expires/keys大于 0.8 | 不满足就换allkeys-* |
maxmemory-samples | 大数据量实例建议 10 | CONFIG SET并写回配置 |
| lazyfree 是否开启 | 大 key 场景全开 | 四个开关逐项确认 |
| 单 key 体积 | 单实例无超过 10MB 的 key | 拆分成多个小 key |
| TTL 是否有抖动 | 无集中过期洪峰 | 加随机偏移 |
| 慢日志阈值 | 已配置且定期巡检 | 建议 10ms 阈值 |
| 主从配置一致性 | 两节点策略、上限一致 | 用统一模板下发 |
| 监控项完整性 | evicted_keys、expired_keys、mem_fragmentation_ratio均有告警 | 补齐监控 |
关于单 key 体积,再补一个实操技巧。用MEMORY USAGE key可以直接查到某个键占多少字节,排查大 key 非常方便。如果要批量扫描,用SCAN配合MEMORY USAGE,绝对不要用KEYS *,后者在大实例上会直接把主线程卡死。
# 安全地评估一批 key 的体积 redis-cli --scan --pattern "session:*" --count 1000 | head -100 | \ while read k; do echo -n "$k "; redis-cli memory usage "$k"; done还有一个容易忽略的细节:maxmemory-policy是可以随时在线切换的,切换本身不会引发数据丢失,但会立刻改变后续的淘汰行为。所以线上调整策略时,最好配合监控观察命中率变化至少一个业务周期,别在流量高峰期动。
我个人在实际操作中的体会是,Redis 的 key 淘汰从来不是一个配置项的问题,而是"容量规划 + 数据形态 + 访问模式"三者共同决定的结果。同样的allkeys-lru,在一个商品详情缓存的实例上表现很好,换到一个带偶发全量扫描的实例上就会把热点冲掉——这不是配置错了,是访问模式变了,得换 LFU。所以每次遇到命中率异常,我的第一反应不是改配置,而是先拉出evicted_keys、expired_keys、keyspace_hits三条曲线对齐时间轴,看看究竟是容量不够、过期洪峰、还是访问模式变了,把病因定下来,再动手。先看指标再改参数,这个顺序反过来,十次有八次是在瞎调。