在电商秒杀、热门文章详情这类高并发场景中,某个热点Key在缓存过期的一瞬间,可能同时有成千上万个请求涌入数据库执行同样的查询,轻则数据库响应变慢,重则直接被打挂,这就是经典的缓存击穿问题。要解决这个问题,核心思路是在缓存失效时加一把互斥锁,让第一个拿到锁的请求去重建缓存,其余请求要么等待重建完成,要么先返回旧数据。本文将围绕Node.js环境,详细讲解如何用Redis实现一把可靠的分布式互斥锁。

一、缓存击穿的成因与互斥锁的基本思路
先来分析问题的根源。假设某个热点Key设置了60秒过期时间,当过期时刻到来,恰好有5000个并发请求同时查询这个Key。所有请求都发现缓存为空,于是都去数据库执行同一条SQL。数据库本身可能只需要承担一次查询压力,现在却要承担5000次,而且这5000次查询的结果完全相同,属于典型的重复劳动。
互斥锁的解决思路很直观:第一个发现缓存失效的请求先去抢锁,抢到锁的请求负责查库并回填缓存;没抢到锁的请求不要去查库,而是短暂休眠后重新读缓存,一旦缓存重建完成就能直接命中。这样无论并发量多大,同一时刻只有一个请求打到数据库。
除了互斥锁方案,常见的替代方案还有逻辑过期:不给Key设置物理过期时间,而是在Value中冗余一个过期字段,读到过期数据后异步触发重建并先返回旧值。逻辑过期的优点是请求永远不会阻塞,但实现复杂度更高,且会牺牲短暂的数据一致性。本文重点讲解实现更直接、应用更广泛的互斥锁方案。
二、基于Redis SETNX实现互斥锁的核心原理
分布式环境下,多个Node.js进程甚至多台服务器都可能同时抢锁,进程内的锁对象显然不够用,必须借助外部存储。Redis的SET key value NX EX seconds命令是业界标准做法:NX保证只有Key不存在时才能设置成功,EX设置过期时间防止死锁,value通常存储一个唯一标识(比如随机UUID加进程ID),用于标识锁的持有者。
const redis = require('redis');
const { randomUUID } = require('crypto');
const client = redis.createClient({ url: 'redis://127.0.0.1:6379' });
client.connect();
// 尝试获取锁,lockKey为锁名,ttl为锁过期时间(秒)
async function tryLock(lockKey, ttl = 10) {
const token = randomUUID(); // 唯一标识,防止误删他人的锁
const result = await client.set(lockKey, token, {
NX: true,
EX: ttl
});
return result === 'OK' ? token : null;
}
// 释放锁:先校验token再删除,必须用Lua脚本保证原子性
const UNLOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
async function unlock(lockKey, token) {
return client.eval(UNLOCK_SCRIPT, {
keys: [lockKey],
arguments: [token]
});
}这里有两个关键细节需要注意。第一,锁必须设置过期时间,否则持有锁的进程一旦崩溃,锁会永远残留,后续所有请求都无法重建缓存。第二,释放锁必须采用「先校验再删除」的原子操作,也就是借助Lua脚本。如果先GET再DEL分两步执行,可能在GET之后锁恰好过期被别的请求抢到,此时DEL删掉的就是别人的锁,导致互斥性被破坏。
三、完整的缓存重建流程与代码实现
有了锁的原语,接下来把它嵌入缓存查询的完整链路。流程设计为:先查缓存,命中直接返回;未命中则尝试抢锁,抢到锁的请求查数据库并回填缓存;没抢到锁的请求休眠几十毫秒后重试读缓存,设置最大重试次数防止无限等待。
async function getDataWithMutexLock(businessKey) {
const cacheKey = 'cache:' + businessKey;
const lockKey = 'lock:' + businessKey;
// 1. 先查缓存
let data = await client.get(cacheKey);
if (data) return JSON.parse(data);
// 2. 缓存未命中,尝试抢锁
const token = await tryLock(lockKey, 10);
if (!token) {
// 没抢到锁,说明别的请求正在重建,短暂等待后重读缓存
for (let i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 50));
data = await client.get(cacheKey);
if (data) return JSON.parse(data);
}
// 等待超时,可以返回降级数据或抛出异常
throw new Error('缓存重建超时,请稍后重试');
}
try {
// 双重检查:拿到锁后再查一次缓存,防止重复重建
data = await client.get(cacheKey);
if (data) return JSON.parse(data);
// 真正去数据库查询
const dbResult = await queryFromDatabase(businessKey);
// 回填缓存,随机过期时间避免大量Key同时失效
const ttl = 300 + Math.floor(Math.random() * 60);
await client.set(cacheKey, JSON.stringify(dbResult), { EX: ttl });
return dbResult;
} finally {
// 无论成功失败都要释放锁
await unlock(lockKey, token);
}
}代码中有两处容易被忽略的设计。一是「双重检查」:第一个请求拿锁重建期间,其他请求在等待;当第一个请求释放锁后,可能有等待中的请求又拿到锁,如果没有二次检查缓存,就会重复执行数据库查询,浪费资源。二是过期时间加入随机偏移,让同一批写入的Key错峰失效,从源头减少热点Key同时击穿的概率。
四、锁超时与业务执行时间的矛盾及应对
互斥锁方案最大的隐患是锁超时时间与业务执行时间不匹配。假设锁TTL设为10秒,但数据库查询因为慢查询花了15秒,锁在第10秒自动过期,另一个请求抢到锁开始重建,第15秒第一个请求执行完finally里的unlock,由于token校验机制它不会误删别人的锁,但此时确实存在两个请求同时在查库的情况,互斥性被短暂打破。
p>针对这个问题,业界成熟的做法是「看门狗」机制:持有锁的进程启动一个定时器,每隔TTL的三分之一时间执行一次锁续期,把过期时间重新延长到完整TTL,业务执行完毕后取消定时器并释放锁。Redlock等成熟库内部就内置了类似机制。如果不想自己实现,生产环境可以直接使用redlock这个npm包,它支持多Redis实例部署,能进一步降低单点故障导致锁失效的风险。五、生产环境落地建议
首先是压测验证。互斥锁方案在极端并发下会让大量请求处于自旋等待状态,会占用Node.js的事件循环和Redis连接。建议把等待重试间隔设置在50到100毫秒,并严格限制最大重试次数,超时后走降级逻辑,比如返回上一次的旧数据或默认兜底值,而不是让请求无限堆积。
其次是连接池管理。高并发下每次操作都新建Redis连接是不可接受的,应该复用全局client实例,或者使用连接池。Node-redis客户端本身支持连接复用,注意在应用启动时完成connect()并做好错误监听与自动重连配置。
最后是监控与告警。建议对锁的获取成功率、缓存重建耗时、等待超时次数建立监控指标,一旦发现锁竞争异常激烈或重建耗时过长,及时排查慢查询,必要时结合逻辑过期方案或对热点Key做永不过期加定时刷新的处理。多套方案组合使用,才能让系统在高并发场景下保持稳定。
Node.js分布式缓存热点Key互斥锁修改时间:2026-09-02 14:27:15