Node.js LRU缓存实战:用lru-cache解决内存压力与性能瓶颈
缓存是业务系统的第二层记忆。做后端的人应该都有这种感觉数据库压力一大接口响应时间就往上跳这时候最简单有效的办法就是上缓存。把经常读的数据放到内存里数据库负载降下来接口速度提上去。但缓存天然有个矛盾内存是稀缺资源不可能无限膨胀。很多同学把缓存写进去之后发现内存直线飙升进程直接崩溃——缺的就是一个聪明的“守门员”。LRU缓存Least Recently Used最近最少使用正是目前用得最广泛的淘汰策略而Node.js生态中的LruCache实现是我在多个线上项目里验证过、用得最顺手的一把工具。这篇文章不讲空洞理论直接说清楚它解决了什么问题、怎么选型、怎么用、怎么调优。1. LRU到底是什么为什么Node.js业务越复杂越需要它1.1 内存有限缓存必须有个“守门员”缓存的核心矛盾很简单内存是有限的但数据会不断涌入。如果不加限制地往里塞内存迟早被打爆。所以缓存系统必须有一个淘汰策略也就是“守门员”。常见的淘汰策略有三种FIFO先进先出、LRU最近最少使用、LFU最不经常使用。策略淘汰依据优点缺点FIFO最先放入的优先淘汰实现简单可能把高频访问的热数据误杀LRU最久未被访问的优先淘汰贴合局部性原理实现成本适中需要维护访问顺序LFU访问频率最低的优先淘汰能抗住突发的批量扫描维护成本高新数据容易被误杀用图书馆来打比方FIFO就像“谁先进馆谁先走”完全不管这本是不是热门书LFU像“谁借的次数少谁先下架”但一本新书刚上架还没人借会很容易被清走LRU则像“谁最久没被人翻过谁先收进仓库”它保留了最近频繁访问的书符合人在阅读上的普遍习惯——你昨天刚看的书今天大概率还会看。LRU这个假设在大多数真实业务里是成立的。无论是商品详情、用户信息、还是第三方接口返回值都存在明显的热点效应。一批数据被频繁获取另一批数据可能很久都不会被碰到。用LRU既能控制内存上限又能把最热的那些数据留在内存里。1.2 Node.js单线程模型下缓存失控更致命Node.js是单线程事件循环模型所有JavaScript代码都在同一个主线程上跑。V8给单个进程的内存堆是有限制的老版本默认在2GB左右新版本上限大概在4GB。容器部署时为了稳定性还会通过--max-old-space-size再往下限制。这意味着内存一旦失控后果比Java等有独立GC策略的场景更直接。如果缓存无限增长GC扫描时间变长主线程被垃圾回收打断接口延迟飙升甚至出现OOM导致进程退出。我在实际业务中经常看到的情况是开发为了方便直接用全局Map存数据又不做任何清理。业务初期数据少没什么感觉等用户量上来Map里堆了几十万甚至上百万个key把内存顶到瓶颈进程频繁重启。这时候LRU就成了保命手段而不只是锦上添花的优化。举一个典型场景商品详情查询走数据库要50ms命中缓存只要1ms。只要把最高频的几百个商品缓存住就能覆盖大量读请求。LRU就是要解决“哪些请求值得被留下”的问题。2. 方案选型直接用lru-cache还是自己手写一个LRU2.1 npm生态里的几个主要选项怎么选Node.js生态里做内存缓存的库大概分成几类lru-cache功能最完整、维护最活跃的LRU实现严格按最近最少使用淘汰同时支持TTL过期、异步fetch、缓存淘汰回调、大小统计等。绝大多数场景直接选它。node-cache轻量但核心机制更偏向TTL定时清理和严格LRU淘汰还不是一回事如果你看重的是“内存满了优先淘汰冷数据”它不是首选。cache-manager这是一种多级缓存抽象可以在内存层接一个LRU再往下接Redis。适合需要跨缓存层切换的场景但配置复杂度高业务简单时反而显得笨重。自研Map实现只有十几行代码极简场景下能跑但缺少完善的TTL和过期清理机制生产环境不建议。我的选型经验是单进程内存LRU直接用lru-cache。如果未来要接Redis做分布式缓存可以在上层加一层抽象但内存层仍然用lru-cache。没必要为了“统一缓存接口”一开始就引入重框架先满足真实需求再谈扩展性。2.2 lru-cache核心参数逐一拆解lru-cache最新版本的API比较简洁。平时使用最多的参数是这几个import { LRUCache } from lru-cache; const cache new LRUCache({ max: 1000, // 最多保存1000个条目 ttl: 60 * 1000, // 每条缓存60秒后过期 updateAgeOnGet: true, // 读取时刷新存活时间 allowStale: false, // 过期后立即淘汰不允许读旧值 });max的单位是“条数”不是“字节数”。它控制的是缓存的key数量上限。如果value是一些大对象几条数据就能占满几百MB内存这时候只看max就不够用了。如果确实需要按内存大小控制可以用maxSize配合sizeCalculationconst cache new LRUCache({ maxSize: 100 * 1024 * 1024, // 最大100MB sizeCalculation: (value) { return JSON.stringify(value).length; }, });ttl是每条数据从写入开始计算的有效期。过期后数据不一定会立即被删除lru-cache是在下一次访问时惰性检查并清理。allowStale如果设为true过期后还能get到旧值这种模式适合做“stale-while-revalidate”降级方案但大多数业务不需要读旧值建议保持false。updateAgeOnGet值得多说一句。默认情况下如果一条数据已经写入60秒中间没有任何访问到第61秒就会过期如果你习惯“用户只要还在活跃地访问我就希望这个缓存自动续期”就把这个参数打开这样每次读取都会把过期时间往后顺延。另外提醒一下老版本里有maxAge和stale这两个参数名新版本统一改成了ttl和allowStale。看到网上旧教程时不要对不上号。2.3 自研Map实现LRU原理很简单坑也不少很多人会想LRU听起来不复杂我用JavaScript的Map自己实现一个行不行完全行而且原理就只有那么几行。function createLRU(max) { const map new Map(); return { get(key) { if (!map.has(key)) return undefined; const value map.get(key); map.delete(key); map.set(key, value); // 放到末尾表示刚刚被访问 return value; }, set(key, value) { if (map.has(key)) map.delete(key); map.set(key, value); if (map.size max) { const oldestKey map.keys().next().value; map.delete(oldestKey); } }, }; }原理就是利用Map会保持插入顺序的特性。“删了再塞回去”等于把当前key挪到最新位置。每次插入新key时检查数量超过max就删掉第一个key也就是最久没被碰过的key。这个实现跑通LRU没问题但离生产可用还差得远没有TTL过期机制缓存不会自动清理过期数据只能靠人工删除。没有访问统计你无法知道命中率、淘汰了多少数据。没有淘汰回调被淘汰的数据没法做日志或联动清理。没有并发控制高并发下热点key同时回源时容易把数据库打穿。所以在真实项目里我不建议自己维护这种实现。用现成的lru-cache库省心得多它把这些边界情况都处理好了。自研方案适合用来理解原理或者在面试中快速展示思路。3. 实操落地把LRU缓存塞进你的业务代码3.1 安装与最小可用示例安装很简单npm install lru-cache然后写一个最小示例跑通逻辑import { LRUCache } from lru-cache; const cache new LRUCache({ max: 500, ttl: 5 * 60 * 1000, }); cache.set(user:1, { name: Alice }); // 塞满缓存验证淘汰逻辑 for (let i 0; i 600; i) { cache.set(key-${i}, i); } console.log(cache.has(user:1)); // false最早写入的key被淘汰 console.log(cache.has(key-599)); // true最新的key还在这个示例演示了最核心的“容量限制 自动淘汰”行为。max是500插入601个key后最早写进去的user:1就被挤出去了。注意如果你在插入循环之前先读取了一次user:1它的顺序会被刷新到最新那么淘汰的就会变成其他更老的key。3.2 典型场景一数据库查询结果缓存最朴素的用法是给数据库查询加缓存。以商品详情为例const productCache new LRUCache({ max: 2000, ttl: 30 * 1000, }); async function getProduct(id) { const cacheKey product:${id}; const cached productCache.get(cacheKey); if (cached ! undefined) return cached; const product await db.query(SELECT * FROM products WHERE id ?, [id]); productCache.set(cacheKey, product); return product; }这里有一个细节查询结果本身可能是null而lru-cache的get方法在key不存在时返回undefined。如果你把null当作缓存值存进去get之后拿到的也是null判断逻辑不会出错但不建议依赖这个特性。我在实际项目里的习惯是约定“缓存值不能是undefined”一旦出现undefined就当未命中处理。另外一个坑是数据更新后的缓存一致性问题。商品信息被修改了缓存里的旧数据在30秒内还会被读到。常见做法是在写库成功后主动删掉对应keyasync function updateProduct(id, data) { await db.query(UPDATE products SET ? WHERE id ?, [data, id]); productCache.delete(product:${id}); }关于“先删缓存再写库”还是“先写库再删缓存”我的经验是写库成功后再删缓存。这样如果写库失败缓存不会被误删也不会导致旧值被重新填回。虽然存在一个短暂的不一致窗口但比反向操作的风险小得多。3.3 典型场景二Token与用户信息缓存另一种常见场景是缓存用户的基础信息和权限数据。这类数据的特点是读频率远高于写频率但一旦发生权限变更需要尽量快速生效。const permCache new LRUCache({ max: 20000, ttl: 5 * 60 * 1000, updateAgeOnGet: true, }); async function getUserPermission(userId) { const key perm:${userId}; let perm permCache.get(key); if (perm undefined) { perm await loadPermissionFromDB(userId); permCache.set(key, perm); } return perm; }权限缓存最怕的是“用户权限已经改了系统还在用旧缓存”。所以TTL不宜设太长5分钟是一个比较保守的值。如果权限在登录过程中就被固定住也可以考虑把用户会话ID一起拼进key这样每次重新登录都会自然生成新缓存。打开updateAgeOnGet之后活跃用户只要一直有请求他的权限缓存就会不断续期很少触发回源。只有超过5分钟没访问的用户第一次回来时会重新加载一次权限代价也不大。3.4 典型场景三第三方API响应缓存对接外部接口时缓存的意义不只是提速更重要的是保护自己不被限流。很多开放接口有每分钟调用次数限制一旦超限就拒绝服务。const apiCache new LRUCache({ max: 500, ttl: 10 * 1000, }); async function fetchExchangeRate(from, to) { const key rate:${from}:${to}; const cached apiCache.get(key); if (cached ! undefined) return cached; const data await httpGet(https://api.example.com/rate?from from to to); apiCache.set(key, data); return data; }外部接口的响应如果失败不要把它也缓存起来。一旦把错误结果缓存了后面所有请求都会读到错误直到过期。比较进阶的用法是利用lru-cache自带的fetchMethodconst apiCache new LRUCache({ max: 1000, ttl: 10 * 1000, fetchMethod: async (key) { const [from, to] key.split(:); return httpGet(https://api.example.com/rate?from from to to); }, }); const rate await apiCache.fetch(USD:CNY);fetch方法有一个非常大的优势同一个key同时有一百个请求进来它只会触发一次fetchMethod其他请求会复用同一个Promise。这个特性直接解决了“热点key同时过期导致同时打爆下游”的问题后面会再展开讲。3.5 缓存Key设计必修课很多缓存的命中率问题根源不是LRU本身而是key设计得不好。一定要带业务前缀。user:1和product:1是两类完全不同的数据挂在一个缓存实例里时前缀能避免意外覆盖。多个参数拼key时参数顺序要稳定。比如getRate(USD, CNY)和getRate(CNY, USD)是两回事不能只简单拼接。参数值是对象时不要直接JSON.stringify整个对象因为对象键的顺序不同会产生不同的key造成缓存重复。更稳妥的做法是给参数先做排序再用分隔符拼接function buildCacheKey(prefix, params) { const sorted Object.keys(params) .sort() .map((k) ${k}${params[k]}) .join(); return ${prefix}:${sorted}; } // 使用示例 const key buildCacheKey(rate, { from: USD, to: CNY, source: bank }); // rate:fromUSDsourcebanktoCNYkey尽量短占用的内存就小。不要无脑把整个请求体拼进去提取关键参数就够了。4. 调优与踩坑命中率、穿透、击穿、雪崩4.1 缓存命中率怎么统计和评估LRU缓存上线后怎么知道它有没有真正起作用指标只有一个命中率。命中率就是“直接从缓存拿到结果的次数”除以“所有读取缓存的次数”。长期命中率低于60%说明缓存设计有问题需要回头审视。我习惯写一个简单的包装类把命中率统计放进业务代码里class CacheWithStats { constructor(name, options) { this.name name; this.hits 0; this.misses 0; this.cache new LRUCache(options); } get(key) { const val this.cache.get(key); if (val ! undefined) { this.hits 1; } else { this.misses 1; } return val; } set(key, val) { this.cache.set(key, val); } delete(key) { this.cache.delete(key); } hitRate() { const total this.hits this.misses; if (total 0) return 1; return this.hits / total; } }注意这个包装类成立的前提是“缓存值不能是undefined”。所以我通常要求业务层不要把undefined写入缓存。如果确实要缓存空结果可以约定用一个特殊的空对象或空字符串代替。命中率不是越高越好。如果同学为了追求命中率把TTL设成一小时导致更新后的数据一小时都刷不下去那叫过缓存化。一般读多写少并且数据变化不频繁的接口命中率达到80%以上才是合理的。4.2 穿透、击穿、雪崩的应对办法加了LRU之后缓存边界上的三个经典问题并不会自动消失穿透、击穿、雪崩。缓存穿透是指查询根本不存在的数据。每次请求都查不到缓存直接打到数据库。解决办法是给空结果也缓存一份但TTL设短一点比如30秒const result db.query(SELECT * FROM products WHERE id ?, [id]); if (result null) { cache.set(key, , 30 * 1000); // 缓存空值防止穿透 } else { cache.set(key, result); }缓存击穿是指某个热点key刚好过期一瞬间大量并发请求同时回源。之前提到的fetchMethod就是最直接的解法。如果不用fetchMethod可以自己维护一个“单飞锁”const inflight new Map(); async function getProductWithLock(id) { const key product:${id}; const cached productCache.get(key); if (cached ! undefined) return cached; if (inflight.has(key)) { return inflight.get(key); } const promise dbQueryProduct(id) .then((data) { productCache.set(key, data); return data; }) .finally(() { inflight.delete(key); }); inflight.set(key, promise); return promise; }第一次请求进来没有缓存也没有在途的Promise于是发起到数据库的查询。第二个请求进来时发现同一个key已经有一个Promise在途直接复用不再向数据库发请求。这样即使热点key失效数据库也只会收到一次查询。缓存雪崩是指大量key在同一时间段过期导致流量集体回源。单个进程内雪崩影响有限但多实例部署时如果所有实例都在同一秒过期数据库压力会被放大。解决办法是给TTL增加随机抖动const baseTtl 60 * 1000; const ttl baseTtl Math.floor(Math.random() * 30 * 1000);这样每个key的过期时间不均匀分布大面积同时失效的概率大幅降低。4.3 常见问题速查表现象可能原因解决办法内存还是很高max只限制条数不限制value大小用maxSize和sizeCalculation按字节控制或拆分大对象某个key总是没缓存住max设太小数据访问又很分散按业务拆独立缓存实例或调大max用户在持续访问却还是过期了没有开updateAgeOnGetTTL到达后准时过期设置updateAgeOnGet: true实现滑动过期命中率长期在50%上下key粒度过大或过小、TTL太短、数据本身频繁变化调整key设计按需延长TTL只缓存读多写少的稳定数据热点key失效瞬间数据库被打爆没有做并发合并用fetchMethod或单飞锁控制回源并发补充一个分布式环境下的细节每个Node.js实例的内存LRU是各自独立的实例A缓存了数据实例B没有这是正常的。如果业务要求所有实例同时失效只能在写操作时额外发一个内部通知让各实例删除对应key或者直接引入Redis做共享缓存。这里需要接受短暂的不一致没有绝对的完美方案。4.4 我在长期维护中的几个习惯用lru-cache时间长了之后我慢慢总结出一套固定的组织方式。第一按业务拆分多个独立的LRU实例而不是全局共用一个。商品缓存的TTL要30秒权限缓存的TTL要5分钟外部接口缓存的TTL可能只要10秒。如果全都塞进同一个实例max很快就被大容量缓存的业务占满冷数据不断被淘汰热数据反而被挤掉。分开之后每个缓存实例的容量和过期策略都互不干扰。第二把实例管理收拢到一个统一入口不让业务代码直接new LRUCache。这样以后想加监控、换参数、统计命中率都方便。简单做法是用一个Map保存实例const providers new Map(); function getCache(name, options) { if (!providers.has(name)) { providers.set(name, new LRUCache(options)); } return providers.get(name); } const productCache getCache(product, { max: 1000, ttl: 30 * 1000, });第三定期激活动态监控。给每个缓存实例加上命中率统计和内存占用日志观察久了就能发现哪些业务适合缓存、哪些业务不适合。命中率低不是bug它只是告诉你当前的设计没有贴合访问模式。最后建议所有想用LRU的同学都建立一个基本认知缓存不是数据正确性的依赖它只是加速器。所有缓存缺失的路径都必须能从原始数据源回源并且回源过程要可控、并发要有限制。做好这一点再用LRU去压内存和争抢热数据项目会稳得多。