深蓝黑背景品牌封面:居中大标题两行「2000 万数据只缓存 20 万,凭什么留下的都是热点」,副标题「淘汰策略与热点识别」;下方装饰——一个漏斗:顶部宽口标注「全量 2000 万」,中间滤网层,底部窄口流出标注「缓存 20 万 · 全是热点」的青色方块;顶部标注「经典题解析 · 缓存」与「Day 08 · 09-26」 图 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 的热点完好无损。这就是两种策略盲区最直观的对照。

左半部分对比三种淘汰策略:LRU 淘汰最久未访问——实现简单、命中率高,但偶发的批量扫描会把真热点一次性挤出去;LFU 按访问频次淘汰——真热点留得住,但历史老热点难退场,需要热度随时间衰减;组合手段——时间衰减给旧热度打折,本地加远端两级缓存兜住突发。右半部分为运行流程:请求进来先看缓存,命中直接返回;未命中回源数据库,写入缓存;容量已满时按策略挑一个淘汰再写入。底部选型口诀:访问带突发扫描选 LFU,强时间局部性选 LRU,再配热点预热与缓存容量监控兜底 图 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/