本指南使用 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,以自动限制陈旧时间。
工作原理
每次读取的流程:
- 应用调用
cache.get(productId, loader)。 - 辅助类对
cache:product:{id}执行 HGETALL。 - 命中时,直接返回缓存哈希。
- 未命中时,获取由 Lua 实现的 single-flight 锁,再调用 loader(productId) 读取主存储。
- 通过 HSET 和 EXPIRE 写回缓存,然后释放锁。
- 没有取得锁的并发调用者短暂等待缓存填充,随后读取锁持有者写入的值,避免各自请求主存储。
写入时,应用先更新主存储,再删除缓存键,让下次读取从新的源数据重新填充。
旁路缓存辅助类
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
进一步阅读
- node-redis 指南:安装和使用 Node.js Redis 客户端。
- SET 命令:设置字符串,并使用 EX、PX、NX 等选项。
- HSET 命令:写入哈希字段。
- HGETALL 命令:读取哈希全部字段。
- EXPIRE 命令:按秒设置过期时间。
- DEL 命令:失效时删除键。
- Lua 脚本:原子 single-flight 锁与击穿缓解。
原文:Redis cache-aside with node-redis。作者/维护者:Redis 文档团队。本文为原文的中文译文;代码保留原文内容。











暂无评论内容