一个服务用 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. 自测
- 扫描污染的机制是什么?为什么"只访问一次"的数据能挤掉高频数据?
- LRU-K、分段、TinyLFU 分别从什么角度解决扫描?Caffeine 用的是哪一类?
- "TTL 与访问的错配"为什么会造成"占着容量"?抖动解决了什么?
- 为什么"key 空间增长"会让命中率缓慢下降?它容易被归因到什么?
- "淘汰条目的再次访问率"为什么能直接说明"淘汰错了"?
- 判断"LRU 是否够用"的那个问题是什么?