使用 node-redis 实现 Redis 旁路缓存

本指南使用 node-redis,为 Node.js 应用实现 Redis 旁路缓存(cache-aside)。示例还包含一个基于 Node.js 标准 http 模块的小型本地 Web 服务器,用于演示缓存命中、未命中、写入失效和缓存击穿保护。

概述

旁路缓存是读密集型应用最常见的 Redis 用法之一。应用先查询 Redis,只有未命中才访问主数据库,再把结果写回 Redis 并设置 TTL,后续读取即可从内存完成。

这种模式提供:

  • 热点工作集的亚毫秒级读取。
  • 有界的陈旧时间,每个条目都会在已知时间窗口内过期。
  • 随命中率提高而减少主数据库负载。
  • 无需重新序列化整条记录的字段级更新。
  • 热门键在高负载下过期时的缓存击穿保护。

本例将每个商品保存在 cache:product:{id} 形式的 Redis 哈希中,字段为 id、name、price_cents、stock。键设置 TTL,以自动限制陈旧时间。

工作原理

每次读取的流程:

  1. 应用调用 cache.get(productId, loader)。
  2. 辅助类对 cache:product:{id} 执行 HGETALL。
  3. 命中时,直接返回缓存哈希。
  4. 未命中时,获取由 Lua 实现的 single-flight 锁,再调用 loader(productId) 读取主存储。
  5. 通过 HSET 和 EXPIRE 写回缓存,然后释放锁。
  6. 没有取得锁的并发调用者短暂等待缓存填充,随后读取锁持有者写入的值,避免各自请求主存储。

写入时,应用先更新主存储,再删除缓存键,让下次读取从新的源数据重新填充。

旁路缓存辅助类

RedisCache 封装了缓存操作,原文提供了源码链接:

const { createClient } = require("redis");
const { RedisCache } = require("./cache");
const { MockPrimaryStore } = require("./primary");

const client = createClient({ socket: { host: "localhost", port: 6379 } });
await client.connect();

const primary = new MockPrimaryStore({ readLatencyMs: 150 });
const cache = new RedisCache({ redisClient: client, ttl: 30 });

// Read through the cache.
const { record, hit, redisLatencyMs } = await cache.get("p-001", (id) => primary.read(id));

// Update a single field without rewriting the whole record.
await cache.updateField("p-001", "stock", "41");

// Invalidate the cache key on a write to the primary.
primary.updateField("p-001", "price_cents", "699");
await cache.invalidate("p-001");

数据模型

每个缓存商品保存为 Redis 哈希:

cache:product:p-001
  id          = p-001
  name        = Sourdough Loaf
  price_cents = 650
  stock       = 42

实现使用以下命令:

  • HGETALL 读取缓存记录。
  • HSET 与 EXPIRE 在未命中后重新填充。
  • DEL 在写入后使缓存失效。
  • TTL 在演示界面显示剩余有效时间。
  • EVAL 执行防止击穿的 Lua single-flight 锁。
  • WATCH/MULTI/EXEC 实现条件字段更新。

旁路缓存读取

get() 首先调用 HGETALL。命中时返回缓存哈希,并增加进程内的命中计数;未命中则交给 single-flight 加载器:

async get(entityId, loader) {
  const cacheKey = this._cacheKey(entityId);

  const started = process.hrtime.bigint();
  const cached = await this.redis.hGetAll(cacheKey);
  const redisLatencyMs = Number(process.hrtime.bigint() - started) / 1e6;

  if (cached && Object.keys(cached).length > 0) {
    this._stats.hits += 1;
    return { record: cached, hit: true, redisLatencyMs };
  }

  this._stats.misses += 1;
  const record = await this._loadWithSingleFlight(entityId, loader);
  return { record, hit: false, redisLatencyMs };
}

返回对象包含实测 Redis 往返时间,演示界面可据此展示命中与未命中的延迟差异。

用 Lua 锁防止缓存击穿

热门键过期时,所有并发读取可能同时发现未命中。如果没有协调,它们都会查询主存储并重复覆盖缓存,这就是缓存击穿。

辅助类使用一个很小的 Lua 脚本,原子地获取短期锁。只有成功执行 SET NX 的调用者读取主存储,其他调用者短暂轮询缓存,并返回锁持有者写入的结果:

-- Acquire a short-lived lock with SET NX PX. Returns 1 on acquire, 0 otherwise.
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
    return 1
end
return 0

第二个脚本只有在调用者仍持有锁时才释放。这样,原来的锁超时后被别人重新取得时,不会被旧调用者误删:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

Node.js 端在未命中时通过 EVAL 执行这两个脚本:

async _loadWithSingleFlight(entityId, loader) {
  const cacheKey = this._cacheKey(entityId);
  const lockKey = this._lockKey(entityId);
  const token = randomBytes(8).toString("hex");

  const acquired = await this.redis.eval(ACQUIRE_LOCK_SCRIPT, {
    keys: [lockKey],
    arguments: [token, String(this.lockTtlMs)],
  });

  if (acquired === 1) {
    try {
      const record = await loader(entityId);
      if (record == null) return null;
      const multi = this.redis.multi();
      multi.del(cacheKey);
      multi.hSet(cacheKey, record);
      multi.expire(cacheKey, this.ttl);
      await multi.exec();
      return record;
    } finally {
      await this.redis.eval(RELEASE_LOCK_SCRIPT, {
        keys: [lockKey],
        arguments: [token],
      });
    }
  }

  this._stats.stampedesSuppressed += 1;
  const deadline = Date.now() + this.lockTtlMs;
  while (Date.now() < deadline) {
    await new Promise((resolve) => setTimeout(resolve, this.waitPollMs));
    const cached = await this.redis.hGetAll(cacheKey);
    if (cached && Object.keys(cached).length > 0) return cached;
  }
  return loader(entityId);
}

每个调用者的唯一令牌保证了安全释放:只有真正持有当前锁的调用者才能释放它。

写入后使缓存失效

主存储发生写入时,应用删除缓存键。下一次读取从主存储取得新数据:

async invalidate(entityId) {
  const deleted = await this.redis.del(this._cacheKey(entityId));
  return deleted === 1;
}

这是最简单、最稳妥的模式:不尝试直接让缓存与主存储同步,而是删除缓存条目,让下一次读取重新填充。

字段级更新

每条记录是哈希,因此可以直接更新一个字段,无需重新序列化整条记录。只有记录已经缓存时才更新,避免 Redis 中出现不完整记录:

async updateField(entityId, field, value) {
  const cacheKey = this._cacheKey(entityId);
  while (true) {
    await this.redis.watch(cacheKey);
    const exists = await this.redis.exists(cacheKey);
    if (!exists) {
      await this.redis.unwatch();
      return false;
    }
    const result = await this.redis
      .multi()
      .hSet(cacheKey, field, String(value))
      .expire(cacheKey, this.ttl)
      .exec();
    if (result === null) continue;
    return true;
  }
}

这适用于比其他字段变化更频繁的热点字段,例如库存或浏览次数,否则每次都需要完整重新加载。

命中与未命中统计

辅助类在进程内记录命中、未命中,以及被 single-flight 锁抑制的击穿次数。演示界面显示这些指标,便于观察缓存吸收负载的效果:

stats() {
  const total = this._stats.hits + this._stats.misses;
  const hitRate = total > 0 ? Math.round((1000 * this._stats.hits) / total) / 10 : 0;
  return {
    hits: this._stats.hits,
    misses: this._stats.misses,
    stampedes_suppressed: this._stats.stampedesSuppressed,
    hit_rate_pct: hitRate,
  };
}

生产环境应将这些数据作为 Prometheus 计数器输出,或发送到指标系统,而不只是放在进程内存中。

前提条件

运行演示前,确认 Redis 正常运行并可访问,默认连接 localhost:6379;安装 Node.js 18 或更新版本;安装 redis 包:

npm install redis

Redis 位于其他地址时,启动演示时指定 --redis-host 与 --redis-port。

运行演示

示例包含本地演示服务器,原文提供源码:

node demoServer.js

HTTP 与并发处理只使用 Node 内置模块:http 作为 Web 服务器,url 解析查询和表单,crypto 生成每个调用者的锁令牌。

交互页面支持:

  • 通过缓存读取商品,查看命中或未命中。
  • 比较 Redis 往返时间与模拟的主存储读取延迟。
  • 观察请求之间 TTL 的倒计时。
  • 更新主存储字段,查看缓存自动失效。
  • 对刚失效的键发送大量并发读取,演示只有一个请求访问主存储的击穿测试。
  • 随时重置命中和未命中计数。

启动后访问 http://localhost:8080。

模拟主存储

为了让演示自包含,示例提供 MockPrimaryStore,模拟速度较慢的磁盘数据库:

class MockPrimaryStore {
  constructor({ readLatencyMs = 150 } = {}) {
    this.readLatencyMs = readLatencyMs;
    ...
  }

  async read(id) {
    await new Promise((r) => setTimeout(r, this.readLatencyMs));
    ...
  }
}

每次 read() 都等待 readLatencyMs,使界面上的缓存延迟差异明显。它还跟踪主存储读取总数,用于确认 single-flight 生效:N 个并发读取访问冷键时,应只有一次主存储读取。

真实应用可将其替换为 SQL 查询、下游服务 HTTP 调用,或其他速度较慢但具有权威性的数据源。

生产使用

本指南刻意使用小型本地示例,便于专注理解旁路缓存。生产环境通常需要加强以下方面。

选择符合陈旧容忍度的 TTL

TTL 是旧值最长可能被返回的时间。较短 TTL 意味着命中率低、主存储负载高;较长 TTL 意味着命中率高,但写入之间可能读到更多旧值。根据业务容忍度选择,并在不允许陈旧的场景结合显式写入失效。

使缓存失效,不直接保持同步

底层记录变化时,删除缓存键,而不是重写。旁路缓存稳健的原因,就是不假设缓存始终最新:未命中后从主存储重新获取。

显式处理不存在的记录

示例对缺失记录返回 null,不进行缓存。真实系统可用较短 TTL 缓存“未找到”哨兵值,吸收针对不存在 ID 的探测负载。它的有效期应短于正常记录,确保新建数据能尽快可见。

调整 single-flight 锁的 TTL

锁的 TTL 应大于主存储最坏情况下的读取延迟,避免慢加载器中途失去锁。RELEASE_LOCK_SCRIPT 中的唯一令牌可保证锁过期后,原调用者不会删除别人重新取得的锁。

为共享 Redis 的缓存键设置命名空间

多个应用共享 Redis 时,用应用名作为前缀,例如 cache:billing:product:{id},防止不同服务互相覆盖。

直接检查 Redis 中的缓存条目

测试或排查时,可直接查看键,确认字段和 TTL 符合预期:

redis-cli HGETALL cache:product:p-001
redis-cli TTL cache:product:p-001

进一步阅读


原文:Redis cache-aside with node-redis。作者/维护者:Redis 文档团队。本文为原文的中文译文;代码保留原文内容。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容