Node.js 缓存架构实战:内存、Redis 与一致性策略

系统讲解 Node.js 缓存架构:内存缓存与 LRU、Redis 缓存层设计与序列化、TTL 与过期策略、缓存穿透/击穿/雪崩的成因与防护(布隆过滤器/互斥锁/随机过期)、缓存更新的一致性策略(Cache Aside/Write Through/双写)与监控演进。

缓存是 Node.js 高并发服务的核心杠杆:正确使用能省下 90% 的数据库压力,设计不当则带来穿透、击穿、雪崩与数据不一致。本文从缓存分层讲起,覆盖内存缓存、Redis 缓存层、三大缓存事故的防护,以及一致性策略与监控,给出可落地的完整方案。

1. 缓存分层与命中率

1.1 缓存金字塔

             CPU L1/L2/L3  —— 硬件层
                  ↓
           进程内缓存(Map/LRU)—— 零网络延迟,但只本机
                  ↓
           Redis 集群(分布式缓存)—— 共享、持久、可扩展
                  ↓
            数据库 / 外部服务 —— 最终数据源

1.2 命中率与收益

命中率 = 命中的请求数 / 总请求数
        命中率 ↑ → 数据库压力 ↓ → 尾延迟 ↓ → 成本 ↓
场景合适缓存粒度典型 TTL
用户资料按 userId 单条5–10 分钟
商品详情按 sku 单条10–30 分钟
热门榜单全量短 TTL30–60 秒
配置字典全量长 TTL1–24 小时

一句话:缓存设计的起点是回答"读多写少吗、允许短暂不一致吗"——满足两个条件的才上缓存;先想清楚分层与 TTL,再动手写代码。


2. 进程内缓存:Map、LRU 与失效

2.1 简单 Map 缓存

// 最简单的内存缓存:对象 + TTL
const cache = new Map();
function get(key) {
  const item = cache.get(key);
  if (!item) return undefined;
  if (item.expire < Date.now()) { cache.delete(key); return undefined; }
  return item.value;
}
function set(key, value, ttlMs) {
  cache.set(key, { value, expire: Date.now() + ttlMs });
}

2.2 LRU:容量受限时的正确淘汰

无界 Map 会随请求无限膨胀。**LRU(Least Recently Used)**淘汰最久未使用的条目:

import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
  max: 10_000,              // 最多 1 万个条目
  ttl: 60_000,              // 默认 60s 过期
  updateAgeOnGet: true,     // 命中即刷新"最近使用"
});
cache.set('hot', { data: 1 });
const v = cache.get('hot');
方案适用
无界 Map条目少、有明确清理时机(如内存数据字典)
LRU容量必须受控、热点集中
TTL 过期数据天然随时间失效(榜单、统计)

一句话:进程内缓存必须同时设容量上限与 TTL——LRU 管容量、TTL 管新鲜度;否则缓存对象会演变成隐性的内存泄漏。


3. Redis 缓存层设计

3.1 连接与基础读写

import Redis from 'ioredis';
const redis = new Redis({ host: 'redis', port: 6379 });

// 读写:自动 JSON 序列化,带 TTL
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
const user = JSON.parse(await redis.get(`user:${id}`));

3.2 序列化选择

序列化体积速度适用
JSON中快通用默认
MessagePack小快大对象、流量敏感
gzip+JSON最小慢(压缩 CPU)超大对象、低频

3.3 Key 规范

命名空间:对象:标识符 —— 便于按前缀扫描与清理
  user:10086
  product:sku123
  config:global

绝不使用无前缀 key,避免与其他业务互相污染。

3.4 高可用考量

  • 用连接池与 maxRetriesPerRequest 配置超时,别让缓存故障拖垮主流程;
  • 缓存不可用必须降级:读 Redis 失败 → 直接读 DB,绝不把异常抛给用户;
  • 大数据用 cluster/哨兵,单机 INFO 监控内存与命中率。

一句话:Redis 缓存层 = 带 TTL 的 JSON 读写 + 规范 Key + 严格降级;“缓存挂了不影响主流程"是一票否决的底线,必须用 try/catch 包住所有缓存读取。


4. TTL 与过期策略

4.1 Redis 三种过期策略

策略行为特点
惰性删除访问时检查过期省 CPU,但过期 key 残留占内存
定期删除周期性抽查删除两者折中
内存淘汰超过 maxmemory 触发LRU/LFU/随机 等策略

4.2 TTL 设计

  • 短 TTL 抗波动:热点数据 TTL 缩短到秒级,失效重建成本低;
  • 随机化防雪崩:同一批 key 的 TTL 加随机抖动,避免同时失效;见第 5 节;
  • 逻辑过期:某些场景用"写入时带逻辑过期时间”,配合后台刷新,读永远不 miss。

一句话:TTL 是"新鲜度 vs 命中率"的旋钮——数据越变化频繁 TTL 越短;批量写入必须加随机抖动,这是防雪崩的第一道闸。


5. 三大缓存事故:穿透、击穿、雪崩

5.1 穿透(Cache Penetration)

现象:查询不存在的数据,缓存永远 miss,请求直击 DB。
防护:

// ① 布隆过滤器:拦截必然不存在的 key
import { BloomFilter } from 'bloom-filters';
const bf = new BloomFilter(10_000_000, 0.001); // 千万级,误判率 0.1%
if (!bf.has(id)) return null;  // 一定不存在,直接短路

// ② 缓存空值:对不存在的 key 也缓存(TTL 短)
await redis.set(`user:${id}`, 'NULL', 'EX', 60);

5.2 击穿(Cache Breakdown)

现象:某个热点 key 恰好过期,一瞬间大量请求同时打 DB。
防护——互斥锁重建:

async function get(key, loader) {
  const v = await redis.get(key);
  if (v) return JSON.parse(v);
  // 用 SETNX 抢锁,只有一个进程去重建
  const lock = await redis.setnx(`lock:${key}`, '1');
  if (lock) {
    try {
      await redis.expire(`lock:${key}`, 10);
      const fresh = await loader();
      await redis.set(key, JSON.stringify(fresh), 'EX', 300);
      return fresh;
    } finally {
      await redis.del(`lock:${key}`);
    }
  }
  // 拿不到锁就短暂等待后重试
  await sleep(50);
  return get(key, loader);
}

5.3 雪崩(Cache Avalanche)

现象:大量 key 同时过期,DB 瞬间被击穿。
防护:

// 过期时间加随机抖动,错开失效高峰
const ttl = baseTtl + Math.floor(Math.random() * 300);

一句话:穿透防"不存在"(布隆+空值缓存)、击穿防"热点单键"(互斥锁重建)、雪崩防"批量同刻过期"(TTL 随机化)——三招各有靶点,别混用。


6. 缓存一致性:写策略与双写问题

6.1 四种经典模式

模式写入做法读做法一致性
Cache Aside先写 DB,再删缓存缓存未命中去 DB最终一致,删缓存后短暂 miss
Write Through先写缓存,缓存同步写 DB读缓存强一致,延迟高
Write Behind只写缓存,异步落 DB读缓存最终一致,有丢失窗口
延迟双删先删缓存→写 DB→延迟再删读缓存更好收敛

6.2 生产最常用:Cache Aside

// 写入:先 DB 后删缓存(别先删缓存再写 DB,否则并发读会把旧值写回)
await db.update(user);
await redis.del(`user:${id}`);

// 读:缓存 miss 才回源 DB,并回填
const v = await redis.get(`user:${id}`);
if (!v) {
  const row = await db.find(id);
  await redis.set(`user:${id}`, JSON.stringify(row), 'EX', 300);
  return row;
}

6.3 为什么"先删缓存再写 DB"会出问题

线程A:删除缓存 → 写 DB
线程B:读缓存(miss) → 读DB(旧值) → 回填缓存(旧值)
→ 缓存永远存旧值。先写 DB 再删缓存,配合短 TTL 兜底,窗口最小。

一句话:缓存一致性没有银弹,工程默认是 Cache Aside(先 DB 后删缓存)+ 短 TTL 兜底;追求更强收敛再上延迟双删或 MQ 异步刷新,别在低要求场景过度设计。


7. 缓存监控与演进

7.1 必须监控的指标

指标含义健康标准
命中率hits / (hits+misses)读多写少场景 > 80%
过期淘汰量每秒 evicted抖动异常说明 TTL 设计有问题
内存占用used_memory接近 maxmemory 要扩容
大 key 扫描> 10KB 的 value序列化或拆分设计不当
连接数connected_clients突增说明连接未复用
// 埋点示例:命中率
const hit = await redis.get(`user:${id}`);
if (hit) metrics.cacheHit(); else metrics.cacheMiss();

7.2 演进路径

单体 Map 缓存
  → Redis 单机
  → Redis 主从 + 哨兵
  → Redis Cluster 分片
  → 多级缓存(本地 + Redis)+ 热 key 本地兜底

一句话:缓存系统的健康 = 命中率、内存、大 key 三个指标盯住;架构从单机演进到集群是有节奏的,别一开始就上最重的方案。


8. 踩坑清单

坑现象对策
无界 Map内存持续增长LRU + TTL
缓存未降级Redis 挂 → 服务挂读缓存全程 try/catch 降级 DB
热点 key 过期瞬间打爆 DB互斥锁重建
批量同刻过期DB 被击穿TTL 随机化
先删缓存再写 DB缓存存旧值先 DB 后删缓存 + 短 TTL
缓存空值不处理穿透直击 DB空值缓存 / 布隆过滤器
大 value 未压缩内存爆炸、网络慢压缩序列化
Key 无前缀业务相互污染统一命名空间

9. 总结

环节要点
分层进程内 Map/LRU → Redis → DB,逐层兜底
内存缓存必须 LRU + TTL 双约束
Redis 层规范 Key + JSON + 严格降级
TTL短 TTL 抗波动、随机化防雪崩
三防穿透布隆、击穿加锁、雪崩抖 TTL
一致性Cache Aside(先 DB 后删缓存)+ TTL 兜底
监控命中率 / 内存 / 大 key 三指标

一句话记住:缓存的本质是用"可接受的不一致"换"数量级的性能"——分层、TTL、三防、降级、监控,五件套做齐,缓存才是杠杆而不是事故源。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js CLI 工具开发实战:参数、交互、打包与发布
  2. Node.js 输入校验与数据契约:Zod、类型安全与工程实践
  3. Node.js 错误处理与日志工程:从异常到可观测