图 1:本文封面——漏斗滤出 1% 的热点数据。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。缓存容量只有全量数据的 1%,「怎么保证留下的都是热点」考的不是某个淘汰算法的实现,而是热点识别与策略选型的判断力。
想象面试现场,面试官问:「2000 万条数据,缓存只能放 20 万条,怎么保证缓存里留下的都是热点?」
第一步,审题:先承认一个前提。
热点无法预知,只能被访问行为揭示。所以这道题的正确框架是:用一种策略让「被频繁访问的数据自然留下,不被访问的自然淘汰」,再针对策略的盲区打补丁。谁声称能提前算出热点,谁在拍脑袋。
第二步,基础回答:两种主流淘汰策略。
LRU(最近最少使用)淘汰最久没被访问的数据。访问具有时间局部性——最近用过的很可能马上再用,所以 LRU 是大多数场景的默认选择。Redis 官方文档也把 allkeys-lru 列为没有特殊理由时的推荐默认值。但 LRU 有一个著名盲区:偶发的批量扫描。一次全表导出、一次离线任务顺序读,大量只访问一次的冷数据涌进缓存,会把真正的热点一次性挤出去——LRU 只看「最近」,分不清「频繁」。
LFU(最不经常使用)按访问频次淘汰,频次高的真热点留得住,天然免疫批量扫描。Redis 4.0 起提供 LFU 模式,用少量比特的概率计数器估算访问频次。但 LFU 有自己的盲区:历史老热点难退场——一个曾经爆火、现在已经没人看的内容,累计频次太高,迟迟不肯让位。所以 Redis 给 LFU 配了热度衰减参数,默认每分钟把计数器打折一次,让「过气的热点」慢慢退场。
第三步,用实验对比两者的盲区。
口说无凭,跑个模拟。容量 8 的缓存,6 个热点键各访问 50 次,随后 30 个冷键各扫描一次(模拟批量任务),最后看热点键还剩几个命中。Node.js 22.15.0 直接运行(node cache-eviction.js):
'use strict';
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.map = new Map(); // Map 迭代序即插入序,重插即移到最新
}
get(key) {
if (!this.map.has(key)) return false;
const v = this.map.get(key);
this.map.delete(key);
this.map.set(key, v); // 移到最新
return true;
}
put(key) {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, 1);
if (this.map.size > this.capacity) {
this.map.delete(this.map.keys().next().value); // 淘汰最久未访问
}
}
}
class LFUCache {
constructor(capacity) {
this.capacity = capacity;
this.freq = new Map(); // key -> 访问次数
this.time = new Map(); // key -> 最近访问序号(同频时淘汰最旧)
this.tick = 0;
}
touch(key) {
this.freq.set(key, (this.freq.get(key) || 0) + 1);
this.time.set(key, ++this.tick);
}
get(key) {
if (!this.freq.has(key)) return false;
this.touch(key);
return true;
}
put(key) {
this.touch(key);
if (this.freq.size > this.capacity) {
let victim = null;
for (const k of this.freq.keys()) {
if (
victim === null ||
this.freq.get(k) < this.freq.get(victim) ||
(this.freq.get(k) === this.freq.get(victim) && this.time.get(k) < this.time.get(victim))
) {
victim = k;
}
}
this.freq.delete(victim); // 淘汰频次最低者,同频淘汰最旧
this.time.delete(victim);
}
}
}
function simulate(CacheClass) {
const cache = new CacheClass(8);
for (let round = 0; round < 50; round++) {
for (let h = 1; h <= 6; h++) cache.get(`hot-${h}`) || cache.put(`hot-${h}`);
}
for (let c = 1; c <= 30; c++) cache.get(`cold-${c}`) || cache.put(`cold-${c}`); // 突发扫描
let hits = 0;
for (let h = 1; h <= 6; h++) if (cache.get(`hot-${h}`)) hits++;
return hits;
}
const lruHits = simulate(LRUCache);
const lfuHits = simulate(LFUCache);
console.log(`突发扫描后 6 个热点键的命中数:LRU ${lruHits}/6,LFU ${lfuHits}/6`);
if (lfuHits <= lruHits) throw new Error('预期 LFU 在突发扫描下保住更多热点');
实测输出:
突发扫描后 6 个热点键的命中数:LRU 0/6,LFU 6/6
一次批量扫描,LRU 的热点全军覆没,LFU 的热点完好无损。这就是两种策略盲区最直观的对照。
图 2:LRU 与 LFU 的盲区对比、缓存运行流程与选型口诀。
第四步,策略之外的三个补丁。
一,组合手段。时间衰减给旧热度打折,解决 LFU 的老热点退场问题;本地缓存加远端缓存两级结构,兜住突发流量。二,热点预热。可预期的热点(定时的活动、定点的推送)提前灌进缓存,别等请求来撞。三,监控兜底。Redis 的 INFO 命令返回命中数与未命中数,命中率明显低于预期时先查 evicted_keys——淘汰键太多,说明策略或容量错了。策略选型不是一锤子买卖,是按命中率数据持续校正的。
易错点清单。
一,以为 LRU 没有盲区——批量扫描挤掉热点是它的经典死法。二,以为 LFU 严格更优——老热点不退场的问题必须配时间衰减。三,纠结淘汰算法的精确实现——Redis 的 LRU 本身就是采样近似的,工程上「近似但省内存」比「精确但费内存」更值钱。四,只谈策略不谈观测——没有命中率监控,策略选对了也证明不了。
回到面试现场,建议的答题节奏:先立「热点靠行为揭示」的前提,再对比 LRU 与 LFU 的盲区,然后补时间衰减、多级缓存、热点预热、命中率监控四个补丁。embedding 缓存与推理结果缓存面临同样的「容量有限、冷热不均」,这套判断直接平移。
你的缓存用的是哪种淘汰策略?有没有被批量任务「洗」过缓存的经历?欢迎分享。
参考资料
- Redis key eviction(Redis 官方文档:maxmemory-policy 各淘汰策略的语义与选型建议,allkeys-lru 为无特殊理由时的默认推荐,LFU 自 4.0 起可用并采用概率计数器与衰减机制,LRU 为采样近似实现,INFO 可观测命中率与淘汰键数) — https://redis.io/docs/latest/develop/reference/eviction/
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,
Map的插入序迭代等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/