缓存是 Node.js 高并发服务的核心杠杆:正确使用能省下 90% 的数据库压力,设计不当则带来穿透、击穿、雪崩与数据不一致。本文从缓存分层讲起,覆盖内存缓存、Redis 缓存层、三大缓存事故的防护,以及一致性策略与监控,给出可落地的完整方案。
1. 缓存分层与命中率
1.1 缓存金字塔
CPU L1/L2/L3 —— 硬件层
↓
进程内缓存(Map/LRU)—— 零网络延迟,但只本机
↓
Redis 集群(分布式缓存)—— 共享、持久、可扩展
↓
数据库 / 外部服务 —— 最终数据源
1.2 命中率与收益
命中率 = 命中的请求数 / 总请求数
命中率 ↑ → 数据库压力 ↓ → 尾延迟 ↓ → 成本 ↓
| 场景 | 合适缓存粒度 | 典型 TTL |
|---|---|---|
| 用户资料 | 按 userId 单条 | 5–10 分钟 |
| 商品详情 | 按 sku 单条 | 10–30 分钟 |
| 热门榜单 | 全量短 TTL | 30–60 秒 |
| 配置字典 | 全量长 TTL | 1–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、三防、降级、监控,五件套做齐,缓存才是杠杆而不是事故源。
延伸阅读
- Redis 专题:ioredis 驱动与缓存架构 — Redis 数据结构与 ioredis 实战
- Node.js 数据库集成:ORM 与事务 — 缓存背后的数据层
- Node.js 性能调优指南 — 内存与事件循环深度调优
- Node.js 消息队列集成 — 异步刷新缓存与 MQ 的配合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。