☰
Node.js 缓存优化实战:用 LRU Cache 提升接口响应效率
2026/10/10 10:38:29 网站建设 项目流程

Node.js 服务端一旦流量上来,最先挨打的往往不是 CPU,而是数据库和响应时延。我在给接口做性能优化时,试过各种手段,最后性价比最高的还是加内存缓存,而所有淘汰策略里,LRU Cache 是 Node.js 场景下最顺手、也最不容易出错的那个。这篇文章就围绕"Node.js 用 LRU Cache 提升缓存效率"这个主题,把原理、手写实现、生产级库的用法、真实落地场景和常见坑完整过一遍。

适合这几类人看:刚接触 Node.js 后端、想给接口提速又怕缓存撑爆内存的开发者;准备面试、想搞懂 LRU 底层数据结构的人;已经在用缓存但发现命中率上不去、内存一直涨的人。不管你是哪一类,看完应该都能直接动手改代码。

1. 为什么非要 LRU:缓存淘汰的底层逻辑

1.1 缓存是空间换时间,但内存不是无限的

缓存的本质很简单:把算过的结果存起来,下次直接用。比如用户详情接口,查数据库要经过 SQL 解析、磁盘 IO、网络传输,一次可能要 50 毫秒;调用方如果 1 秒内请求同一个用户 10 次,每次都重查一遍,就是在白扔 500 毫秒。如果第一次查完放到内存里,后面的请求直接读缓存,单次响应能压到 1 毫秒以内,热度越高的 key 收益越大。

但内存是有上限的。Node.js 进程默认的堆内存老生代大小通常只有几 GB,V8 在老生代接近上限时会频繁触发 GC,甚至直接进程崩溃。缓存数据又不能无限堆,所以必须给缓存一个容量上限。问题来了:容量满了以后,新数据进来,到底该踢掉谁?

这就是淘汰策略要做的事。淘汰策略选得对不对,直接决定缓存命中率,进而决定你引入缓存的收益有多大。

1.2 三种主流淘汰策略,为什么 LRU 胜出

先看 FIFO(先进先出)。实现最简单:一个队列,先放进去的先被淘汰,不管它后来有没有被访问过。这么做的问题很典型:有些数据是"常青树",比如公共配置、商品详情,只要被放进来过一次就一直是高频访问对象,FIFO 会一视同仁地把它们当"老人"踢出去,导致这些热数据反复失效、反复重建,缓存形同虚设。

再看 LFU(最不经常使用)。按访问频次淘汰,访问次数最少的先走。听上去很公平,但实际有两个坑:第一,要额外维护每个 key 的访问计数,内存和计算开销都比 LRU 高;第二,存在"旧热点霸榜"问题——某个 key 在活动期间被刷了几十万次,活动结束后没人再碰它,但它的计数仍然很高,永远排在淘汰队列末尾,占着宝贵内存却不产生任何收益。

LRU 的思路和这两者都不一样。它只关注"最近"有没有被访问过,核心假设是:如果一个数据最近被访问过,那么短时间内它大概率还会被访问;反过来,很久没被碰过的数据,接下来的访问概率也低,踢掉它代价最小。具体实现时,每次 get 或 set 都把对应 key 挪到"最活跃"的位置,满了就淘汰"最久没被访问"的那个 key。

三种策略的对比,我用一个简单表格总结一下:

策略淘汰依据实现成本典型问题
FIFO进入缓存的时间最低会误杀高频热数据
LFU累计访问次数较高旧热点占位,额外计数开销
LRU最近访问时间低全量扫描场景表现一般

在我的实践里,绝大多数接口的访问模式都满足"时间局部性"——用户刚看过 A,很快还会再看 A 或与 A 相关的东西。LRU 正好命中这种模式,所以它成为我默认的首选。

1.3 LRU 不是万能药:什么时候别用它

任何策略都有边界。LRU 最典型的失效场景是大规模遍历:比如一次性拉取全量订单做统计,所有 key 都被扫了一遍,每个 key 都被"刷新"成最近访问,缓存里留下的全是这批一次性数据,真正高频的少量 key 反而被挤掉了。另外,如果业务访问没有任何规律、完全均匀分布,LRU 和随机淘汰的差距也不大。这时候更适合考虑业务层面更确定的方案,比如针对具体 key 设置不同的过期时间、或者干脆用带权重淘汰策略。

知道了 LRU 适用在哪些场景,下面直接上代码。

2. 先别急着装依赖:用 Map 手写一个 LRU Cache

2.1 为什么 JS 的 Map 天生适合做 LRU

很多人一想到 LRU,第一反应是"双向链表 + 哈希表",这是教科书里的经典实现:链表负责维护访问顺序,哈希表负责 O(1) 定位节点。但在 JavaScript 里,其实不需要这么折腾,因为 Map 本身就保证了迭代顺序——Map 会按照 key 的插入顺序返回键值对,每次先删再插一个已存在的 key,它就会排到最后面,变成"最新"。

这个特性简直是给 LRU 量身定制的。我只需要遵守两条规则:

  • 访问(get)一个 key 时,先删除再重新插入,让它排到队尾,表示"刚被用过"。
  • 插入新 key 后,如果 Map 的大小超过上限,就从队头开始删,队头就是最久没被动过的 key。

整个过程不需要手动维护链表指针,也不涉及双向节点的 prev/next,代码量少一个数量级,还不太容易写错。

2.2 核心实现与逐行解读

于是我手写了一个最小可用的 LRU 类,代码不到三十行:

class LruCache { constructor(maxSize = 100) { this.maxSize = maxSize; this.map = new Map(); } get(key) { if (!this.map.has(key)) { return undefined; } const value = this.map.get(key); // 删除后重新插入,让这个 key 变成最新访问 this.map.delete(key); this.map.set(key, value); return value; } set(key, value) { if (this.map.has(key)) { this.map.delete(key); } this.map.set(key, value); if (this.map.size > this.maxSize) { const oldestKey = this.map.keys().next().value; this.map.delete(oldestKey); } } }

逐行说几个关键点。

get方法里,先判断 key 是否存在,存在了还要"删了再插"。这一步很多人不理解:既然都能 get 到 value 了,为什么不直接返回?原因是 Map 的迭代顺序只认插入顺序,不认访问顺序。如果你只读不重插,这个 key 在 Map 里的位置就停留在第一次插入的时机,等到淘汰时,一个明明很活跃的 key 也可能因为"位置靠前"被误杀。删掉再插入,等于把它移动到队尾,表示"最近刚被访问过"。

set方法里,对已存在的 key 先删后插,是为了避免直接 set 导致 Map 里出现两个相同 key 的"伪更新"——实际上 Map 里同一个 key 永远只有一个条目,但直接 set 不会改变它在迭代顺序里的位置。先删再插,才能让更新过的 key 也排到队尾。

淘汰逻辑用的是this.map.keys().next().value,这行代码的作用是拿到迭代器的第一个值,也就是整个 Map 里最早插入的 key。这里可以看出 Map 这个数据结构的两大优势:迭代有序、拿到头部 key 是 O(1) 的。

配合一段测试代码验证一下:

const cache = new LruCache(3); cache.set('a', 1); cache.set('b', 2); cache.set('c', 3); cache.get('a'); // 访问 a,a 变成最新 cache.set('d', 4); // 需要淘汰一个 key,最久未使用的是 b console.log(cache.map.has('b')); // false,b 被正确淘汰 console.log(cache.map.has('a')); // true console.log(cache.map.has('c')); // true console.log(cache.map.has('d')); // true

这段我在本地跑过多次,行为完全符合预期。

2.3 手写版本够用吗?先看它缺什么

手写版本适合用来理解原理、应付面试,但直接扔到生产环境,有三个明显短板。

第一,没有 TTL(过期时间)机制。内存 LRU 只管"容量满了踢谁",不管"数据放太久了该失效"。比如缓存一条用户积分数据,如果积分变了而 key 一直不被淘汰,用户就会一直看到旧积分。生产环境必须有"放 N 秒后自动过期"的能力。

第二,没有容量统计和命中统计。我想知道缓存目前装了多少条、命中多少次,得自己额外打点。

第三,没有处理"缓存值特殊含义"的问题。比如某个 key 对应的真实值就是undefined,get返回undefined时,调用方无法区分"缓存里有这个 key 但值是 undefined"和"缓存里没有这个 key"。

这三个短板,成熟库基本都解决了。所以我的建议是:面试题可以自己手写,线上项目还是用生态里经过大量场景验证的实现。下一节就讲怎么用、参数怎么调。

3. 生产环境首选:lru-cache 库的使用与调参

3.1 为什么选 lru-cache

Node.js 社区里 LRU 相关的包不少,但使用最广、维护最稳定、API 设计最符合实际场景的,还是lru-cache。它支持的参数覆盖了容量限制、内存限制、过期时间、惰性刷新、删除回调等生产环境几乎全部刚需,而且这些参数之间相互作用,设计得比较严谨。

安装就一行:

npm install lru-cache

注意不同大版本的 API 有差异。v7 之后构造函数改成接收一个 options 对象,不再支持new LRU(max)这种直接传数字的旧写法。如果你搜到比较老的教程,复制代码前先确认自己装的是哪个版本,不然很容易报类型错误之类的异常。

3.2 核心参数逐个拆解

我把平时用到的关键参数整理成一张表,下面逐个展开:

参数作用典型设置
max最多缓存多少个 key根据内存预算估算
maxSize / sizeCalculation按字节数限制总大小限制超大对象缓存
ttlkey 的存活时间(毫秒)接口数据 5~10 分钟
updateAgeOnGet读取时是否刷新存活时间高频访问场景开 true
allowStale过期后是否还能读旧值允许短暂陈旧时开 true
disposekey 被淘汰时的回调清理连接、释放资源

max是最直观的容量参数,单位是条数。我会先估算单条缓存平均占多少字节,再结合进程可用内存定 max。比如老生代还剩 200MB,单条数据平均 20KB,那 max 定 10000 左右是比较安全的。

maxSize和sizeCalculation配合使用,用来做按字节的内存限制,适合缓存对象大小差异很大的场景。有的响应几十字节、有的几百 KB,如果只按条数限制,可能出现"条数很少但内存快爆"的情况。sizeCalculation 是一个函数,接收 value 和 key,返回这条数据的字节数;库内部会拿所有条目的字节数总和跟 maxSize 比较。

ttl解决了手写版"没有过期时间"的短板。单位是毫秒,比如ttl: 1000 * 60 * 5表示每条缓存最多存活 5 分钟。

提示:lru-cache 的 max 参数是缓存条数上限,不是内存上限。如果你缓存的值体积差异很大,一定要配合 maxSize 和 sizeCalculation 使用,否则可能出现"条数不多但内存快爆"的尴尬情况。

updateAgeOnGet是很多人忽略但很实用的参数。默认情况下,ttl 是从插入那一刻开始算的,不管中间读取多少次,到点就过期。如果把 updateAgeOnGet 设为 true,那么每次 get 都会刷新这条数据的存活时间,相当于给热数据续命。这个参数适合"只要还在被频繁访问就希望它一直存活"的场景。

allowStale设为 true 时,即使 key 已经超过 ttl,在还没有新的写入把它顶掉之前,get 仍然能拿到旧值。这个特性在"用户可以容忍短暂陈旧数据、但绝不能拿到空值"的场景下很实用,代价是数据新鲜度下降。

dispose回调比较冷门但很强大。当一个 key 因为过期、淘汰、或手动 delete 被移除时,dispose 会被调用。比如缓存的是数据库连接句柄、文件流这类需要释放的资源,就可以在 dispose 里做清理,避免资源泄漏。

3.3 两个容易踩的配置细节

第一个坑:设置了ttl不代表库会立刻把过期 key 从内存里删掉。lru-cache 的过期检查是惰性的,只在 get 某个 key、或者迭代缓存时才会发现它过期了。这意味着"已经过期但还没被访问"的 key 仍然占着内存。所以如果业务里 key 的写入量很大、ttl 又短,内存的实际占用条数可能会明显超过 max 的预期。要缓解这个问题,可以定期调用cache.purgeStale()手动扫一遍过期条目,或者把 max 设置得保守一些。

第二个坑:ttl和updateAgeOnGet同时使用时,要明确业务意图。如果你希望数据从写入开始算 5 分钟过期,不管怎么访问都不续期,那 updateAgeOnGet 一定不要开;如果你希望只要还在高频访问就不让它过期,那就可以开。两者代表两种完全不同的缓存语义,开错会导致数据要么太容易过期、要么长期不更新。

此外还要注意max和maxSize不要同时设得太激进。如果 max 条数上限先到,即使 maxSize 还有余量,也不会再写入新 key;反之亦然。容量规划时要选一个作为主要约束,避免两个参数互相打架,导致缓存容量利用率上不去。

4. 实战:给查询接口接入 LRU 缓存

4.1 缓存键的设计决定命中率的天花板

接入缓存前,我习惯先花时间想清楚 key 怎么设计。key 设计直接决定同一个数据能被多少人复用,如果设计得太"个性化",命中率注定上不去。

举例:用户详情接口,很多人会直接拼user:${userId},这是对的。但有些同学为了方便定位问题,把时间戳也拼进去,比如user:${userId}:${Date.now()},这就等于每次请求都是全新 key,缓存永远 miss,跟没加缓存没区别。

再比如带筛选条件的列表查询,比较稳妥的做法是列出所有参与筛选的参数,按固定顺序拼到 key 里,比如orderList:${status}:${page}:${pageSize}。参数的拼接顺序要固定且不产生歧义,否则同一个查询可能因为参数顺序不同生成两个 key,白白浪费内存。

另外,缓存 key 要考虑全局唯一和安全隔离。多个业务共用一个缓存实例时,建议在 key 前面加业务前缀,像user:、order:这样,避免不同业务的 userId 撞在一起。

4.2 完整接入代码与回源逻辑

下面给一个可直接参考的接入示例。场景是用户配置项接口,查询数据库成本较高,但同一用户短时间内反复请求的概率大:

const LRU = require('lru-cache'); const configCache = new LRU({ max: 5000, ttl: 1000 * 60 * 10, updateAgeOnGet: true, }); async function getUserConfig(userId) { const key = `userConfig:${userId}`; const cached = configCache.get(key); if (cached !== undefined) { return cached; } const config = await loadConfigFromDb(userId); if (config) { configCache.set(key, config); } return config; }

有几个细节值得说明。

第一,cached !== undefined的判断方式:因为设置了 ttl,key 过期后 get 会返回 undefined,这个判断天然把"过期"当成"未命中",逻辑是通的。

第二,只对查询成功的结果写缓存。如果数据库查询失败返回 null,我不会把它 set 进缓存,否则一次临时故障会导致这个用户在未来 10 分钟一直拿到 null。

第三,异步逻辑的并发问题。如果这个接口一瞬间有 10 个相同的请求同时进来,而缓存里还没有数据,这 10 个请求会同时打到数据库。解决办法可以在函数内部加一个"单飞"逻辑:用一个 map 保存正在查询中的 Promise,相同 key 的后续请求直接 await 同一个 Promise。这是"缓存击穿"的标准解法,下一节再细说。

还有一个容易被忽视的点:缓存对象不要直接返回给调用方。如果调用方拿到对象后直接改了某个字段,内存缓存里的值也被改了,等于缓存被污染。稳妥的做法是返回前做一次浅拷贝,或者至少约定缓存对象是只读的。这一点在团队协作里尤其重要,我踩过不止一次。

4.3 命中率怎么观测,收益怎么量化

加缓存不能凭感觉,得用数据说话。最简单的做法是在封装层加两个计数器:

let hitCount = 0; let missCount = 0; async function getUserConfig(userId) { const key = `userConfig:${userId}`; const cached = configCache.get(key); if (cached !== undefined) { hitCount++; return cached; } missCount++; // ...回源逻辑 } function getCacheStats() { const hitRate = hitCount / (hitCount + missCount); return { hitCount, missCount, hitRate }; }

建议把这个统计接口暴露到一个内部状态路由上,或者周期性打印日志。我在实际项目中会同时记录两个指标:总体命中率,以及热点 key 的命中率。如果总体命中率在 60% 以下,我首先怀疑的不是容量不够,而是 key 设计出了问题,比如把不该变的参数拼进去了;如果某个热点 key 命中率很高但接口 p95 依然很高,那就要看看是不是单次回源实在太慢,这属于回源优化的范畴,不是缓存能解决的。

另一个实用指标是"缓存节省的数据库查询次数":用 miss 次数近似替换掉原本会打到数据库的请求数,在监控面板上对比接入缓存前后的数据库 QPS,下降幅度就是最直观的收益。

5. 高频问题与排查技巧实录

5.1 缓存穿透、击穿、雪崩:三种典型故障

这三个概念很多人分不清,我按实际遇到的场景说一遍。

缓存穿透,指的是请求查了一个根本不存在的 key。每次查询都 miss,直接打到数据库。有人会问:查不到的数据是不是不该缓存?理论上该缓存,但要缓存的是"空结果",同时给一个较短的 ttl,比如 30 到 60 秒,这样至少能挡住一部分异常请求。更彻底的办法是布隆过滤器,不过多数业务场景用"缓存空值"就够了。

缓存击穿,指的是某个非常热门的 key 恰好过期,一瞬间大量请求同时发现 miss,于是同时回源。我前面提到的"单飞"就是解决这个问题的:同一时间只允许一个请求真正回源,其他请求等它的结果。用一个 promiseMap 就能实现:

const inflight = new Map(); async function getUserConfig(userId) { const key = `userConfig:${userId}`; const cached = configCache.get(key); if (cached !== undefined) return cached; if (inflight.has(key)) { return inflight.get(key); } const promise = loadConfigFromDb(userId) .then((config) => { if (config) configCache.set(key, config); return config; }) .finally(() => { inflight.delete(key); }); inflight.set(key, promise); return promise; }

这段代码的核心是:第一次回源时把 promise 存进 inflight,后续相同 key 的请求直接复用同一个 promise,等数据库查询结束后统一写缓存,再清理 inflight 里的记录。

缓存雪崩,指的是大量 key 在同一时间集体过期,导致数据库瞬时压力飙升。常见的诱因是统一设置相同的 ttl,比如所有缓存都是 5 分钟,某个时刻它们一起失效。解法也很简单:给 ttl 加一个随机抖动,比如1000 * 60 * 5 + Math.random() * 1000 * 30,让过期时间散开,避免整点过期。

5.2 内存占用怎么看,容量怎么规划

LRU 缓存跑一段时间后,最常被问的问题是"缓存到底占了多少内存"。在 Node.js 里,最直接的观测手段是:

const memoryUsage = process.memoryUsage(); console.log({ rss: memoryUsage.rss, heapTotal: memoryUsage.heapTotal, heapUsed: memoryUsage.heapUsed, });

关注 heapUsed 的增量。如果加缓存之后 heapUsed 一路上涨且 GC 后不回落到基准值,基本可以判断缓存占的内存超出了预期。这时候先检查是不是把大字段塞进缓存了,比如某个接口返回的 JSON 里带了一段几百 KB 的日志字段,缓存 1000 条就是几百 MB。解决办法是用 sizeCalculation 按字节限制,或者只缓存裁剪后的字段。

容量规划我一般按这个流程走:先用压测模拟真实流量,记录单条缓存值的平均大小;再结合进程允许的最大内存,留出 30% 到 50% 的余量给业务自身和其他数据,算出 max 的安全值;上线后再盯着命中率和 GC 曲线调整。不要一上来就定一个很大的 max,宁可先小后大,逐步观察。

5.3 踩坑速查表

最后把实战里遇到的高频问题整理成一张速查表,方便对照排查:

现象可能原因解法
命中率长期低于 50%key 设计了个性化参数去掉时间戳、随机数,固定参数顺序
内存持续上涨缓存了超大数据用 maxSize + sizeCalculation 限制
数据一直不更新ttl 没设或 updateAgeOnGet 开了明确缓存语义,设置合理的 ttl
突增流量时数据库被打爆热点 key 过期导致击穿加单飞逻辑,或热点 key 永不过期加主动更新
key 冲突导致数据错乱不同业务共用前缀加业务前缀,比如 user: / order:
拿到的缓存对象被改坏直接返回了内部对象引用返回前浅拷贝,或约定只读

每个问题的背后,其实都对应一个"缓存语义没想清楚"的时刻。缓存不是简单的 get/set,它涉及容量、时间、一致性三个维度,任何一个维度没定清楚,都会以线上故障的形式教做人。

我个人在实际操作中的体会是:给接口加缓存之前,先回答三个问题——这个数据能不能容忍短暂过期、同一个 key 大概多久会被访问一次、单条数据有多大。三个问题都有答案了,LRU 的接入只是几行代码的事;没想清楚就硬上,后面排查的代价远大于省下的那点查询耗时。另一个我养成的习惯是:凡是加了缓存的接口,都会顺手把命中率统计和内存监控一起加上,宁可多写十行日志,也不要等出问题后再去猜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询