人人都会AI编程

多级缓存策略:内存缓存 + Redis 缓存

更新时间:2026-07-10

服务性能优化的大量实践最终都会指向一个朴素的目标:减少重复的昂贵操作。数据库查询就是最典型的昂贵操作。当 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 层做限流或保护。

实际生产中的注意事项

  1. 内存缓存的容量控制:不要将所有数据都塞进内存缓存。使用 maxKeys 限制最大条目数,并设置合理的 TTL,防止 Node.js 进程内存膨胀导致 GC 压力或 OOM。
  2. Javascript 对象深拷贝node-cache 默认会存储对象的引用,如果从缓存取出后直接修改对象属性,会污染缓存。可以在 set 时存储序列化副本,或在 get 时确保返回新对象的副本(JSON.parse(JSON.stringify())lodash.cloneDeep)。但频繁序列化又会引入 CPU 开销,需要权衡。
  3. Redis 故障降级:如果 Redis 不可用,多级缓存仍可通过直接查数据库并只使用内存缓存来保持服务可用,但此时所有请求都会降级到数据库,需做好限流和报警。
  4. key 的设计:统一前缀、命名空间,避免 key 冲突。例如 product:123user:456。在项目规模较大时,可以采用 service 维度划分。
  5. 监控与可观测性:记录每一级缓存的命中率,能帮你调整 TTL 和内存缓存策略。例如,若 L1 命中率很低,说明 TTL 太短或数据不够热,不值得堆在内存中。

总结

多级缓存策略(内存缓存 + Redis)是 Node.js 服务在高并发读场景下的一种有效优化手段。它将最热的数据推到离 CPU 最近的地方,逐级降低网络和磁盘 I/O 开销。合理的分层设计、失效机制和一致性保证,可以让你的服务在承受巨大流量时仍然保持低延迟和高可用。在实际应用中,请务必结合实际业务特性(读写比例、数据一致性要求)来调整每一级的策略,而不是盲目套用。