导读:本期聚焦于木下创作的《Node.js分布式锁释放时如何校验唯一标识?用Lua脚本保证原子性》,敬请观看详情。释放分布式锁时直接执行DEL命令会带来严重隐患:任何客户端都可能误删他人持有的锁。为什么Redis官方推荐先GET比对唯一标识再DEL?这两步操作之间存在时间窗口,如何避免竞态条件?本文围绕Node.js环境下的Redis分布式锁释放问题展开,讲解为什么每个锁必须绑定UUID加线程标识组成的唯一值,如何通过EVAL执行Lua脚本把校验和删除合并为原子操作,并给出ioredis与node-redis两种客户端的完整代码示例,同时分析锁过期时间续期、误删防护等常见坑点,帮助你写出真正可靠的分布式锁。

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

Node.js分布式锁释放时如何校验唯一标识?用Lua脚本保证原子性

为什么释放锁之前必须校验唯一标识

先看一个典型的错误实现。加锁时使用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
end

KEYS[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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260909/53239.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。