2616 字
约 8 分钟
17
S12-A21 · LRU 的三个坑:为什么"最近使用"不代表"最该留下"

一个服务用 LRU 缓存"用户最近的查询结果",容量 10 万条。上线后发现"热门商品"的缓存频繁失效——因为一次全量导出任务扫了 8 万个冷门商品,把热门条目全挤了出去。LRU 按"最近访问"淘汰,这个"污染"的机制很朴素:LRU 用"最近访问"作为"未来会被用"的预测,而"扫描"只是一次性的"看过",它不产生"会再用"的信号——于是预测失效。
而一次扫描产生的"最近访问"会污染整个缓存。

LRU 的假设是"最近用过的更可能被再用"。而三类访问模式会推翻这个假设,每一类的修法不同。


1. 坑一:扫描污染(scan resistance)

现象 机制
批量任务后命中率骤降 一次性访问大量冷数据
缓存"看起来满"但没命中 条目被无用数据占满
周期性发生 定时任务/报表

这是 LRU 最著名的缺陷:"只访问一次"的数据会把"高频"数据挤出去,而后者的价值高得多。这个缺陷的"成本"很直观:热门条目被挤掉后要重新回源,而重新回源又要占用"本来用于服务的容量"——于是"一次扫描"的代价可能持续十几分钟。

三者的"代价"不同:LRU-K 要记 K 次历史(内存),分段要维护两个队列(复杂度),TinyLFU 要维护频率草图(CPU + 内存)。所以"先确认是不是扫描"再选。
修法有三种:LRU-K(看最近 K 次访问)、SLRU/分段(新数据先进"试用区")、TinyLFU(用频率估计拦截一次性访问)。Caffeine 用的是 TinyLFU 的变体(W-TinyLFU),对扫描有天然抵抗。

// 一次扫描的影响(10 万容量,LRU)
扫描前:热门 2 万条占据缓存,命中率 92%
扫描 8 万冷条目:热门前 8 万条被挤空
扫描后:命中率掉到 15%(要等热门重新被访问才恢复)
// 用 W-TinyLFU:新条目先进"窗口区",只有频率够高才进"主区"
// 于是"一次性访问"在窗口区就被淘汰,不污染主区

2. 坑二:TTL 与"访问"的错配

情形 问题
条目 7 天未访问但仍在 TTL 内 占着容量
高频条目 TTL 短 频繁回源
TTL 随机抖动缺失 同时过期

第一项是"容量与时间两个维度不一致":LRU 只看访问,不看时间第一项的"量化"方式是"看'最久未访问条目的占比'":如果 30% 的容量被"一周没访问"的条目占着,那容量实际只有 70% 在服务。
,于是"很久没访问但 TTL 还长"的条目会一直占位。

第三项是"雪崩"的常见成因:同一批写入的条目会在同一时刻过期,于是"同时失效、同时回源"。

// 治"同时过期":TTL 加抖动
cache:
  ttl: 300s
  ttl_jitter: 60s          # 实际 TTL ∈ [240, 360]
  # 效果:过期点被打散,回源峰值下降

3. 坑三:key 的"访问密度"不均

情形 后果
少数 key 被极高频访问 热点集中,单分片被打满
大量 key 只访问一次 占容量
key 空间随时间增长 命中率缓慢下降

第一项的"识别"方式是"按分片看 QPS/CPU":总量正常而某一分片异常高——而"总命中率"完全正常,于是它容易被漏掉。
第一项是"热点 key"问题:集群模式下,某一个分片的 CPU/带宽被打满,而其它分片空闲。

第二项与坑一是同一件事的两种说法:"一次性 key"挤占容量。

第三项最隐蔽:key 空间增长(用户增长、商品增长)会让命中率"每年下降一点",而没人会把这归因到"缓存"。

4. 三种缓存策略的取舍

策略 适合
LRU 访问分布均匀、无明显扫描
LFU(频率) 热点稳定、有扫描
W-TinyLFU 通用(频率 + 近期 + 抗扫描)
FIFO / TTL-only 数据有时效、不需要"热度"判断

第二项的问题在"历史包袱":曾经热但已不热的条目会长期占据(因为频率高)。所以纯 LFU 需要"频率衰减"。第二项的"衰减"实现通常是"计数器定期减半":于是"新热点"能在几个周期内超过"老热点"——而"减半周期"决定了"多快能跟上变化"。

第一项在"无扫描"的场景里仍然简单有效——所以"换策略"之前要先确认"是不是真的有扫描污染"。

"扫描"可以在入口就拦住,比换策略更便宜:让批量任务不走缓存(或走独立的前缀)。

// 三种"绕过缓存"的做法(按成本)
1 批量任务直连数据源(不发缓存请求)→ 零成本,但数据源压力上升
2 批量任务用独立 key 前缀(cache:batch:...)→ 隔离污染
3 批量任务走独立缓存实例(物理隔离)→ 最彻底
// 判据:批量任务的"访问量"是否与"在线流量"同一量级

5. 怎么度量"策略是否合适"

指标 用途
命中率(整体 + 分时段) 基础
命中率的时间序列 发现"周期性骤降"
淘汰条目的"再次访问率" 判断"淘汰错了"
回源 QPS 成本侧

第三项的"门槛"可以估:若"淘汰后 1 分钟内又被访问"的比例超过 10%,说明这些条目本不该被淘汰——而"换成频率策略"通常能把这个数降到 3% 以下。
第三项最直接:如果"被淘汰的条目"很快又被访问,说明淘汰策略错。这个数能在"缓存埋点"里算出来。

第二项能定位"扫描":每次批量任务后命中率骤降、然后缓慢恢复,就是典型的扫描污染。第二项的"恢复曲线"还能区分"扫描"与"容量不足":扫描是"骤降后恢复",容量不足是"降到某点就不动"——两者的修法完全不同。

// 三个数的读法
命中率 92% → 85%(持续 10 分钟)→ 92%   :正常波动
命中率 92% → 15%(持续 10 分钟)→ 60%   :扫描污染(恢复慢)
命中率 92% → 88% → 84% → 80%(每月降)  :key 空间增长

"容量"与"工作集"的关系决定"淘汰是否会发生"——这一项常被忽略:

// 三种关系的表现
容量 >> 工作集:几乎不淘汰,命中率稳定高位(策略不重要)
容量 ≈ 工作集:频繁淘汰,策略影响明显(LRU vs LFU 差 10–30%)
容量 << 工作集:无论什么策略都命中率低(该加容量或改架构)
// 判据:先估工作集(去重后的活跃 key 数),再比容量

6. 排错:五种"缓存命中率异常"

现象 成因 处理方向
批量任务后骤降 扫描污染 换 W-TinyLFU 或分段
缓慢下降 key 空间增长 加容量或改策略
周期性同时失效 TTL 无抖动 加 jitter
集群某分片打满 热点 key 本地缓存/拆 key
淘汰后立刻回源 淘汰策略错 看"再次访问率"

第四项的"代价"要说清:本地缓存有"过期窗口",于是"更新后有几个实例还在返回旧值"——而它只在"极热 key 恰好被更新"时出现,很难复现。
第四项的修法是"本地缓存 + 分布式缓存两层":极热 key 放在每个实例的本地缓存里,于是"分布式缓存只承担"次热"。代价是"一致性变弱(本地有过期窗口)"。

最后一个"验收"方式:在换策略前后,用同一份"访问日志重放"对比命中率。这比"上线后看一周"快得多,且能排除负载变化的影响。

// 重放对比(不需要改线上)
1 抓 1 小时的访问序列(key + 时间戳)
2 用两种策略各模拟一遍(容量相同)
3 比:命中率、淘汰后重访率、回源次数
// 判据:新策略在这些指标上高 5% 以上才值得换

7. 边界:什么情况 LRU 就够

场景 为什么 做法
访问分布均匀、无扫描 假设成立 LRU
容量远大于工作集 不会发生淘汰压力 LRU
数据量小(几千条) 淘汰影响小 任意
数据只增不重访 缓存本身无意义 不做缓存

判断口径一句话:"有没有"只访问一次的大量数据"进入缓存。最后一条与"选型"有关的边界:换缓存库的成本远高于换策略参数。所以先从"加 jitter / 限制大 key / 分段"这类参数级动作开始,解决不了再换实现。
有(导出、爬取、报表),LRU 会被污染;没有(用户请求为主),LRU 够用。


8. 自测

  1. 扫描污染的机制是什么?为什么"只访问一次"的数据能挤掉高频数据?
  2. LRU-K、分段、TinyLFU 分别从什么角度解决扫描?Caffeine 用的是哪一类?
  3. "TTL 与访问的错配"为什么会造成"占着容量"?抖动解决了什么?
  4. 为什么"key 空间增长"会让命中率缓慢下降?它容易被归因到什么?
  5. "淘汰条目的再次访问率"为什么能直接说明"淘汰错了"?
  6. 判断"LRU 是否够用"的那个问题是什么?
S12-A21 · LRU 的三个坑:为什么"最近使用"不代表"最该留下"
https://www.clxhxhhr.top/posts/6584/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。