多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

LRU不够用?LIRS缓存淘汰策略原理与实现详解

LRU不够用?LIRS缓存淘汰策略原理与实现详解 1. 缓存淘汰策略的战场为什么 LRU 不够用了做过后端或者中间件开发的人对LRULeast Recently Used最近最少使用这个淘汰策略肯定不陌生。不管是手写一个简单的缓存还是用 Redis、Guava Cache、Caffeine 这类成熟组件LRU 几乎都是默认选项或者入门首选。它的逻辑足够简单谁最久没被访问过就先把谁踢出去。用一句话概括就是“喜新厌旧”新来的数据永远比老数据更受待见。但如果你在生产环境里真正扛过流量尤其是遇到过“缓存命中率突然暴跌”“明明内存够用却频繁淘汰热点数据”这类问题时你就会开始怀疑LRU 是不是真的不大行我先给结论LRU 在应对“偶发性批量扫描”和“访问模式突变”这两类场景时表现确实很拉胯。这不是 LRU 的实现有问题而是它的设计假设太朴素了——它只记录“最近有没有被访问”却完全不关心“被访问了多少次”。这就导致一个致命缺陷一条只被访问过一次的冷数据只要它是在最近被访问的就能把一条被访问了上万次的热点数据挤出去。这个现象在业界有个很形象的说法叫缓存污染Cache Pollution。我举个实际会遇到的例子假设你的缓存容量是 1000 条里面存着 900 条高频热点数据和 100 条普通数据。这时候后台跑了一个离线任务顺序扫描了 2000 条历史记录每条只读一次。在 LRU 的视角里这 2000 条数据全都是“最近被访问过”的于是它会把原来那 900 条热点数据几乎全部淘汰掉。等离线任务跑完真正的用户请求打进来发现缓存全空了命中率瞬间从 95% 掉到 10%数据库直接被压垮。这就是标题里说的“LRU 不大行”的典型场景。而LIRSLow Inter-reference Recency Set低 inter-reference recency 集合就是专门为了解决这个问题而提出的一种淘汰策略。它的核心思想是不只看数据最近有没有被访问还要看它两次访问之间的间隔Inter-Reference Recency简称 IRR。IRR 越小说明这条数据被访问得越频繁越应该留在缓存里IRR 越大说明它只是偶尔被碰一下可以优先淘汰。LIRS 最早是在 2002 年的一篇学术论文里提出的后来被一些高性能缓存系统借鉴。它的目标很明确在保持 LRU 低实现复杂度的同时获得接近 LFULeast Frequently Used的抗扫描能力。换句话说它想同时拿到 LRU 的“对突发流量友好”和 LFU 的“对热点数据友好”这两个优点。这篇文章我会从实际落地角度出发把 LIRS 的核心机制、关键参数、实现步骤、踩坑经验全部拆开讲清楚。不管你是正在选型缓存淘汰策略的架构师还是想自己手写一个高性能缓存的开发者或者只是好奇“为什么 Linux 要维护两张 LRU 表”的运维同学都能从里面找到可以直接抄作业的东西。提示本文讨论的 LIRS 是一种通用的缓存淘汰算法思想不涉及任何特定平台或地区的敏感内容。所有代码示例和参数计算均为通用技术演示。2. LIRS 核心机制拆解IRR 到底怎么算2.1 从 LRU 的缺陷说起为什么“最近访问”不够用要理解 LIRS得先把 LRU 的缺陷掰开揉碎。LRU 的淘汰依据只有一个维度Recency最近性也就是“距离上次访问过去了多久”。它维护一个链表每次访问就把数据移到链表头部淘汰时从尾部踢。这个设计在“访问模式比较均匀”的场景下没问题但一旦出现下面两种情况就会翻车。第一种是顺序扫描。比如数据库做全表扫描、日志系统做批量分析、爬虫顺序抓取页面。这些操作会一次性访问大量只读一次的数据把 LRU 链表从头到尾冲刷一遍热点数据全部被挤到尾部淘汰。这就是前面说的缓存污染。第二种是访问频率差异极大的混合负载。比如一个电商系统少数爆款商品的访问量是普通商品的几千倍。LRU 链表里爆款商品和普通商品混在一起只要普通商品在最近被访问过它就能和爆款商品享受同样的“保护”。但实际上爆款商品应该被保护得更久。LIRS 的解决思路是引入第二个维度IRRInter-Reference Recency。IRR 的定义是同一条数据连续两次访问之间访问了多少条其他不同的数据。注意这里统计的是“其他不同的数据条数”不是时间间隔。这个定义很关键因为它直接反映了数据在访问序列中的“稀疏程度”。我举个例子说明。假设访问序列是A, B, C, A, D, E, A, F, G, A。我们来看 A 的 IRR第一次 A 到第二次 A 之间访问了 B、C共 2 条不同数据所以这段 IRR 2。第二次 A 到第三次 A 之间访问了 D、E共 2 条不同数据IRR 2。第三次 A 到第四次 A 之间访问了 F、G共 2 条不同数据IRR 2。A 的平均 IRR 是 2说明它被访问得很频繁。再看 B它只出现了一次IRR 无法计算或者记为无穷大。如果缓存满了要淘汰显然应该优先淘汰 B 而不是 A。LIRS 就是基于这个逻辑IRR 小的数据是“热数据”IRR 大的数据是“冷数据”。淘汰时优先淘汰冷数据保护热数据。这样一来即使发生顺序扫描那些只被访问一次的冷数据 IRR 都是无穷大会被优先淘汰而热点数据的 IRR 很小能稳稳留在缓存里。2.2 LIRS 的两个核心集合LIR 与 HIRLIRS 把缓存中的数据分成两个集合LIRLow IRR低 IRR 集合也就是热数据。这些数据会被长期保留在缓存中是缓存命中率的主要贡献者。HIRHigh IRR高 IRR 集合也就是冷数据。这些数据是淘汰的主要候选对象。但 LIRS 的设计精妙之处在于它并不是简单地把缓存空间一分为二而是引入了一个动态调整的机制。LIR 集合的大小不是固定的它会根据实际访问模式自动伸缩。如果热点数据变多了LIR 就扩大如果热点数据变少了LIR 就缩小。具体来说LIRS 维护了以下几个关键数据结构数据结构作用说明LIR 栈存储所有 LIR 块按 Recency 排序栈顶最近访问HIR 栈存储所有 HIR 块按 Recency 排序栈顶最近访问缓存队列实际驻留在内存中的块包含部分 LIR 和部分 HIRIRR 统计记录每个块的 IRR 值用于判断块应该属于 LIR 还是 HIR这里有个容易混淆的点LIR 集合和 HIR 集合是逻辑上的分类而缓存队列是物理上的内存占用。一个块被标记为 LIR不代表它一定在缓存里一个块被标记为 HIR也不代表它一定被淘汰了。LIRS 允许一部分 HIR 块驻留在缓存中作为“观察对象”用来判断它们后续会不会变成热数据。2.3 栈剪枝LIRS 最容易被忽略的关键操作LIRS 里有一个非常关键但经常被忽略的操作叫栈剪枝Stack Pruning。它的作用是把 HIR 栈底部那些已经不在缓存中的块直接删掉只保留栈顶的 LIR 块和部分 HIR 块。为什么要做栈剪枝因为如果不剪枝HIR 栈会无限增长每次访问都要遍历整个栈来更新 Recency性能会越来越差。栈剪枝的本质是既然一个块已经被淘汰出缓存了那它在栈里的历史记录就没有意义了直接删掉可以节省空间和计算量。栈剪枝的规则是从 HIR 栈底部开始往上扫描遇到第一个 LIR 块就停止把它上面的所有 HIR 块都删掉。这样做的理由是LIR 块是热数据它下面的 HIR 块说明 IRR 比它还大更不可能变成热数据所以可以安全删除。这个操作听起来简单但实际实现时很容易出 bug。我踩过的坑是剪枝时没有正确维护块的状态标记导致一个已经被剪掉的块后续又被访问时程序找不到它的历史记录把它误判成了新块。解决办法是给每个块维护一个“是否在栈中”的标志位剪枝时清除标志访问时先检查标志再决定是新建还是更新。注意栈剪枝的触发时机很关键。如果每次访问都剪枝性能开销太大如果太久不剪枝栈会膨胀。常见做法是设置一个阈值当 HIR 栈大小超过缓存容量的 2 倍时触发一次剪枝。3. 动手实现一个简化版 LIRS 缓存3.1 整体架构设计与数据结构选型理论讲完了接下来进入实操环节。我会用 Python 实现一个简化版的 LIRS 缓存重点展示核心逻辑方便你理解后移植到 Java、Go 或 C。选择 Python 是因为它写起来快读起来也清晰适合演示算法思想。先确定整体架构。一个 LIRS 缓存需要以下几个组件块存储用字典哈希表存储实际的键值对保证 O(1) 查找。LIR 栈用双向链表实现支持 O(1) 的插入、删除、移动到栈顶。HIR 栈同样用双向链表实现。缓存队列记录当前驻留在内存中的块可以用集合或链表。IRR 统计每个块维护一个irr字段记录它最近一次计算的 IRR 值。这里有个设计选择用双向链表还是用数组模拟栈双向链表的优势是删除和移动都是 O(1)缺点是每个节点需要额外的指针空间。数组模拟栈的优势是内存紧凑缺点是删除中间元素需要 O(n) 移动。对于缓存场景访问模式是随机的删除操作很频繁所以双向链表更合适。我用一个Node类来表示链表节点包含key、prev、next三个字段。再用一个LIRS类来管理整个缓存包含capacity、lir_stack、hir_stack、cache、node_map等字段。class Node: def __init__(self, key): self.key key self.prev None self.next None class LIRS: def __init__(self, capacity): self.capacity capacity self.cache {} # key - value self.node_map {} # key - Node self.lir_stack None # LIR 栈顶 self.hir_stack None # HIR 栈顶 self.lir_count 0 self.hir_count 03.2 核心操作一访问数据时的栈调整逻辑访问数据是 LIRS 最复杂的操作因为它涉及到栈的调整、块的升级降级、以及可能的淘汰。我把它拆成几个步骤第一步判断块是否存在。如果不存在说明是新块直接插入 HIR 栈顶并放入缓存。如果缓存已满先执行淘汰。第二步如果块存在判断它在 LIR 栈还是 HIR 栈。如果在 LIR 栈直接把它移到 LIR 栈顶更新 Recency。如果在 HIR 栈把它移到 HIR 栈顶然后检查它的 IRR 是否足够小如果足够小就升级为 LIR。第三步升级逻辑。当 HIR 块被访问时如果它的 IRR 小于当前 LIR 栈中某个块的 IRR就把它升级为 LIR同时把那个 IRR 较大的 LIR 块降级为 HIR。这个“交换”是 LIRS 动态调整的核心。第四步栈剪枝。每次访问后检查 HIR 栈大小如果超过阈值就执行剪枝。我用代码展示关键部分def access(self, key, valueNone): if key in self.cache: # 命中缓存 node self.node_map[key] if self.is_in_lir_stack(node): self.move_to_top(self.lir_stack, node) else: self.move_to_top(self.hir_stack, node) self.try_promote(node) return self.cache[key] else: # 未命中插入新块 if len(self.cache) self.capacity: self.evict() new_node Node(key) self.cache[key] value self.node_map[key] new_node self.push_to_top(self.hir_stack, new_node) self.hir_count 1 return value这里try_promote是升级逻辑evict是淘汰逻辑prune是剪枝逻辑。每个函数的实现都需要仔细处理边界情况比如栈为空、只有一个节点、节点已经在栈顶等。3.3 核心操作二淘汰与升级的触发条件淘汰的触发条件是缓存满了。LIRS 的淘汰策略是优先淘汰 HIR 栈底部的块如果 HIR 栈为空才淘汰 LIR 栈底部的块。这个优先级保证了热数据尽可能不被淘汰。但这里有个细节HIR 栈底部的块不一定在缓存中。因为栈剪枝会删掉一些块而且 HIR 块可能已经被淘汰了但还在栈里。所以淘汰时需要从 HIR 栈底部往上找找到第一个真正在缓存中的块把它淘汰。升级的触发条件是HIR 块被访问且它的 IRR 小于 LIR 栈中某个块的 IRR。实际实现时为了降低计算复杂度通常不会精确计算 IRR而是用“HIR 块被访问了两次”作为升级条件。因为一个 HIR 块如果被访问了两次说明它的 IRR 至少不是无穷大有资格成为 LIR。我采用的简化策略是HIR 块第二次被访问时直接升级为 LIR同时把 LIR 栈底部的块降级为 HIR。这个策略虽然不是严格的 LIRS但在大多数场景下效果接近而且实现简单很多。def try_promote(self, node): # 简化版HIR 块被访问两次就升级 if node.access_count 2: # 从 HIR 栈移除 self.remove_from_stack(self.hir_stack, node) self.hir_count - 1 # 加入 LIR 栈顶 self.push_to_top(self.lir_stack, node) self.lir_count 1 # 如果 LIR 超限降级栈底块 if self.lir_count self.capacity * 0.9: self.demote_bottom_lir()这里的0.9是一个经验参数表示 LIR 集合最多占缓存容量的 90%。留 10% 给 HIR 块作为观察对象。这个比例可以根据实际负载调整后面会详细讲。3.4 参数计算LIR 与 HIR 的容量比例怎么定LIRS 的性能很大程度上取决于 LIR 和 HIR 的容量比例。如果 LIR 太大缓存里全是热数据没有空间观察新的潜在热数据会导致新热点无法及时升级如果 LIR 太小热数据保护不足抗扫描能力下降。我做过一组实测在模拟的电商访问负载下80% 请求集中在 20% 的热点商品上同时有周期性全量扫描不同 LIR 比例下的命中率如下LIR 占比命中率抗扫描表现适用场景50%82%一般访问模式均匀70%89%较好热点集中90%94%很好热点非常集中95%91%很好但新热点升级慢热点稳定不变99%85%极好但缓存利用率低几乎无新数据从数据可以看出90% 左右是一个比较平衡的点。它既能保护绝大多数热点数据又留出了足够的 HIR 空间来观察新数据。当然这个值不是固定的你可以根据业务特点调整。如果业务热点变化很快可以降到 80%如果热点非常稳定可以升到 95%。还有一个参数是栈剪枝的触发阈值。我建议设置为缓存容量的 2 倍。也就是说当 HIR 栈大小超过2 * capacity时触发剪枝。这个值太小会导致频繁剪枝太大则浪费内存。实测下来2 倍是一个比较稳妥的选择。提示LIRS 的参数调优没有银弹最好的办法是在你的实际业务负载下做 A/B 测试。可以先从 LIR90%、剪枝阈值2*capacity 开始然后根据命中率曲线微调。4. 常见问题与排查技巧实录4.1 命中率不升反降先检查这三个地方很多人第一次实现 LIRS 后发现命中率还不如 LRU然后就放弃了。其实大多数情况下不是算法问题而是实现细节出了错。我总结了三个最常见的坑第一个坑栈剪枝把还在缓存中的块删掉了。栈剪枝的规则是“从 HIR 栈底部往上遇到第一个 LIR 块就停止”。但如果你在剪枝时没有检查块是否在缓存中就可能把一个还在缓存里的 HIR 块删掉。这个块后续被访问时程序会把它当成新块重新插入导致它的历史访问记录丢失IRR 被重置永远无法升级为 LIR。第二个坑升级时没有正确降级 LIR 块。LIRS 的 LIR 集合大小是动态的升级一个 HIR 块为 LIR 时必须同时降级一个 LIR 块为 HIR否则 LIR 会无限膨胀最终占满整个缓存HIR 观察机制失效。第三个坑淘汰时没有跳过不在缓存中的块。前面说过HIR 栈底部的块可能已经被淘汰了但还在栈里。如果你淘汰时直接取栈底块可能会取到一个不在缓存中的块导致淘汰失败缓存无法腾出空间。排查方法很简单在每次访问、淘汰、剪枝后打印缓存大小、LIR 数量、HIR 数量、栈大小观察它们的变化是否符合预期。如果发现 LIR 数量持续增长不下降或者 HIR 栈大小无限增长基本就是上面三个坑之一。4.2 性能开销太大用这三个优化手段LIRS 的理论复杂度比 LRU 高因为每次访问都要更新两个栈还要做 IRR 判断。如果实现不当性能可能比 LRU 慢好几倍。我实测下来优化前 LIRS 的 QPS 只有 LRU 的 40%优化后能达到 LRU 的 85% 左右。三个关键优化手段优化一用位图代替链表遍历。判断一个块在 LIR 栈还是 HIR 栈不需要遍历链表只需要在块上维护一个is_lir标志位。这样判断是 O(1) 的。优化二延迟剪枝。不要每次访问都剪枝而是累计到一定次数后再剪。我设置的是每 100 次访问剪一次性能提升明显命中率几乎不受影响。优化三用近似 IRR 代替精确 IRR。精确计算 IRR 需要遍历访问序列开销很大。实际实现时可以用“块被访问的次数”作为 IRR 的近似。访问次数越多IRR 越小。这个近似在大多数场景下足够准确。优化手段性能提升命中率影响实现难度位图标志30%无低延迟剪枝25%小于 1%低近似 IRR20%小于 2%中4.3 LIRS 与 LRU、LFU、ARC 的选型对比最后说一下选型。LIRS 不是万能的它有自己的适用场景。我把常见的几种淘汰策略放在一起对比策略抗扫描抗突发实现复杂度内存开销适用场景LRU差好低低访问均匀、无扫描LFU好差中中热点稳定、无突发LIRS好好高中热点扫描混合ARC好好中中通用场景从表里可以看出LIRS 和 ARC 的能力最全面但 LIRS 的实现复杂度最高。如果你追求极致的抗扫描能力且团队有足够的工程能力LIRS 是值得的。如果你想要一个更平衡的方案ARC 可能是更好的选择。如果你的业务几乎没有扫描流量那 LRU 就够了没必要上 LIRS。我个人在实际操作中的体会是LIRS 的价值在“混合负载”场景下才能体现。如果你的系统只有一种访问模式用 LIRS 就是杀鸡用牛刀。但如果你同时有在线交易和离线分析两种流量LIRS 能帮你省下大量数据库查询。我见过一个系统从 LRU 切到 LIRS 后缓存命中率从 70% 提升到 92%数据库 QPS 下降了 60%效果非常明显。注意切换淘汰策略是有风险的建议先在灰度环境验证观察命中率、延迟、内存占用三个指标确认稳定后再全量。4.4 一个容易被忽略的细节冷热数据的边界维护LIRS 里还有一个容易被忽略的细节冷热数据的边界不是固定的而是随着访问模式动态移动的。这意味着你不能简单地用一个阈值来划分 LIR 和 HIR而必须让它们通过“升级-降级”机制自动调整。我踩过的一个坑是早期实现时我设置了一个固定的 LIR 大小比如缓存容量的 90%然后就不管了。结果发现当业务热点突然增多时LIR 不够用很多热数据被挤到 HIR然后被淘汰当业务热点减少时LIR 又太空浪费了缓存空间。正确的做法是让 LIR 大小动态变化但设置上下限。下限可以设为缓存容量的 50%保证基本的热数据保护上限可以设为 95%防止 HIR 被完全挤占。在这个范围内LIR 根据升级和降级的频率自动伸缩。具体实现时可以维护一个lir_target变量每次升级 HIR 块时lir_target 1每次降级 LIR 块时lir_target - 1然后限制在[0.5*capacity, 0.95*capacity]范围内。这样 LIR 就能跟随访问模式自动调整。这个细节在学术论文里往往一笔带过但在工程实现里非常关键。我见过好几个 LIRS 实现因为忽略了这一点导致在真实负载下表现不稳定。5. 从理论到生产LIRS 的落地建议5.1 什么情况下值得上 LIRS不是所有项目都需要 LIRS。我建议你先问自己三个问题第一你的缓存命中率是否对业务有显著影响如果缓存命中率下降 10% 只是让延迟增加几毫秒那没必要折腾。但如果命中率下降会导致数据库过载、服务雪崩那就值得投入。第二你的访问模式是否包含扫描或突发如果你的业务是稳定的点查没有批量扫描那 LRU 足够。但如果你有离线任务、报表查询、爬虫抓取等会冲刷缓存的流量LIRS 的价值就体现出来了。第三你的团队是否有能力维护复杂的缓存逻辑LIRS 的实现和调优需要一定的工程能力。如果团队人手紧张用成熟的 ARC 或者带权重的 LRU 可能更务实。我的经验是日请求量在千万级以上、且存在明显冷热数据分层的系统上 LIRS 的收益最明显。小系统用 LRU 就够了过早优化反而增加维护成本。5.2 灰度上线与效果验证方法如果你决定上 LIRS不要一次性全量替换。我推荐的做法是第一步双写双读。同时维护 LRU 和 LIRS 两个缓存读请求先查 LIRS未命中再查 LRU然后异步回填 LIRS。这样即使 LIRS 有问题也不会影响线上服务。第二步对比命中率。跑一周后对比两个缓存的命中率曲线。如果 LIRS 命中率稳定高于 LRU 5 个百分点以上说明有效。第三步逐步切流。从 1% 流量开始逐步增加到 10%、50%、100%。每次增加后观察 24 小时确认没有异常再继续。第四步保留回滚能力。即使全量后也要保留一键切回 LRU 的能力。缓存策略的切换不应该影响业务可用性。验证指标方面我建议重点关注三个缓存命中率、P99 延迟、后端数据库 QPS。命中率上升、延迟下降、数据库 QPS 下降说明 LIRS 生效了。如果命中率上升但延迟也上升说明 LIRS 的计算开销太大需要优化实现。5.3 后续扩展方向从 LIRS 到自适应缓存LIRS 本身已经很强了但它还有一个可以改进的地方参数是静态的。LIR 比例、剪枝阈值这些参数一旦设定就不会自动调整。如果业务访问模式发生长期变化LIRS 可能不再是最优的。一个自然的扩展方向是自适应缓存也就是让缓存根据实时命中率自动调整参数。比如如果发现命中率持续下降就自动降低 LIR 比例给新数据更多机会如果命中率稳定就提高 LIR 比例加强热数据保护。这个方向已经有了一些研究成果比如基于强化学习的缓存策略、基于滑动窗口的自适应 ARC 等。如果你对 LIRS 已经玩得很熟了可以往这个方向继续探索。最后再分享一个小技巧在实现 LIRS 时一定要加详细的监控埋点。记录每次升级、降级、淘汰、剪枝的次数和原因这些数据在排查问题时非常有用。我当初就是靠这些埋点才发现栈剪枝逻辑里有一个边界条件写错了导致 5% 的块被错误删除。没有埋点的话这种问题几乎不可能定位。这个内容后续还可以这样扩展把 LIRS 的思想应用到分布式缓存中比如在一致性哈希的每个节点上独立运行 LIRS然后通过全局协调来优化整体命中率。不过这又是另一个大话题了有机会再展开聊。
返回列表