缓存雪崩通常不是单个 key 的问题,而是大量 key 在同一时间窗内集中失效,导致原本由缓存扛住的读流量瞬间压向数据库。Node.js 的异步 I/O 可以同时发起大量查询,但数据库连接池一旦被占满,请求就会在事件循环中堆积,造成响应时间飙升,甚至引发级联故障。要解决这类问题,核心思路是分散缓存的过期时间,并在缓存真正失效时控制住回源流量。缓存预热和互斥锁正是围绕这两个目标展开的两层防护。

缓存雪崩的触发链路与Node.js中的表现
缓存雪崩最常见的成因是批量写入缓存时设置了相同的过期时间。比如一个商品列表页涉及一千个商品,如果这些商品的缓存 key 都在同一时刻写入,并且都使用 EX 3600 这样的固定 TTL,那么一小时之后它们也会在同一秒附近集体过期。此时如果有新的读请求进来,缓存层几乎全部失效,这些请求会直接穿透到数据库。
Node.js 的事件循环虽然可以承载大量异步操作,但数据库连接池是有限的资源。假设连接池只有 100 个连接,瞬时并发达到 1000,所有未命中缓存的请求都会尝试执行 SQL 查询。前 100 个请求占据连接后,后续请求只能排队等待,数据库压力陡增,响应时间从几十毫秒变成几秒甚至几十秒。更危险的是,当响应时间变长后,新的请求还在不断进入,事件循环中的回调队列越积越多,最终可能导致 Node.js 进程内存耗尽或假死。
下面这段代码展示了一个没有防护的缓存读取逻辑,它在缓存失效后直接查询数据库,一旦遇到雪崩场景就会放大数据库压力。
const Redis = require("ioredis");
const redis = new Redis();
const db = require("./db");
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const product = await db.query("SELECT * FROM products WHERE id = ?", [productId]);
if (product) {
await redis.set(cacheKey, JSON.stringify(product), "EX", 3600);
}
return product;
}
可以看到,问题不在于单次查询,而在于同一时刻大量请求都会进入 db.query 分支。要降低风险,必须从时间维度和并发维度同时做限制。时间维度上让 key 的过期时间分散开,并发维度上让同一个 key 的回源操作串行化。
缓存预热:主动构建缓存屏障
缓存预热指的是在服务启动、发布新版本或者缓存被清空之后,主动把预计会被高频访问的数据提前加载到 Redis 中。这样做可以避免服务刚上线时缓存命中率极低,导致所有流量直接打到数据库。预热不是一次性动作,它应该成为启动流程的一部分,也可以在低峰期定时执行。
选择哪些数据做预热需要结合业务特征。可以从数据库的慢查询日志中找出高频查询,也可以由运营系统标记热门商品、热门文章或热门用户。预热的数据量要有所克制,不能把整张表都塞进 Redis,否则内存成本和数据一致性问题都会变得不可控。通常只预热活跃度最高的几百到几千条记录即可覆盖大部分读流量。
预热时还要特别注意过期时间的设置。如果所有预热 key 仍然使用同一个 TTL,那么预热本身反而会制造出下一次雪崩。正确做法是给每个 key 的基础 TTL 加上一个随机偏移量,让过期时间自然散开。下面的示例使用 ioredis 的 pipeline 批量写入,并给每个 key 设置了 3600 秒到 4200 秒之间的随机 TTL。
const Redis = require("ioredis");
const redis = new Redis();
const db = require("./db");
async function preheatProducts() {
const products = await db.query("SELECT id, name, price, stock FROM products WHERE hot = 1 LIMIT 1000");
const pipeline = redis.pipeline();
const baseTtl = 3600;
for (const product of products) {
const cacheKey = `product:${product.id}`;
const randomTtl = baseTtl + Math.floor(Math.random() * 600);
pipeline.set(cacheKey, JSON.stringify(product), "EX", randomTtl);
}
await pipeline.exec();
console.log(`preheated ${products.length} products`);
}
预热脚本的执行时机可以放在服务启动时,也可以单独通过部署流程触发。如果预热失败,需要配置重试机制,并保证服务仍然可以正常启动。预热不能完全避免运行期间的缓存失效,但它能显著降低冷启动阶段的回源压力,让服务有时间逐步填充其他缓存。
互斥锁:单点热点key失效时的并发控制
预热解决的是批量 key 集中过期的问题,但在实际运行中,仍然可能出现某个热点 key 突然过期或缓存被手动删除的情况。例如一个爆款商品详情页的缓存过期后,瞬间涌入的数千个请求会全部发现缓存未命中,然后同时去数据库查询同一条记录。互斥锁的目标就是在这种情况下只允许一个请求回源,其余请求等待缓存重建后再读取。
在 Redis 中可以通过 SET key value NX EX seconds 实现一个简单的分布式锁。NX 表示只有 key 不存在时才写入成功,EX 用于设置锁的过期时间,避免某个请求异常退出后锁一直占用。Node.js 的 ioredis 客户端可以直接在 set 方法中传入这些选项。下面是一个带双重检查和锁安全释放的示例。
const crypto = require("crypto");
async function getOrSetWithLock(productId) {
const cacheKey = `product:${productId}`;
const lockKey = `lock:product:${productId}`;
const token = crypto.randomUUID();
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const locked = await redis.set(lockKey, token, "NX", "EX", 30);
if (locked === "OK") {
try {
const product = await db.query("SELECT * FROM products WHERE id = ?", [productId]);
if (product) {
const baseTtl = 3600 + Math.floor(Math.random() * 600);
await redis.set(cacheKey, JSON.stringify(product), "EX", baseTtl);
return product;
}
return null;
} finally {
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
} else {
await new Promise(resolve => setTimeout(resolve, 100));
return getOrSetWithLock(productId);
}
}
这段代码中加了锁的请求负责查询数据库并重建缓存,其他未拿到锁的请求等待 100 毫秒后重新调用自身,此时缓存大概率已经存在,可以直接返回。释放锁时没有直接调用 DEL,而是通过 Lua 脚本判断 token 是否一致,只有持有锁的请求才能删除锁。这个细节非常关键,如果省略 token 校验,就可能出现锁超时后被其他请求重新加锁,而前一个请求又在完成数据库查询后误删新锁的情况。
预热与互斥锁的协同策略
把预热和互斥锁组合起来使用,可以覆盖大多数缓存雪崩和缓存击穿场景。预热负责在系统上线前把主要热点数据提前填充好,并让过期时间随机分布,从源头减少缓存同时失效的概率。互斥锁则作为运行时兜底,处理个别 key 突然失效或缓存被淘汰后的并发回源。两者并不冲突,反而是互补关系。
如果只做预热而不加互斥锁,一旦出现计划外的热点数据或者 Redis 中某个 key 被人工删除,雪崩风险依然存在。如果只做互斥锁而忽略预热,当大量 key 同时过期时,即使每个 key 的互斥锁都能拦住重复查询,仍然会有成百上千个请求并发进入数据库回源,因为锁是分散在不同 key 上的。所以随机过期时间仍然是最基础的一层保护。
在实际工程中,还可以结合本地缓存、限流和降级进一步加固。例如在 Node.js 进程内增加一层短 TTL 的内存缓存,当 Redis 短暂不可用时,本地缓存可以兜底一部分读请求。对数据库查询接口设置并发上限或熔断阈值,一旦数据库响应时间异常升高,就快速返回降级数据,避免雪崩继续扩大。下面是一个集成互斥锁的 Express 路由示例。
const express = require("express");
const app = express();
app.get("/product/:id", async (req, res) => {
try {
const product = await getOrSetWithLock(req.params.id);
if (product) {
res.json(product);
} else {
res.status(404).json({ error: "not found" });
}
} catch (err) {
res.status(500).json({ error: "internal error" });
}
});
上线前建议通过压测工具模拟批量 key 过期和单热点 key 失效两种场景,观察数据库连接数、接口响应时间和缓存命中率的变化。锁的等待时间和锁的过期时间需要根据实际业务调优,等待时间过短会导致大量请求快速重试,过长则会增加接口延迟。锁的 TTL 一般设置成比数据库查询最坏耗时稍长即可,避免锁过早释放造成重复回源。
最后要注意的是,分布式锁并不是解决缓存问题的银弹。如果 Redis 本身发生故障,所有依赖 Redis 的锁和缓存都会失效。因此生产环境通常会部署 Redis 哨兵或集群,并在客户端配置重试和超时策略。在极端情况下,即使缓存层完全不可用,也应该让服务进入有损降级状态,而不是无限制地冲击数据库。