在 Node.js 服务多实例部署时,使用 Redis 单节点的 SETNX 命令虽然能快速加锁,但主从切换或网络抖动可能让锁意外失效。Redlock 算法通过让客户端向多个独立 Redis 节点发起加锁请求,并在多数节点成功时才算获取锁,从而降低单点故障带来的风险。接下来以 redlock 这个 npm 包为例,介绍如何在代码中落地。

为什么单节点 SETNX 不够用
单节点 Redis 锁通常用 SET resource token NX PX ttl 实现。它的原子性很好,但存在一个明显问题:Redis 主节点一旦宕机,从节点提升为主节点后,原主节点上的锁记录可能还没来得及同步过去。新的主节点上锁记录丢失,另一个客户端就能成功加锁,导致两个进程同时进入临界区。对于订单库存扣减这类场景,这就可能造成重复扣减或超卖。
加入主从复制后延迟窗口依旧存在。即使使用哨兵或集群模式,异步复制的特性决定了故障切换时锁的丢失无法完全避免。Redlock 的思路不是依赖主从结构,而是要求客户端同时连接多个互相独立的 Redis 节点。只有当客户端在超过半数的节点上成功写入锁,且总耗时小于锁的有效期时,才认为锁获取成功。这样即便某个节点异常,只要多数节点仍正常,锁的一致性就能得到保证。
需要注意的是,多节点意味着请求复杂度上升。每个节点都需要单独创建连接,加锁和释放都要向所有节点发送命令。如果业务量不大,直接换用单节点加 Redlock 并不划算。只有在对锁安全性要求较高,并且可以接受额外网络开销时,才值得引入。
安装与初始化 Redlock 客户端
在 Node.js 项目中,通常使用 ioredis 作为 Redis 客户端,再配合 redlock 包完成分布式锁逻辑。安装命令非常直接,执行 npm install ioredis redlock 即可。redlock 内部会调用每个 Redis 客户端的 EVAL、SET 等命令,所以需要把创建好的 ioredis 实例数组传入。
连接多个节点时,每个 client 都应当指向独立的 Redis 进程,而不是同一个进程的多个数据库编号。否则当这个进程崩溃时,所有数据库都会一起不可用,也就失去了多节点投票的意义。示例中准备了三个本机不同端口的 Redis 实例,生产环境建议部署在不同机器上。
const Redis = require('ioredis');
const Redlock = require('redlock');
const clients = [
new Redis({ host: '127.0.0.1', port: 6379 }),
new Redis({ host: '127.0.0.1', port: 6380 }),
new Redis({ host: '127.0.0.1', port: 6381 }),
];
const redlock = new Redlock(clients, {
driftFactor: 0.01,
retryCount: 10,
retryDelay: 200,
retryJitter: 100,
});
redlock.on('error', (err) => {
console.error('redlock error', err);
});
上面代码中的 driftFactor 用来补偿不同进程之间的时钟偏移,默认 0.01 表示预留 1% 的 TTL 时间。锁的实际有效时间会被 redlock 自动计算为 TTL 减去时钟漂移和网络耗时。重试相关参数中,retryCount 是最大重试次数,retryDelay 是基础等待时间,retryJitter 让每次等待时间随机浮动,避免多个客户端同时重试造成惊群。
redlock 客户端本身会发出 error 事件,当某个 Redis 节点连接断开或命令失败时会触发。如果不监听 error 事件,Node.js 默认会抛出未捕获异常。建议在初始化后立刻挂载监听器,并记录日志或接入告警。
加锁、续期与安全释放
获取锁使用 redlock.acquire(resources, ttl)。第一个参数 resources 是资源标识数组,可以同时锁定多个资源,但通常传单个 key。第二个参数 ttl 是锁的过期时间,单位为毫秒。acquire 返回一个 Lock 对象,业务执行完成后需要调用 lock.release() 释放。释放操作必须放在 finally 中,确保异常情况下也能清理。
async function processOrder(orderId) {
const resource = ['locks:order:' + orderId];
let lock = null;
try {
lock = await redlock.acquire(resource, 5000);
console.log('获取锁成功');
// 执行业务,如扣库存、写订单
await deductStock(orderId);
} catch (err) {
console.error('加锁失败或业务异常', err);
} finally {
if (lock) {
try {
await lock.release();
} catch (releaseErr) {
console.error('释放锁失败', releaseErr);
}
}
}
}
对于执行时间可能超过 TTL 的任务,需要提前续期。redlock 的 Lock 对象提供 extend(ttl) 方法,会向所有节点发送延长有效期的命令。通常用一个定时器在锁过期前重复调用 extend,任务结束后清理定时器并释放锁。下面的例子把 TTL 设为 2000 毫秒,每 1000 毫秒续期一次,给长任务留足余量。
async function processLongTask(orderId) {
const resource = ['locks:order:' + orderId];
let lock = null;
let extender = null;
try {
lock = await redlock.acquire(resource, 2000);
extender = setInterval(async () => {
try {
lock = await lock.extend(2000);
console.log('锁已续期');
} catch (err) {
console.error('续期失败', err);
}
}, 1000);
await longRunningTask(orderId);
} finally {
if (extender) clearInterval(extender);
if (lock) {
try {
await lock.release();
} catch (err) {
console.error('释放锁失败', err);
}
}
}
}
释放锁不能简单执行 DEL,否则可能误删其他客户端在同一资源上的锁。redlock 内部使用 Lua 脚本校验锁的 token,只有当 value 仍属于当前客户端时才删除。脚本逻辑等价于下面的片段:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
这个 Lua 脚本会被 EVAL 发送到所有节点。由于 Redis 执行 Lua 脚本具有原子性,比较和删除不会被打断,可以有效避免锁持有者过期后误删新的锁。如果某个节点上返回 0,说明该节点上的锁可能已经过期或被替换,redlock 不会因为它而影响整体释放流程,但会通过错误或事件提示需要关注。
Redlock 的局限与工程建议
Redlock 并不是绝对安全的方案。分布式系统领域对它的争论集中在 GC 停顿、时钟跳变和网络分区上。比如客户端在获取锁后发生长时间的垃圾回收暂停,等暂停恢复时锁可能已经过期,而客户端却仍认为自己持有锁并继续写数据。redlock 通过 driftFactor 补偿时钟偏移,但无法补偿进程停顿。对于这类风险,需要结合业务层幂等、数据库唯一约束等兜底手段。
部署层面有几个建议。第一,连接节点数量通常选择 3 或 5,数量过少无法提供真正多数派保障,过多则加锁延迟明显上升。第二,每个 Redis 节点应独立部署在不同故障域,避免使用同一个物理机上的多实例。第三,锁的 TTL 应略大于业务实际执行时间,并配合续期逻辑,而不是设置得特别大。TTL 太长会导致进程崩溃后锁长时间无法自动释放,TTL 太短又会频繁续期增加网络负担。
工程中还可以根据业务可接受程度做降级。如果服务本身有消息队列或数据库行锁做最终一致保障,可以优先使用单节点 Redis 锁加幂等校验。只有多个服务同时共享一份关键资源,且无法通过其他机制保证互斥时,才考虑接入 Redlock。总的来说,Redlock 提供了一种更严谨的分布式锁实现方式,但它仍需要和业务约束、监控告警以及灰度验证结合起来,才能真正减少并发冲突带来的问题。