缓存雪崩是分布式系统中最经典的故障场景之一:当大量缓存Key设置了相同的过期时间,在同一秒内集体失效,所有请求就会像开闸的洪水一样直接砸向数据库。对于使用Node.js和Redis构建的应用来说,如果没有做任何防护,一次流量高峰就足以让数据库连接池耗尽、服务超时、上游请求堆积,最终引发整个链路的连锁崩溃。本文将从原理出发,讲解如何通过随机过期时间这一简单有效的手段化解缓存雪崩,并结合Node.js给出完整的落地方案。

一、缓存雪崩到底是怎么发生的
要理解缓存雪崩,先要理解缓存的读写路径。正常情况下,请求先查缓存,命中则直接返回,未命中才去查数据库并回写缓存。这个模式在缓存命中率高的阶段运行良好,问题出在缓存失效的瞬间。假设系统里有十万条商品数据在凌晨三点做了一次批量预热,全部设置了24小时的TTL,那么第二天凌晨三点,这十万条缓存会在同一秒内集体过期。
此时如果恰逢早高峰流量进来,所有请求都无法命中缓存,全部穿透到数据库。数据库的连接数是有上限的,假设MySQL最大连接数是500,而Node.js服务的并发请求量达到每秒数千,大量请求就会排队等待连接,响应时间从几十毫秒恶化到数秒,上游调用方因为超时开始重试,进一步放大流量,形成雪崩效应。雪崩的可怕之处在于它的正反馈机制:越慢越重试,越重试越慢。
除了集中过期,Redis实例本身宕机也会导致雪崩,这种情况的防护需要依赖集群高可用和熔断降级,本文重点讨论的是集中过期这一类,因为它完全可以通过代码层面的设计来避免。
二、随机过期时间的实现原理
随机过期时间的思路非常直白:既然雪崩的根源是过期时间点过于集中,那就主动把这个时间点打散。比如基础过期时间设置为1小时,再叠加一个0到10分钟之间的随机偏移量,这样十万条缓存会在1小时到1小时10分之间陆续过期,数据库的回源压力被均匀摊开,不会再出现瞬时尖峰。
在Node.js中实现非常简单,核心就是计算TTL时引入随机数。下面是一个基于ioredis的完整示例:
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
maxRetriesPerRequest: 3
});
/**
* 生成随机化的TTL(单位:秒)
* @param {number} baseTtl 基础过期时间
* @param {number} jitter 随机抖动范围
* @returns {number} 最终的过期时间
*/
function randomTtl(baseTtl, jitter) {
return baseTtl + Math.floor(Math.random() * jitter);
}
/**
* 带随机过期时间的缓存写入
*/
async function setCache(key, value, baseTtl = 3600, jitter = 600) {
const ttl = randomTtl(baseTtl, jitter);
await redis.set(key, JSON.stringify(value), 'EX', ttl);
return ttl;
}
/**
* 缓存读取,未命中时回源并写入缓存
*/
async function getWithCache(key, loadFromDb) {
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
const data = await loadFromDb();
if (data !== null) {
await setCache(key, data);
}
return data;
}这段代码的关键在于randomTtl函数:每次写入缓存时,TTL都会在baseTtl基础上叠加一个随机值。假设基础TTL为3600秒,抖动范围为600秒,那么实际的过期时间分布在3600到4200秒之间。对于十万条数据来说,平均每秒只会有一百多条缓存过期,回源压力完全可以承受。
抖动范围的选择有一定讲究。抖动太小,打散效果不明显;抖动太大,缓存整体平均寿命变短,命中率会轻微下降。工程实践中通常取基础TTL的10%到20%作为抖动范围,这是一个经过验证的平衡点。另外要注意Math.random()在单次请求内多次调用结果是不同的,不需要额外做随机种子处理。
三、批量预热场景的正确写法
雪崩最常见的发生场景是批量预热。比如运营做了一次活动,需要提前把所有商品数据灌入缓存。如果预热脚本简单地写成统一TTL,就是在给自己埋雷。正确的做法是给每一批数据都加上随机偏移:
const products = await db.query('SELECT id, name, price FROM products');
// 批量预热管道,避免循环中多次网络往返
const pipeline = redis.pipeline();
for (const item of products) {
const ttl = 3600 + Math.floor(Math.random() * 900); // 1小时到1小时15分
pipeline.set(`product:${item.id}`, JSON.stringify(item), 'EX', ttl);
}
await pipeline.exec();这里使用了ioredis的pipeline来批量提交命令,十万条数据也只需要几十次网络往返,预热效率远高于逐条写入。注意TTL的随机数是在循环内逐条生成的,每条数据的过期时间都不同,从根本上消除了集中过期的可能。
除了随机TTL,预热还有一个更彻底的方案叫逻辑过期:写入缓存时不设置物理TTL,而是在value中保存一个逻辑过期时间字段。读取时发现逻辑上已过期,就由当前请求负责异步刷新缓存并返回旧数据,其他请求继续使用旧值。这样缓存永远不会真正失效,数据库不会受到直接冲击。两种方案可以结合使用,随机TTL应对普通场景,逻辑过期应对热点Key。
四、随机过期时间之外的组合防御
随机过期时间只能解决集中过期这一类雪崩,一个健壮的缓存体系还需要其他手段配合。第一个是互斥锁:当缓存失效时,只允许一个请求去数据库加载数据并回写缓存,其他请求短暂等待或直接返回旧数据,避免同一个Key被并发回源多次。在Node.js中可以用Redis的SET key value NX PX timeout命令实现简单的分布式锁。
第二个是熔断限流。当数据库已经出现压力时,应用层必须具备自我保护能力。可以借助await-cacheman这类库或自己封装计数器,对回源数据库的操作做并发数限制,超出阈值的请求快速失败或降级返回默认值。宁可让部分用户看到降级内容,也不能让数据库彻底倒下,这是分布式系统设计的基本取舍。
第三个是缓存永不过期加异步刷新,也就是前面提到的逻辑过期的进阶版。适合商品详情、配置信息这类对实时性要求不高的数据。配合定期巡检任务主动刷新缓存,用户请求永远只读缓存,数据库的访问完全可控。
最后给出一个综合了随机TTL和互斥锁的完整示例,这是生产环境中比较推荐的回源模式:
async function safeGet(key, loadFromDb) {
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockId = `${Date.now()}-${Math.random()}`;
// 尝试获取锁,持有时间为5秒
const locked = await redis.set(lockKey, lockId, 'NX', 'PX', 5000);
if (locked) {
try {
const data = await loadFromDb();
await setCache(key, data); // 内部使用随机TTL
return data;
} finally {
// 只释放自己持有的锁,用Lua保证原子性
const script = `if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1]) else return 0 end`;
await redis.eval(script, 1, lockKey, lockId);
}
} else {
// 未抢到锁,短暂等待后重试读缓存
await new Promise(r => setTimeout(r, 50));
const retry = await redis.get(key);
return retry !== null ? JSON.parse(retry) : null;
}
}这套方案中,随机TTL负责把过期时间打散,互斥锁保证同一个Key失效后只有一个请求回源,两者结合基本可以杜绝绝大多数雪崩场景。需要注意的是锁的等待时间不宜过长,重试次数也要有限,否则在极端情况下依然会形成请求堆积。锁释放使用Lua脚本是经典做法,避免了误删他人锁的风险。
总结一下,缓存雪崩的防护是一个组合工程:随机过期时间解决集中失效,互斥锁防止并发回源击穿,熔断限流保障数据库安全,逻辑过期守护热点数据。在Node.js中落地这些方案的成本都不高,关键是养成在写缓存的第一行代码时就考虑TTL分布的习惯,防患于未然远比事后救火划算。