线上 Redis 内存被打满、OOM 告警、缓存命中率一夜之间掉一半——这类事故里,有相当一部分的根因不在机器资源,而在淘汰策略。LRU 和 LFU 这两个词,大家在八股文里都背过,都知道一个是“最近最少使用”、一个是“最不经常使用”,可真到线上调参、看监控、做 Redis 缓存治理的时候,很多人并不清楚 Redis 到底是怎么实现这两个算法的,也不清楚哪个更适合自己的业务。
这篇文章不打算给你罗列“LRU vs LFU 区别”的表格就完事,我尽量把两件事讲透:一是 Redis 源码里 LRU/LFU 的真实实现思路,二是实际配置、调优、选型时踩过的坑。文章有点长,但每段都是能直接拿去用的经验。
1. LRU 与 LFU 的核心思路:一个看时间,一个看次数
1.1 LRU 为什么只能“记住最近一次访问”
LRU 的全称是 Least Recently Used,翻译过来就是“最近最少使用”。它的核心假设是:如果一个数据在最近一段时间内没有被访问过,那么它在未来被访问的概率也比较低。这个假设在绝大多数业务场景下是成立的,所以 LRU 是工业界用得最多的缓存淘汰算法,没有之一。
你可以把它想象成衣柜整理法:你每次穿过的衣服都挂在最外侧,不常穿的衣服自然会被挤到最里侧。衣柜满了要扔衣服的时候,你会优先扔最里侧那件——因为那件是最久没穿的。LRU 就是这套逻辑,它记录的是每个数据“最后一次被访问的时间点”,淘汰时优先淘汰那个时间点最旧的。
这个思路简单、高效,但有一个先天的盲区:它完全不关心数据在历史窗口里的访问频率。一个 key 昨天被访问了一万次,今天一天没人碰;另一个 key 今天刚被访问了一次。在 LRU 看来,今天被访问过的那个 key 是“热的”,昨天那个“一万次先生”反而是“凉的”,要淘汰就先淘汰它。一次大规模离线扫描、一次数据导入,就能瞬间把真正热点的位置挤掉,这就是所谓“缓存污染”问题。后面你会看到,LFU 恰恰是为了补这个短板才被引入的。
1.2 LFU 在哪些场景把 LRU 按在地上摩擦
LFU 的全称是 Least Frequently Used,核心思路很直白:不看你最近什么时候来过,而是看你单位时间内到底来了多少次。访问次数越多,说明这个 key 越“核心”,淘汰时就优先牺牲那些访问次数少、又不怎么活跃的 key。
还是拿衣柜举例:LRU 是“看谁最久没穿就扔谁”,LFU 是“看谁一年到头穿的次数最少就扔谁”。一个一年穿不了几次的压箱底大衣,哪怕上个月翻出来穿过一次,在 LFU 眼里依然不是什么重要货色;而一件每周穿三次的卫衣,即使两天没碰,它仍然是毫无疑问的热门款。
LFU 的价值在两类业务里特别明显。第一类是稳定热点型:比如某个配置项、某个身份信息、某个热门商品详情,每天都稳定有大量访问,但访问节奏很均匀,没有明显的“最近突发”。第二类是访问频率差异极大的场景:比如内容社区的热门文章和冷门文章,一篇爆款可能贡献 90% 的阅读量,这种分布下,频率比时间更能代表一个 key 的价值。
LFU 也有自己的软肋:历史惯性太大。一个 key 曾经火过,如果它的频率计数永远不降,它就会一直霸占缓存位置,导致新晋热点进不来。所以 Redis 的 LFU 实现里专门设计了“时间衰减”机制,这个在后面实现章节详细拆。
1.3 算法差异对照表:五分钟理清选型大方向
| 维度 | LRU | LFU |
|---|---|---|
| 核心度量 | 最后一次访问时间 | 单位时间内的访问频率 |
| 数据特征假设 | 近期访问过的数据更可能被再次访问 | 历史访问频率高的数据更可能被再次访问 |
| 擅长场景 | 访问时间局部性强、热点会漂移 | 热点稳定、访问频率差异大 |
| 典型弱点 | 一次性批量访问会污染缓存 | 历史高频 key 会长期占位,新热点难上位 |
| 内存代价(教科书实现) | 双向链表 + 哈希表,每个 key 额外维护链表指针 | 需要记录每个 key 的访问次数,通常也是哈希结构 |
| Redis 落地方式 | 近似 LRU,随机采样淘汰 | 近似 LFU,计数器 + 时间衰减 |
先把这个大方向刻在脑子里,后面的实现和选型都围绕这张表展开。
2. Redis 为什么放着“完美 LRU”不用,偏要搞一套近似实现
2.1 教科书 LRU 的内存账:每个 key 都要多花 8 字节
很多人在面试里能把 LRU 的双向链表结构画得明明白白:用一个 HashMap 存 key 到链表节点的映射,每次访问就把节点挪到链表头部,淘汰时直接砍掉链表尾部。逻辑完美,但放在 Redis 里行不通。
原因很简单:内存。教科书版的精确 LRU,每个 key 除了自身数据之外,还要额外维护一个链表节点指针(在 64 位系统里一个指针就是 8 字节,如果双向链表要 prev 和 next 两个指针就是 16 字节)。单个 key 多 8-16 字节听起来不夸张,但 Redis 里的 key 数量通常是以千万甚至亿为单位的。一亿个 key,光链表指针就得多花 1.6GB 内存。为了一个淘汰算法的“精确性”付出这么大的内存代价,Redis 的设计哲学不允许。
Redis 作者在内存使用上抠得非常狠:宁可淘汰得不那么精确,也绝不在单条数据上额外挂重型结构。这就是为什么你翻遍 Redis 源码,也找不到一个全局的双向链表来维护所有 key 的访问顺序。
2.2 近似 LRU:24 位字段 + 随机采样,1 秒完成淘汰
Redis 真正的做法非常取巧:每个 key 对应的 RedisObject 结构体里,有一个 24 位的字段叫 lru。在 LRU 模式下,这个字段记录的是这个 key 最近一次被访问时的“时钟值”,单位是秒级时间戳的截断值。
为什么是 24 位?因为省内存。24 位能表示的最大值是 2^24 - 1。当时钟值超过这个范围后会发生回绕,但 Redis 在比较两个字段的旧旧程度时用的是相对差值,正常业务场景下回绕不会造成问题。这个字段本来就存在于 RedisObject 里,LRU 只是“废物利用”而已。
那么淘汰的时候怎么选目标?Redis 不会遍历全部 key 去比较时间戳,那样太慢了。它采用随机采样:从全局哈希表里随机抽取 maxmemory-samples 个 key(默认是 5),然后在这 5 个里面挑一个 lru 字段最小的(也就是最久没被访问的)淘汰掉。采样数越大,淘汰目标越接近真正的“全局最久未使用”,但 CPU 开销也越高。这就是“近似 LRU”这个名字的由来。
在我们的缓存治理实践中,这个机制直接导致了一个反直觉现象:如果你用 Redis 做缓存并设置了 allkeys-lru,某个冷数据被淘汰并不意味着它真的是全局最冷的 key,它只是“抽样中最冷的一个”。理解这一点,你就不会因为偶发的淘汰误判而怀疑代码逻辑了。
2.3 LFU 如何在一个字段里同时存“时间”和“频率”
Redis 在 4.0 版本引入了 LFU。这里有个问题:RedisObject 里还是那个 24 位的 lru 字段,它现在既要存时间信息,又要存访问频率信息,怎么塞进去?
答案是位拆分。在 LFU 模式下,这 24 位被重新划分:高 16 位存 ldt(last decrement time,上次衰减时间,以分钟为单位),低 8 位存 logc(logistic counter,对数计数器)。16 位存储分钟级时间戳,足够记录大约 45 天的范围;8 位的计数器取值 0-255,用来表征访问热度。
注意,logc 这个名字里有个关键词:对数。它记录的不是精确的访问次数,而是经过对数映射后的“热度等级”。为什么不能直接记录访问次数?因为 8 位最多只能记到 255,一个 key 如果被访问了 300 次就顶天了,高频 key 全部卡在 255 上,根本分不出谁更热。Redis 的做法是让计数按概率递增:访问次数越多,logc 增长越慢。新 key 的初始 logc 值不是 0 而是 5,然后第一次访问、第二次访问可能很快就涨上去了,越往后,每次访问能把计数器往上抬的概率越低。这个设计保证了一个 100 次访问的 key 和一个 10000 次访问的 key 在 logc 上能拉开足够大的差距。
LFU 里的衰减机制同样很关键:一个 key 就算历史热度再高,也不能永远占着茅坑不拉屎。Redis 通过 lfu-decay-time 参数控制衰减:每隔 N 分钟,如果 key 没有被访问,就会根据时间差对 logc 做一次衰减计算。衰减不是简单地减去分钟数,而是会经过一个非线性映射,避免高频 key 因为稍微冷落几小时就瞬间归零。这个机制的直观效果是:老热点会随着时间推移缓慢“降温”,给新热点留出上位空间。
2.4 淘汰候选池:避免“刚删完又被采样进来”
从 Redis 4.0 开始,淘汰逻辑里又加了一个细节:淘汰候选池(eviction pool)。这个设计值得单独拿出来讲,因为很多线上诡异现象都跟它有关。
早期的淘汰逻辑是纯粹的“采样即删”:每次内存超了,就采样一批 key,挑一个淘汰掉。这样做的问题在于随机性太大,采样出来的“最老 key”可能并不是真正的全局最老,而且删完一轮之后,下一轮采样又可能采到一批新的冷 key,导致淘汰目标在随机波动之间反复横跳,整体淘汰过程很不平滑。
候选池的做法是:Redis 会维护一个按“淘汰优先级”排序的小池子,每次需要淘汰时,先采样一批 key,把这一批里最该淘汰的若干候选放进池子里,然后从池子里按优先级逐个淘汰。淘汰完一轮后,如果内存还不够,再从候选池里继续淘汰,而不是立刻重新采样。这个机制让淘汰过程更加稳定,也减少了“真正冷的数据被漏掉、反而删掉了不冷不热数据”的概率。
这个设计也解释了另一个线上现象:淘汰量不是平稳的一条线,而是一阵一阵的突刺。因为 Redis 在内存超限后往往不是只删一个 key,而是按“轮”清理,一轮清掉一批候选,所以你在 INFO stats 里看到的 evicted_keys 通常是脉冲式增长而不是匀速增长。
3. 配置选型与参数调优:从命令到实战
3.1 八种淘汰策略速查:先用哪张表,再动手
Redis 的 maxmemory-policy 一共有 8 个可选值,很多人只知道 lru 和 lfu,其实还有一堆变体。先看一张速查表:
| 策略 | 作用范围 | 淘汰依据 | 典型场景 |
|---|---|---|---|
| noeviction | 全局 | 不淘汰,内存满则写命令报错 | 不允许丢失任何数据的场景,比如纯锁、精确计数 |
| allkeys-lru | 全局所有 key | 最近最少使用 | 通用缓存,大部分业务默认选择 |
| allkeys-lfu | 全局所有 key | 最不经常使用 | 稳定热点明显、频率差异大的缓存 |
| allkeys-random | 全局所有 key | 随机淘汰 | 所有 key 热度均衡,淘汰谁都不心疼 |
| volatile-lru | 只淘汰设置了过期时间的 key | 最近最少使用 | 需要保留“永不淘汰数据”的缓存区 |
| volatile-lfu | 只淘汰设置了过期时间的 key | 最不经常使用 | 同上,但改用频率判断 |
| volatile-random | 只淘汰设置了过期时间的 key | 随机淘汰 | 同上,随机策略 |
| volatile-ttl | 只淘汰设置了过期时间的 key | 剩余存活时间最短优先 | 数据越接近过期越先淘汰 |
这里有一个特别容易踩的坑:volatile-* 系列只在带过期时间的 key 里做淘汰。如果你的业务里根本没有给 key 设置过期时间,那 volatile-lru 实际上等同于 noeviction,内存满了之后 Redis 不会淘汰任何数据,写命令直接报 OOM。所以如果你的业务是“纯缓存、全部 key 都可以丢”,老老实实用 allkeys-* 系列。
3.2 三个决定淘汰效果的核心参数怎么调
第一个参数是 maxmemory-samples,默认 5,影响所有采样类淘汰策略的精度。采样数越大,Redis 选出来的淘汰目标越接近全局最优,但每次淘汰前采样付出的 CPU 开销也越高。根据我自己的经验,默认 5 对于绝大多数业务够用;如果你的 Redis 里 key 数量特别多、且内存长期处于高位,可以调到 10,性价比较高。再往上调到 20,精度提升已经不明显,但 CPU 和延迟会开始波动,不建议盲目拉高。
第二个参数是 lfu-log-factor,默认 10,只影响 LFU。它控制 logc 的增长速度,factor 越大,logc 增长越慢,一个 key 需要积累更多访问次数才能达到高热度;factor 越小,新 key 的计数器涨得越快,新兴热点更容易冒头。如果业务里热点集中度很高,希望真正的爆款 key 能够快速获得“豁免权”,可以把 lfu-log-factor 调小一点,比如 5 左右。
第三个参数是 lfu-decay-time,默认 1,单位是分钟。它决定一个 key 多久不访问就开始衰减热度。decay-time 越小,老热点降温越快,新热点越容易挤进来;decay-time 越大,老热点占位越稳。如果业务里有类似“每年一次的大促活动页”这种周期性热点,decay-time 可以调大到 10 甚至更高,让去年积累的热度能在今年活动来临时还有残留。
3.3 一份可直接复制的生产配置示例
下面这份配置适合大多数以缓存为主要用途的 Redis 实例,你可以直接抄到 redis.conf 里:
maxmemory 4gb maxmemory-policy allkeys-lru maxmemory-samples 10 lfu-log-factor 10 lfu-decay-time 1如果你的业务已经明确是稳定热点型,建议换成:
maxmemory 4gb maxmemory-policy allkeys-lfu maxmemory-samples 10 lfu-log-factor 5 lfu-decay-time 2改配置的时候有两点要特别注意。一是 maxmemory 必须明确设置,64 位 Linux 下 Redis 默认 maxmemory 是 0,表示不限制内存,这会导致 Redis 在内存不受控的情况下被操作系统 OOM Killer 直接杀掉,这个坑见过太多次了。二是 CONFIG SET 命令可以动态修改策略而不需要重启,例如:
redis-cli config set maxmemory-policy allkeys-lfu但要注意,CONFIG SET 是运行时生效的临时配置,如果还想让重启后保持,记得执行 CONFIG REWRITE 把当前配置写回配置文件。
3.4 一次真实切换:命中率从 82% 到 94% 的调优记录
说一个我实际处理过的案例,这个案例能帮你把前面的概念串起来。某活动页服务,Redis 用作商品信息缓存,内存 4GB,key 数量大约 300 万,原配置是 allkeys-lru。问题表现是:每次运营批量导入一批商品数据后,缓存命中率就会明显下滑,从正常的 90% 以上跌到 82% 左右,持续几个小时都恢复不过来。
分析下来原因很清晰:批量导入的 key 会瞬间把 LRU 的“最近访问时间”刷成最新,但它们在导入后根本没有真实访问流量。根据 LRU 规则,这批虚假新 key 反而占据了缓存中的优势位置,把真正高频访问的老商品 key 挤出去了一部分。老商品 key 再次被访问时需要回源数据库,命中率自然就降下来了。
处理方案是切换到 allkeys-lfu,同时把 lfu-log-factor 调到 6、lfu-decay-time 保持 1。因为业务里的热点分布非常稳定,爆款商品就那么几百个,它们的长期访问频率远高于一次性导入的数据。切换到 LFU 之后,高频老 key 的 logc 会稳定维持在高位,批量导入的新 key 即使成功写入了缓存,因为没有持续访问,也会在短时间内衰减并被淘汰。切完第三天再看监控,命中率稳定回升到了 94%,evicted_keys 里的异常突刺也基本消失了。
这件事给我的启发是:如果你的业务存在明显的“一次性批量写 + 高频长久读”特征,LRU 很容易被污染,LFU 反而是更稳妥的选择。
4. 自己动手模拟 LRU 与 LFU,直观看出差异
4.1 极简 Python 模拟器:80 行代码讲清核心逻辑
讲再多理论,不如自己跑一遍模拟。我用 Python 写了一个极简的缓存淘汰模拟器,只实现最核心的“淘汰谁”逻辑,忽略 Redis 里各种工程细节,目的是直观感受 LRU 和 LFU 在不同访问模式下的行为差异。
from collections import OrderedDict class LRUCache: def __init__(self, capacity): self.capacity = capacity self.cache = OrderedDict() def access(self, key): if key in self.cache: self.cache.move_to_end(key) return True if len(self.cache) >= self.capacity: self.cache.popitem(last=False) self.cache[key] = None return False class LFUCache: def __init__(self, capacity): self.capacity = capacity self.cache = {} self.freq = {} def access(self, key): if key in self.cache: self.freq[key] += 1 return True if len(self.cache) >= self.capacity: victim = min(self.freq, key=self.freq.get) del self.cache[victim] del self.freq[victim] self.cache[key] = None self.freq[key] = 1 return False这段代码里 LRUCache 用的是 Python 的 OrderedDict,每次访问都把 key 移到末尾,淘汰时从头部踢掉一个;LFUCache 用一个字典维护访问次数,淘汰时选次数最少的 key。代码本身不复杂,但它完整保留了两种算法最核心的判断逻辑:一个看“位置新旧”,一个看“次数多少”。
4.2 三种访问模式下的实验结果
用上面的模拟器,我跑了三组典型的访问序列。
第一组是“稳定热点型”:总共 100 个 key,其中 10 个 key 占了 80% 的访问概率,缓存容量 30。跑 10 万次访问后统计命中率,LFU 明显占优,命中率大概高出 LRU 十几个百分点。原因符合预期:稳定热点 key 的频率计数会越滚越高,几乎不可能被淘汰。
第二组是“突发热点型”:某个 key 在短时间内被高频访问,之后彻底不再访问,其他 key 均匀访问。这一组里 LRU 表现更好,因为突发访问结束后,那个 key 的“最近访问时间”会慢慢变旧,很快就能被正常淘汰;而 LFU 里它的频率计数还停留在高位,如果这个 key 之前已经被访问了足够多次,它会像一块膏药一样贴在缓存里很长时间。
第三组是“周期切换型”热点:每隔一段时间,热点整体切换到另一批 key。两种算法各有胜负,LRU 的反应速度更快,切换后能迅速跟上新热点;LFU 则因为旧热点计数尚在高位,切换初期会多保留一段时间的“昨日黄花”,但配合衰减逻辑后差距会缩小。
4.3 实验结果告诉我们:别迷信任何单一算法
这个模拟直接印证了一个结论:不存在绝对更好的淘汰算法,只有更适合你访问模式的算法。如果你盲目地把 allkeys-lru 换成 allkeys-lfu,但在你的业务里热点本来就是变化极快的,LFU 的历史惯性反而会成为负担。
在实际选型时,我一般会问自己三个问题:
- 业务里的热点是长期稳定还是快速漂移的?稳定选 LFU,漂移选 LRU。
- 是否存在大量一次性批量访问?如果有,LRU 容易被污染,LFU 更抗造。
- 访问频率的分布是否极端?比如 20% 的 key 贡献了 80% 的访问,LFU 能更好地保护这 20%。
如果拿不准,先在测试环境用真实流量回放跑一下,再对两种策略做命中率对比,这是最靠谱的方式。
5. 线上淘汰问题的排查思路与避坑手册
5.1 明明 maxmemory 没改,为什么淘汰量暴增
最典型的情况是:maxmemory 设置得过低,没有把 AOF 重写、主从复制的 backlog 缓冲区、客户端输出缓冲区算进去。Redis 判断是否需要淘汰时看的是 used_memory,但它实际占用的系统内存还包括了复制积压缓冲区、AOF 缓存等额外开销。你把 maxmemory 设为 4GB,used_memory 可能刚涨到 3.8GB,但进程 RSS 已经把整机的内存吃紧了,系统开始 swap,性能直线下降,甚至触发 Linux 的 OOM Killer。
排查方法很简单:用 redis-cli 执行 INFO memory,重点看 used_memory、used_memory_rss、maxmemory 这三项。如果 used_memory_rss 长期明显高于 maxmemory,说明你的实例在“超卖内存”,需要调低 maxmemory 或者扩容。
另一个容易被忽略的点是淘汰风暴。当内存持续在 maxmemory 边缘抖动时,Redis 每次写命令都会触发 freeMemoryIfNeeded 进行淘汰,表现为读延迟偶发飙高、evicted_keys 持续增长。这种情况不要只盯着淘汰策略,优先检查是不是有瞬时大 key 写入、或者缓存过期时间设置得过于集中导致雪崩。
5.2 新 key 一写入就被淘汰,多半是这 3 个原因
第一种原因是内存长期处于“临界水位”。如果你的 Redis 一直顶着 maxmemory 跑,每次写入一个新 key 都会触发一次淘汰,新 key 参与采样并优先被淘汰的概率并不低。这种情况的解法不是调整淘汰算法,而是扩容或者治理大 key。
第二种原因是 LFU 下新 key 的初始计数吃亏。LFU 的新 key 初始 logc 是 5,而老热点 key 如果已经积累了较高的计数,在候选池比较里新 key 几乎必输。一个刚写入的 key 还没来得及被访问几次,就在淘汰轮次里被老 key 碾压出局。这种场景下需要调大 lfu-decay-time 的老化速度(也就是调小 lfu-decay-time 的值)或者调小 lfu-log-factor,让新 key 能通过一两次访问快速建立起热度。
第三种原因是批量导入污染了缓存。一次性写入海量新 key,全部带最新时间戳或初始计数,缓存里被塞满低价值数据。结果是后续正常访问的 key 反而容易被淘汰,命中率下降。解决办法是:批量写入时尽可能放在业务低峰期;导入完成后,对真正的核心热点 key 做一次“预热访问”;或者直接考虑用 LFU 降低批量导入的影响。
5.3 切换淘汰策略后命中率暴跌,怎么快速回滚
切换淘汰策略不是没有代价的。从 LRU 切换到 LFU 后,已有 key 的 lru 字段里存的是“最近访问时间”,并没有积累过任何频率计数。也就是说,切换瞬间,整个缓存相当于冷启动,所有 key 的频率计数都要从初始值开始重新积累,这个过程中命中率难免有一段下滑期。
如果你在切换后发现命中率不升反降,先别急着删缓存或者重启。用 CONFIG SET 切回原来的策略即可生效,不需要重启 Redis:
redis-cli config set maxmemory-policy allkeys-lru redis-cli config rewrite我个人建议任何淘汰策略的调整都至少观察一周。LFU 的频率积累和衰减都需要时间才能达到稳定状态,一两天内的波动不能说明策略有问题。如果一周后命中率仍然没有改善,再考虑回滚也不迟。
5.4 每天 2 分钟,用 INFO 命令给 Redis 做体检
最后分享一个我自己的习惯。每天花两分钟,执行下面三条命令,能拦住大多数内存和淘汰相关的隐患:
redis-cli info memory | grep -E "used_memory|maxmemory|mem_fragmentation_ratio" redis-cli info stats | grep evicted_keys redis-cli info commandstats | grep -E "cmdstat_get|cmdstat_set"重点看三个信号。一是 evicted_keys 是否出现持续性的高位增长,如果是,说明内存压力长期存在;二是内存碎片率(mem_fragmentation_ratio)是否超过 1.5,超过就该考虑重启或调大 maxmemory;三是 GET/SET 命令占比是否合理,如果 GET 命中率长期偏低,说明缓存的 key 设计和淘汰策略都有优化空间。
排查淘汰问题时还有一种很有用的方式:用 DEBUG OBJECT 查看单个 key 的 LRU/LFU 状态。在 LFU 模式下,这个命令会输出 key 的 logc 值和最后访问时间,你能直观看到某个 key 到底有多“热”,判断它被淘汰是不是合理。
LRU 和 LFU 的差别,往深了说就是“时间局部性假设”和“频率分布假设”的分野。Redis 能在几十毫秒内完成对海量 key 的采样、比较、淘汰,靠的不是某个复杂的算法,而是用内存换精度的四两拨千斤。无论是配置参数还是选择策略,我实际用下来的体会是:先看清业务的访问模式,再去调参数,比盲目照搬任何一套“最优配置”都靠谱得多。最后再提醒一句,改完配置一定记得 CONFIG REWRITE,不然下次重启又会回到老配置,这种低级失误最容易让人白忙一晚上。