目标
带 TTL 的热键会制造同步惊群:过期那一刻所有并发请求一起未命中、各自重建同一个值。互斥锁模式让恰好一个请求干活、其余短暂等待;stale-while-revalidate 更进一步,重建期间直接服务旧值、连等待都省掉。负缓存则堵上第二个洞——对永远不会存在的键的查询将无限绕过缓存直击数据库。
步骤要点
- 先复现击穿:hey 以 100 并发打同一商品页,键过期窗口内数据库查询数飙到数百。
- 用 SET NX EX 5 抢互斥锁:抢到者查库回填 300 秒 TTL 并删锁;未抢到者 sleep 50ms 后重读缓存。
- 升级为 stale-while-revalidate:值内嵌 expiresAt,软过期时立即返回旧值并由一个后台任务刷新,读者零等待。
- 对查库结果为空的键写入 {empty:true} 的 30 秒负缓存,杜绝不存在 id 的穿透。
- 重跑负载测试:过期窗口内每个重建周期恰好一次数据库查询,其余全部命中缓存。
排错提示
持锁者崩溃没删锁时靠锁自身的 EX 5 秒自愈,务必始终给互斥锁设过期;负 TTL 过长会让新出现的商品短暂“隐身”,控制在几十秒并在创建时主动清除;大批量写入用统一 TTL 会让所有键同步过期引发全键空间惊群,给每个 TTL 加百分之几的随机抖动即可打散。
适用边界
stale-while-revalidate 会短暂服务陈旧数据,只适合容忍秒级陈旧的场景;互斥锁在未命中路径多一次 Redis 往返,要测量其成本确实低于它所防住的惊群。
代码示例
// Single-flight rebuild
if (await redis.set(LOCK, "1", { NX: true, EX: 5 })) {
const data = await db.loadProduct(42);
await redis.set(key, serialize(data), { EX: 300 });
await redis.del(LOCK);
} else {
await sleep(50);
}
// Negative cache for missing keys
if (!product) await redis.set(key, serialize({ empty: true }), { EX: 30 });