ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis缓存淘汰算法详解:LRU与LFU实现原理、配置与调优

Redis缓存淘汰算法详解:LRU与LFU实现原理、配置与调优 线上 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 算法差异对照表五分钟理清选型大方向维度LRULFU核心度量最后一次访问时间单位时间内的访问频率数据特征假设近期访问过的数据更可能被再次访问历史访问频率高的数据更可能被再次访问擅长场景访问时间局部性强、热点会漂移热点稳定、访问频率差异大典型弱点一次性批量访问会污染缓存历史高频 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 近似 LRU24 位字段 随机采样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 位存 ldtlast decrement time上次衰减时间以分钟为单位低 8 位存 logclogistic 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 用作商品信息缓存内存 4GBkey 数量大约 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(lastFalse) 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, keyself.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 设为 4GBused_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 切回原来的策略即可生效不需要重启 Redisredis-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不然下次重启又会回到老配置这种低级失误最容易让人白忙一晚上。
返回列表