缓存击穿是高并发场景下最常见的稳定性问题之一:某个被大量访问的热点key在Redis中到期的瞬间,成千上万个并发请求发现缓存失效,同时涌入数据库执行同样的查询,轻则拖慢响应,重则直接把数据库打挂。逻辑过期方案是解决这个问题的经典手段之一,它的特点是缓存永不物理过期,把过期判断交给应用层,用旧数据兜底、异步任务重建数据。本文将以Node.js为例,完整实现这套方案。

什么是缓存击穿,为什么逻辑过期有效
先明确三个容易混淆的概念。缓存穿透是指查询一个数据库中根本不存在的数据,请求每次都绕过缓存直达数据库;缓存雪崩是指大量key在同一时刻集中过期,请求洪峰整体压向数据库;而缓存击穿针对的是单个热点key,比如首页商品详情、秒杀商品信息,这类key的访问量极高,一旦失效,瞬间的并发就会形成对数据库的直接冲击。
传统的解决方案是互斥锁:第一个发现缓存失效的请求去抢分布式锁,抢到的去查数据库并回写缓存,抢不到的先休眠重试。这种方式能保证数据库安全,但代价是抢锁失败期间请求会被阻塞,吞吐量下降明显。逻辑过期方案换了一种思路——缓存中的数据永远不设置Redis层面的TTL,而是在value内部保存一个逻辑过期时间。请求读到数据后先判断是否逻辑过期:没过期直接返回;过期了也不阻塞,先返回旧数据,同时触发一个异步任务去重建缓存。
这样做的最大好处是所有请求永远是立即响应的,数据库也不会承受瞬时并发压力,因为同一时刻只会有一个异步重建任务在执行。代价则是牺牲了数据的强一致性,在重建完成之前,客户端拿到的是过期前的旧数据。对于商品详情、热点资讯这类对实时性要求不苛刻的场景,这个取舍通常是值得的。
逻辑过期的数据结构设计
实现逻辑过期,第一步是设计缓存value的结构。通常我们会把真正想缓存的数据和一个逻辑过期时间戳打包成一个对象,再用JSON序列化后存入Redis。逻辑过期时间建议使用绝对时间戳(毫秒),这样即使序列化、反序列化有时钟偏移,判断起来也直观。
需要注意的是,这个key写入Redis时不要调用expire,也不设置set命令的EX参数,让它永久存在。过期与否完全由Node.js代码中的时间比较决定。
Node.js完整实现代码
下面给出基于ioredis的完整实现。核心是一个带缓存的查询函数和一个只允许单实例运行的重建函数。重建的并发控制可以用一个简单的进程内Map来实现,如果服务本身是多实例部署,还需要结合Redis的set nx分布式锁来保证全局只有一个实例在重建。
const Redis = require('ioredis');
const redis = new Redis();
// 封装缓存条目的数据结构
function buildEntry(data, ttlMs) {
return {
data, // 真正的业务数据
expire: Date.now() + ttlMs // 逻辑过期时间戳(毫秒)
};
}
// 尝试获取重建锁,返回释放函数
async function tryLock(key) {
const lockKey = 'lock:rebuild:' + key;
// SET NX EX:只有一个客户端能设置成功,锁本身30秒自动过期兜底
const ok = await redis.set(lockKey, '1', 'EX', 30, 'NX');
return ok === 'OK';
}
async function unlock(key) {
await redis.del('lock:rebuild:' + key);
}
// 带逻辑过期的查询函数
async function queryWithLogicalExpire(key, ttlMs, dbLoader) {
const raw = await redis.get(key);
if (raw === null) {
// 缓存彻底不存在(例如被误删),只能同步回源兜底
const data = await dbLoader();
await redis.set(key, JSON.stringify(buildEntry(data, ttlMs)));
return data;
}
const entry = JSON.parse(raw);
if (Date.now() < entry.expire) {
// 未逻辑过期,直接返回
return entry.data;
}
// 已逻辑过期:尝试触发异步重建,当前请求返回旧数据
rebuildAsync(key, ttlMs, dbLoader);
return entry.data;
}
// 异步重建:多实例场景依赖分布式锁保证只有一个执行者
function rebuildAsync(key, ttlMs, dbLoader) {
// 进程内去重,避免同一实例重复排队
if (rebuildAsync.running.has(key)) return;
rebuildAsync.running.add(key);
(async () => {
try {
if (await tryLock(key)) {
try {
const fresh = await dbLoader();
await redis.set(key, JSON.stringify(buildEntry(fresh, ttlMs)));
} finally {
await unlock(key);
}
}
} catch (e) {
console.error('缓存重建失败', key, e);
} finally {
rebuildAsync.running.delete(key);
}
})();
}
rebuildAsync.running = new Map();
这段代码中有几个关键细节。第一,queryWithLogicalExpire在缓存完全不存在时会同步回源,这是一种兜底策略,正常情况下热点key应该提前预热写入,永远走不到这条路径。第二,rebuildAsync返回的Promise被刻意忽略,不使用await,这正是请求不被阻塞的核心。第三,进程内的runningMap配合Redis分布式锁形成两级去重,既防止单进程重复排队,也防止多实例同时回源。
逻辑过期与互斥锁方案的对比
两种方案没有绝对优劣,取决于业务对一致性和响应延迟的取舍。下表是常见维度的对比:
| 对比维度 | 逻辑过期方案 | 互斥锁方案 |
|---|---|---|
| 请求响应速度 | 始终立即返回,无阻塞 | 抢锁失败的请求需等待重试 |
| 数据一致性 | 短暂返回旧数据,最终一致 | 重建期间可能等待,一致性好 |
| 实现复杂度 | 需改造数据结构,需预热 | 只需加锁逻辑,相对简单 |
| 数据库压力 | 极小,仅单个异步任务回源 | 可控,但重试风暴下仍有压力 |
| 内存占用 | 缓存永不删除,持续占用 | 过期后自动释放 |
从表中可以看出,逻辑过期方案用内存和一致性换性能。如果数据是价格、库存这类绝对不能返回旧值的,就不要用逻辑过期;如果是热点内容展示,几秒到几十秒的延迟完全可接受,逻辑过期是体验最好的选择。
实践中的注意事项与坑点
必须提前预热热点key。逻辑过期方案生效的前提是缓存一直存在,如果热点key从未写入,第一个请求还是会打到数据库。可以在服务启动时、或者通过定时任务,提前把热点数据用buildEntry写入Redis。
注意内存治理。因为key不设置物理TTL,长期运行下Redis内存会持续增长。建议对热点key设置较长的逻辑过期时间,同时配合定期的冷数据清理脚本,或者在逻辑过期字段之外再额外维护一个更长的兜底TTL,作为最后防线。
序列化性能不可忽视。每次读取都要JSON.parse,如果value很大、QPS很高,这个开销会成为瓶颈。可以考虑在应用层加一个短TTL的本地内存缓存(比如5秒),减少Redis和反序列化的压力,但要控制本地缓存的容量避免Node.js进程内存膨胀。
锁的持有时间要留余量。重建锁的30秒过期时间必须大于最坏情况下数据库查询的耗时,否则锁提前释放会导致另一个实例也开始回源,防击穿效果打折扣。同时务必在finally块中释放锁,避免异常导致死锁。
总结一下,逻辑过期方案的精髓在于把过期这件事从Redis搬到了业务代码里,用旧数据换零阻塞,用异步重建换数据库安全。在Node.js的单线程事件循环模型下,这种不阻塞请求的模式尤其契合,配合进程内去重和分布式锁,可以在多实例部署环境下稳定运行。如果你的系统里存在明确的读多写少型热点数据,不妨在下一个迭代中落地这套方案。