用Redis做分布式锁是Node.js服务里非常常见的方案,set key value nx ex这条命令几乎成了标配。但真正在生产环境跑起来之后,问题往往出在锁过期时间不好定:设短了业务没跑完锁就被释放,设长了进程崩溃后其他节点又要干等。超时自动续期(也叫看门狗机制)就是解决这个矛盾的方案,而它的核心难点在于续期动作必须保证原子性,这正是Lua脚本发挥作用的地方。

为什么锁续期必须用Lua脚本保证原子性
先看一段典型的错误续期代码,很多项目里都能找到类似的写法:
const token = uuid(); // 锁的唯一持有标识
await redis.set('mylock', token, 'EX', 30, 'NX');
// 业务执行到一半,准备续期
const owner = await redis.get('mylock');
if (owner === token) {
// 危险窗口:get和expire之间锁可能刚好过期并被别人抢走
await redis.expire('mylock', 30);
}这段代码的问题在于get和expire是两条独立命令,中间存在时间窗口。假设在get执行之后、expire执行之前,锁恰好过期,另一个客户端B抢到了锁并设置了新的value,此时我们再执行expire,就会错误地把B刚刚持有的锁延长了过期时间。更糟糕的情况是,如果后续代码还有释放锁的逻辑(del操作前校验不严),可能出现A把B的锁删掉、C又拿到锁,最终两个进程同时持有锁,分布式锁彻底失效。
Redis的单条命令是原子的,但多条命令之间可能穿插其他客户端的请求。Lua脚本在Redis中是作为一个整体执行的,Redis保证脚本执行期间不会插入任何其他命令。也就是说,把“判断持有者”和“重置过期时间”写进同一个Lua脚本,客户端就无法在两个步骤之间插队,这就是续期必须借助Lua脚本的根本原因。需要注意原子性只针对单个Redis节点,如果部署的是Redis Cluster,key会被哈希到某个槽,脚本只要操作单个key就不受影响;跨多个key操作时要用hash tag保证它们落在同一节点。
续期Lua脚本的正确写法与Node.js集成
续期脚本的逻辑很简单:只有当前锁的value等于客户端传入的token时,才执行pexpire延长过期时间,否则返回0表示续期失败(锁已易主或已删除)。脚本如下:
-- KEYS[1] 锁的key,ARGV[1] 持有者token,ARGV[2] 新的过期毫秒数
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end释放锁的脚本同理,也必须原子化,否则同样存在误删他人锁的风险:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end在Node.js中可以通过ioredis的eval或defineCommand方法执行脚本。推荐用defineCommand把脚本注册成自定义命令,代码更干净,还能利用Redis脚本的缓存机制(EVALSHA)减少每次传输脚本的带宽开销:
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
const renewScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end`;
const releaseScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end`;
redis.defineCommand('renewLock', {
numberOfKeys: 1,
lua: renewScript
});
redis.defineCommand('releaseLock', {
numberOfKeys: 1,
lua: releaseScript
});
module.exports = { redis };看门狗机制的设计与完整实现
有了原子续期能力,还需要一个后台定时任务来驱动它,这就是看门狗。它的基本工作方式是:加锁时不设固定长过期时间,或者设一个较短时间(比如30秒),然后每隔过期时间的三分之一(比如10秒)检查一次业务是否还在执行,是则续期。之所以选三分之一,是为了保证即使某次续期因为网络抖动失败了,后面还有两次重试机会,不至于锁直接过期。
看门狗的生命周期必须和业务绑定:业务开始时启动定时器,业务结束(无论成功还是失败)时立即清除定时器并释放锁。如果进程在业务执行中崩溃,定时器随进程一起消亡,锁会在过期后自动释放,不会出现死锁。下面是完整的封装实现:
const crypto = require('crypto');
class DistributedLock {
constructor(redis, key, ttl = 30000) {
this.redis = redis;
this.key = key;
this.ttl = ttl;
this.token = null;
this.timer = null;
}
// 尝试加锁
async acquire() {
this.token = crypto.randomUUID();
const ok = await this.redis.set(this.key, this.token, 'PX', this.ttl, 'NX');
if (ok !== 'OK') return false;
this.startWatchdog();
return true;
}
// 看门狗:每ttl/3续期一次
startWatchdog() {
this.timer = setInterval(async () => {
try {
// 调用原子续期,锁已易主则返回0
const result = await this.redis.renewLock(this.key, this.token, this.ttl);
if (result === 0) {
this.stopWatchdog();
}
} catch (err) {
console.error('续期失败,等待下次重试:', err.message);
}
}, this.ttl / 3);
if (this.timer.unref) this.timer.unref();
}
stopWatchdog() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
// 业务执行完毕,释放锁
async release() {
this.stopWatchdog();
if (this.token) {
await this.redis.releaseLock(this.key, this.token);
this.token = null;
}
}
}
// 使用示例:包装需要互斥执行的业务
async function withLock(redis, key, fn, ttl = 30000) {
const lock = new DistributedLock(redis, key, ttl);
if (!await lock.acquire()) {
throw new Error('获取锁失败:' + key);
}
try {
return await fn();
} finally {
await lock.release();
}
}
(async () => {
await withLock(redis, 'order:1001:pay', async () => {
console.log('执行支付业务,即使超过30秒锁也不会失效');
await new Promise(r => setTimeout(r, 60000));
});
redis.disconnect();
})();这段实现里有几个细节值得注意。第一,token必须每个客户端唯一(UUID),它是续期和释放时证明身份的凭证,绝不能用固定字符串,否则会互相误删锁。第二,续期失败返回0时要立即停止看门狗,说明锁已经被别的进程持有,当前业务应该考虑中断或进入降级逻辑。第三,定时器建议调用unref(),避免它阻止Node.js进程正常退出。第四,Node.js的事件循环如果被长时间同步计算阻塞,定时器会延迟触发,极端情况下可能来不及续期,所以持锁的业务里不要有大计算量的同步代码,或者把续期间隔设置得再保守一些。
常见误区与生产环境注意事项
第一个误区是给锁设置一个超长的过期时间来“绕过”续期问题,比如直接设成10分钟。这种做法在进程崩溃时会导致锁最长悬挂10分钟,其他节点完全无法工作,等于牺牲了可用性换取表面的简单。看门狗加短TTL的组合才是兼顾方案:正常运行时锁不会提前失效,异常崩溃时锁最多等一个TTL就自动恢复。
第二个误区是忽略时钟和网络问题对续期的影响。续期请求在网络中传输需要时间,如果网络延迟接近或超过TTL的三分之一,续期窗口就会变得很紧张。建议TTL不要太小,一般10到30秒比较稳妥,同时给续期失败加监控告警,一旦发现续期失败率上升,通常是Redis或网络出了问题。
第三个误区涉及Redlock算法。如果业务要求强正确性,部署多个独立Redis节点用Redlock,续期逻辑就需要对多个节点分别执行并检查多数派成功,复杂度明显上升。对绝大多数互联网业务来说,单节点Redis加原子续期加看门狗已经足够,可以参考Redisson在Java生态中的实现思路:它同样是在锁被持有的情况下启动定时任务,每隔内部锁时间的 thirds之一执行Lua续期。Node.js生态里,redlock这个npm包也内置了续期支持,可以直接使用,其用法如下:
const Redlock = require('redlock');
const redlock = new Redlock([redis], {
retryCount: 3,
retryDelay: 200
});
async function run() {
let lock = await redlock.acquire(['resource:1'], 30000);
try {
// redlock会在锁过期前自动续期
const result = await doBusiness();
return result;
} finally {
await lock.release();
}
}总结一下,Node.js实现分布式锁续期的关键点有三:用Lua脚本把身份校验和过期时间重置合成原子操作,用唯一token标识持有者防止误操作他人的锁,用与业务同生命周期的定时器驱动续期并在业务结束时清理。把这三点做扎实,分布式锁在超长任务场景下就能稳定工作了。
Node.js分布式锁Lua脚本锁续期修改时间:2026-09-13 17:23:05