用Redis做分布式锁是微服务和多实例部署下的常见方案,但不少团队在实现释放锁的逻辑时直接调用DEL命令,只要key存在就删除。这种写法在生产环境中迟早会出事故:当业务执行时间超过锁的过期时间后,锁已被Redis自动释放并被其他客户端获取,此时原客户端执行DEL删掉的就是别人的锁。正确的做法是在加锁时写入一个唯一标识,释放时先校验标识再删除,而且这两步必须用Lua脚本保证原子性。本文结合Node.js的实际代码,把这个机制完整讲清楚。

为什么释放锁之前必须校验唯一标识
先看一个典型的错误实现。加锁时使用SET key value NX EX 10,释放时直接执行DEL:
const redis = require('ioredis').createClient();
// 错误示范:释放锁不做任何校验
async function unlock(key) {
await redis.del(key);
}问题出在时间线上。假设客户端A加锁时设置了10秒过期,但业务逻辑因为数据库慢查询执行了15秒。在第10秒,锁被Redis自动过期删除;第11秒,客户端B成功获取了同一把锁。第15秒,客户端A的业务执行完毕,调用DEL删除key,此时删除的其实是B的锁。紧接着客户端C又能拿到锁,于是B和C同时持有"锁",互斥性被彻底破坏。
解决思路是在加锁时写入一个只有当前客户端知道的随机值,释放时只有value匹配才允许删除。这个值通常由UUID加客户端内线程或请求标识拼接而成,保证多实例、多请求之间互不冲突。这样即使A的锁过期了,A在释放时发现value对不上,就知道锁已经不属于自己,放弃删除即可。
校验加删除为什么必须用Lua脚本
有了唯一标识后,直觉的写法是先GET再比较再DEL:
// 仍然有隐患的写法
async function unlock(key, value) {
const current = await redis.get(key);
if (current === value) {
await redis.del(key); // GET和DEL之间存在时间窗口
}
}这段代码的问题在于GET和DEL是两次独立的网络往返,中间存在竞态窗口。设想在GET返回之后、DEL执行之前,key恰好过期且被其他客户端重新加锁,DEL依然会误删。虽然窗口极短,但在高并发场景下并非不可能发生。
Redis本身不提供"比对value并删除"的单条命令,官方推荐的做法是把逻辑写成Lua脚本,通过EVAL执行。Redis以单线程方式执行脚本,脚本执行期间不会被其他命令打断,因此校验和删除天然是原子的。脚本内容非常简单:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
endKEYS[1]是锁的key,ARGV[1]是客户端持有的唯一标识。只有值完全相等才删除并返回1,否则返回0,客户端根据返回值就能判断锁是否还属于自己的。需要注意一点:锁的value如果包含集群或线程信息,比较时必须传完整字符串,只比较UUID前缀是不够的。
Node.js完整实现:ioredis与node-redis两种写法
先看ioredis的版本。ioredis内置了eval方法,参数依次是脚本内容、key数量、key名和脚本参数:
const Redis = require('ioredis');
const crypto = require('crypto');
const redis = new Redis();
const UNLOCK_SCRIPT = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`;
class DistributedLock {
// 加锁:SET key value NX EX 保证原子性
async lock(key, ttlSeconds) {
// 唯一标识 = 随机UUID + 实例ID,避免不同实例重复
this.token = crypto.randomUUID() + ':' + process.pid;
const result = await redis.set(
key, this.token, 'EX', ttlSeconds, 'NX'
);
return result === 'OK';
}
// 解锁:Lua脚本原子校验并删除
async unlock(key) {
const result = await redis.eval(
UNLOCK_SCRIPT, 1, key, this.token
);
return result === 1;
}
}如果使用官方的node-redis客户端(v4及以上版本),推荐先注册脚本获得一个函数对象,调用起来更简洁,而且客户端会优先使用EVALSHA减少网络传输:
const { createClient } = require('redis');
const crypto = require('crypto');
const redis = createClient();
await redis.connect();
const unlockScript = redis.defineScript({
lua: `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end`,
NUMBER_OF_KEYS: 1
});
async function lockAndUnlock(lockKey) {
const token = crypto.randomUUID();
const ok = await redis.set(lockKey, token, {
NX: true,
EX: 30
});
if (!ok) return false;
try {
// 执行业务逻辑
} finally {
const released = await unlockScript.execute(lockKey, token);
if (!released) {
console.warn('锁已过期或被其他客户端持有,未执行删除');
}
}
}两个细节值得强调。第一,token必须在加锁前生成并保存在内存中,不能每次释放时重新生成,否则比对永远不会成功。第二,释放失败(返回0)不应视为程序错误,而是说明锁已经过期,业务方需要意识到这段时间内互斥性可能已经丧失,必要时记录日志或触发补偿流程。
进阶考虑:过期续期与误删兜底
唯一标识解决了误删问题,但没有解决业务超时问题。如果任务可能超过TTL,标准做法是启动一个定时器,每隔TTL的三分之一时间执行一次续期脚本,脚本同样用Lua保证原子性:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end续期成功说明锁仍归自己所有,续期失败则说明锁已丢失,业务应当尽快终止或进入降级逻辑,避免在没有锁保护的状态下继续写共享资源。Node.js中可以用setInterval实现,但要在进程退出时及时清理定时器,否则会造成句柄泄漏。
另外提醒几个实践中的常见坑:锁的key要包含业务维度,比如lock:order:1001,粒度过粗会互相阻塞;TTL不宜设置过长,宁可配合续期机制也不要一次设置几分钟;在Redis主从切换的极端场景下,主节点写入锁后尚未同步到从节点就宕机,可能出现两个客户端同时持锁,如果业务对正确性要求极高,可以考虑Redlock算法或改用etcd、ZooKeeper等强一致存储。
总结一下核心要点:加锁用SET ... NX EX写入随机token,释放锁用Lua脚本做"比对token再删除"的原子操作,配合续期脚本处理长任务。这三件事做到位,Redis分布式锁在日常业务场景下就能稳定可靠地运行。
Node.js分布式锁Lua脚本Redis修改时间:2026-09-09 06:08:35