服务性能优化的大量实践最终都会指向一个朴素的目标:减少重复的昂贵操作。数据库查询就是最典型的昂贵操作。当 QPS 上升到一定量级,将所有请求都打到数据库上不仅会让响应变慢,还会把数据库拖垮。单靠 Redis 一层缓存能解决不少问题,但 Redis 本身也是一次网络 I/O,在高并发场景下,网络往返(RTT)仍然会成为瓶颈。多级缓存正是为了进一步榨取性能而生的架构设计。
为什么需要多级缓存?
一个典型的用户信息查询接口,其数据访问链路可能是:
请求 → Controller → Service → 数据库
加入一层 Redis 缓存后:
请求 → Controller → Service → Redis(有则返回,无则查库)
此时大部分请求的数据库压力已经被卸载,但 Redis 仍是网络 I/O —— 对于单次请求而言,这 0.5~1ms 的延迟完全可以接受;但在需要高频批量查询(如秒杀、热点商品)的场景下,每秒钟几十万次 Redis 查询,累计的网络开销依然可观。
如果再增加一层进程内存缓存,把最热的数据直接缓存在 Node.js 进程的堆内存中:
请求 → Controller → Service → 内存缓存(命中直接返回)
↓ 未命中
Redis(命中则回写内存缓存)
↓ 未命中
数据库(查库后回写 Redis 和内存缓存)
此时绝大多数热点请求在内存中完成,完全避免了网络 I/O,延迟可以降到微秒级。对于冷数据则依次降级查询,保证数据最终一致性。
内存缓存与 Redis 缓存的分工
| 特性 | 内存缓存(如 node-cache) | Redis 缓存 |
|------|---------------------------|-------------|
| 访问速度 | 极快(进程内,纳秒~微秒级) | 快(网络 I/O,毫秒级) |
| 容量 | 受进程内存限制,通常存少量热点数据 | 容量大,可存大量数据 |
| 数据共享 | 单进程独享,多进程间不共享 | 多进程/多服务共享 |
| 数据持久化 | 进程重启即丢失 | 支持持久化,数据可靠 |
| 更新一致性 | 需要额外处理(如广播失效) | 集中管理,易于更新 |
二者天然互补:内存缓存用作 L1 缓存,存放最热、变动不频繁的数据;Redis 作为 L2 缓存,兜底更多业务数据并实现跨进程共享。当有数据更新时,通过某种机制(如 Pub/Sub、消息队列)通知所有进程失效 L1 缓存。
多级缓存的典型实现
我们以一个“根据 ID 查询商品详情”的接口为例,实现一个多级缓存服务。使用 node-cache 作为 L1 内存缓存,ioredis 作为 L2 Redis 缓存。
1. 安装依赖
npm install node-cache ioredis
2. 实现缓存管理器
// cache/cacheManager.js
const NodeCache = require('node-cache');
const Redis = require('ioredis');
// L1 内存缓存,设置默认过期时间为 60 秒,每 120 秒检查过期
const memoryCache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
// L2 Redis 客户端
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
/**
* 多级缓存读取
* @param {string} key 缓存键
* @param {function} fetchFn 数据获取函数(查数据库)
* @param {object} options ttl(秒),forceRefresh 等
*/
async function getFromCache(key, fetchFn, options = {}) {
const { ttl = 600, forceRefresh = false } = options;
// 1. 尝试 L1 内存缓存
if (!forceRefresh) {
const memValue = memoryCache.get(key);
if (memValue !== undefined) {
console.log(`Cache hit: L1 (memory) - ${key}`);
return memValue;
}
}
// 2. 尝试 L2 Redis 缓存
try {
const redisValue = await redis.get(key);
if (redisValue !== null && !forceRefresh) {
console.log(`Cache hit: L2 (redis) - ${key}`);
const parsed = JSON.parse(redisValue);
// 回填 L1 内存缓存
memoryCache.set(key, parsed, ttl);
return parsed;
}
} catch (err) {
console.warn(`Redis 读取失败,降级查库: ${err.message}`);
}
// 3. 缓存未命中,调用数据源
console.log(`Cache miss, fetching from source - ${key}`);
const data = await fetchFn();
// 4. 回写缓存:先写 Redis,再写内存
if (data !== null && data !== undefined) {
try {
// 写入 Redis,使用序列化
await redis.setex(key, ttl, JSON.stringify(data));
} catch (err) {
console.warn(`Redis 写入失败: ${err.message}`);
}
// 写入内存缓存
memoryCache.set(key, data, ttl);
}
return data;
}
/**
* 使缓存失效(L1 + L2)
*/
async function invalidateCache(key) {
memoryCache.del(key);
await redis.del(key).catch(err => console.warn('Redis 删除失败', err));
}
/**
* 使 L1 内存缓存失效(由 Redis Pub/Sub 触发)
*/
function invalidateLocalCache(key) {
memoryCache.del(key);
}
module.exports = {
getFromCache,
invalidateCache,
invalidateLocalCache,
redis,
};
3. 在 Service 中使用多级缓存
// services/productService.js
const db = require('../db'); // 假定的数据库模块
const { getFromCache, invalidateCache } = require('../cache/cacheManager');
// 查询商品详情
async function getProductById(productId) {
const cacheKey = `product:${productId}`;
return getFromCache(cacheKey, async () => {
// 这是实际的数据获取函数,只在缓存未命中时调用
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
);
if (!product) {
return null; // 不缓存空值,避免缓存穿透(可按需调整)
}
return product;
}, { ttl: 300 }); // 缓存 5 分钟
}
// 更新商品信息
async function updateProduct(productId, data) {
// 更新数据库
await db.update('products', data, { where: { id: productId } });
// 使多级缓存失效
await invalidateCache(`product:${productId}`);
// 可选:通过 Redis Pub/Sub 通知其他进程也失效本地内存缓存
// (如果服务是多进程/多实例)
}
module.exports = { getProductById, updateProduct };
4. 多进程共享的内存缓存一致性
多级缓存的一个核心挑战在于:当服务以多进程(如 cluster、PM2、多个容器副本)运行时,每个进程都有自己的 L1 内存缓存。如果某个进程更新了数据,需要通知所有进程失效本地缓存,否则会导致脏读。
最简单的方案是利用 Redis 的 Pub/Sub 广播缓存失效消息:
// cache/pubsub.js
const { redis, invalidateLocalCache } = require('./cacheManager');
// 订阅失效频道
redis.subscribe('cache:invalidate', (err, count) => {
if (err) {
console.error('Failed to subscribe:', err);
}
});
// 收到失效通知时,删除本地内存缓存
redis.on('message', (channel, message) => {
if (channel === 'cache:invalidate') {
console.log(`Received cache invalidation: ${message}`);
invalidateLocalCache(message); // message 就是 key
}
});
// 发布失效消息(在更新数据后调用)
async function publishCacheInvalidation(key) {
await redis.publish('cache:invalidate', key);
}
module.exports = { publishCacheInvalidation };
在 updateProduct 中,除了调用 invalidateCache,还应调用 publishCacheInvalidation('product:123'),让所有进程删除本地内存的对应缓存。
缓存三大经典问题及其应对
多级缓存下仍然需要面对缓存系统中的经典挑战,多级架构并没有消除它们,但可以通过合理设计减轻影响。
1. 缓存穿透
现象:查询一个根本不存在的数据,由于缓存中也没有该数据的记录,每次请求都会穿过 L1、L2 直接打到数据库。
应对:
- 缓存空值:对于查询结果为
null的情况,也缓存一个特殊标记(如NULL),并设置较短的过期时间(如 60 秒)。在上面的getFromCache中,可以调整 fetchFn 逻辑,允许缓存null,但要区分“无数据”和“未命中”。 - 布隆过滤器:在查询前先用布隆过滤器判断 key 是否可能存在,但需注意过滤器的维护成本。
2. 缓存击穿
现象:一个热点 key 在缓存过期的瞬间,大量并发请求同时穿透到数据库,给数据库造成冲击。
应对:
- 互斥锁(Mutex):在 L1 或 L2 缓存未命中时,只允许一个请求去执行 fetchFn 加载数据,其他请求等待该结果。可以使用 Redis 的
SETNX实现分布式锁,或使用 Node.js 内部的async-mutex等。 - “永不过期” + 逻辑过期:缓存不设置 TTL,而是将过期时间存在 value 中,读取时判断是否过期;若过期,异步更新,同时返回旧值。内存缓存特别适合这种策略。
3. 缓存雪崩
现象:大批量缓存在同一时间点过期,导致请求全部涌入数据库。
应对:
- TTL 加随机值:在设置过期时间时,添加一个随机浮动值(如 300 + rand(60)),避免集中过期。
- 多级缓存本身即是缓解:L1 内存缓存即使全部过期,也会被 L2 兜底;只有 L2 也大规模过期时才会压到数据库,此时可以在 L2 层做限流或保护。
实际生产中的注意事项
- 内存缓存的容量控制:不要将所有数据都塞进内存缓存。使用
maxKeys限制最大条目数,并设置合理的 TTL,防止 Node.js 进程内存膨胀导致 GC 压力或 OOM。 - Javascript 对象深拷贝:
node-cache默认会存储对象的引用,如果从缓存取出后直接修改对象属性,会污染缓存。可以在 set 时存储序列化副本,或在 get 时确保返回新对象的副本(JSON.parse(JSON.stringify())或lodash.cloneDeep)。但频繁序列化又会引入 CPU 开销,需要权衡。 - Redis 故障降级:如果 Redis 不可用,多级缓存仍可通过直接查数据库并只使用内存缓存来保持服务可用,但此时所有请求都会降级到数据库,需做好限流和报警。
- key 的设计:统一前缀、命名空间,避免 key 冲突。例如
product:123、user:456。在项目规模较大时,可以采用 service 维度划分。 - 监控与可观测性:记录每一级缓存的命中率,能帮你调整 TTL 和内存缓存策略。例如,若 L1 命中率很低,说明 TTL 太短或数据不够热,不值得堆在内存中。
总结
多级缓存策略(内存缓存 + Redis)是 Node.js 服务在高并发读场景下的一种有效优化手段。它将最热的数据推到离 CPU 最近的地方,逐级降低网络和磁盘 I/O 开销。合理的分层设计、失效机制和一致性保证,可以让你的服务在承受巨大流量时仍然保持低延迟和高可用。在实际应用中,请务必结合实际业务特性(读写比例、数据一致性要求)来调整每一级的策略,而不是盲目套用。