在Node.js多实例部署场景中,分布式锁承载的是跨进程互斥。假设订单服务扩展为4个进程,每个进程都可能触发库存扣减,此时仅靠进程内互斥已经失效,通常需要Redis等外部组件协调。当并发量升高,多个实例会在短时间内同时尝试获取同一把锁,大量请求返回失败,紧接着进入重试。锁竞争越激烈,等待策略对系统吞吐和响应时间的影响越大。如果失败后立刻无休止地重试,相当于自旋;如果失败后先让出执行权等待固定间隔再试,则更接近休眠。二者的实现看似简单,但放在Node.js的异步模型里,稍不注意就会阻塞事件循环或者放大Redis压力。

一、为什么分布式锁的等待策略需要单独设计
分布式锁的核心目标不是让所有请求都成功,而是让同一时刻只有一个请求进入临界区。竞争激烈时,大部分请求会失败,失败后的处理方式决定了系统的延迟分布和资源消耗。Redis通常使用SET key value NX PX ttl实现加锁,这条命令是原子操作,可以避免先查询再写入导致的竞态。请求失败时返回nil,客户端需要自行决定是继续重试还是放弃。
Node.js的异步模型要求所有等待逻辑不能阻塞事件循环。如果直接在while循环里同步调用Redis或空转,事件循环会被锁死,其他请求、定时器、IO回调都无法执行。因此无论是自旋还是休眠,都必须通过async/await配合setTimeout或setImmediate实现真正让出执行权。等待策略的差异主要体现在重试延迟、CPU占用、Redis命令量以及平均获取锁时间上。
二、自旋等待:以高负载换低延迟
自旋等待的核心思路是失败后立即再次尝试,或者只隔极短的时间就重试。优点是锁一旦释放,等待者能尽快感知并获取,平均延迟较低。缺点也很直接:每次重试都会产生一次Redis命令,锁竞争越激烈,无效命令越多。如果多个实例同时自旋,Redis的CPU和网络吞吐会被大量无效请求占据,可能影响其他缓存或队列操作。
在Node.js中实现异步自旋,每次尝试之间需要调用sleep(5)或setImmediate,将执行权交还事件循环。下面的代码展示了基于ioredis的自旋获取锁实现,最大自旋时间设置为1200毫秒,每次尝试之间等待5毫秒。
const Redis = require('ioredis');
const redis = new Redis();
const LOCK_KEY = 'order:lock';
const LOCK_TTL_MS = 5000;
const MAX_SPIN_MS = 1200;
const SPIN_SLEEP_MS = 5;
async function tryAcquireLock() {
const token = `${Date.now()}-${Math.random()}`;
const result = await redis.set(LOCK_KEY, token, 'PX', LOCK_TTL_MS, 'NX');
if (result === 'OK') {
return token;
}
return null;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function acquireWithSpin() {
const start = Date.now();
while (Date.now() - start < MAX_SPIN_MS) {
const token = await tryAcquireLock();
if (token) return token;
await sleep(SPIN_SLEEP_MS);
}
throw new Error('acquire lock timeout');
}
自旋等待适合临界区时间很短的场景,例如修改一行缓存、插入一条流水记录。如果临界区执行时间通常是几十毫秒,自旋可以在锁释放后的下一轮立即获取。但需要设置最大自旋时间和最大重试次数,否则一旦锁长期不释放,自旋会无限执行。实际工程中还可以在自旋阶段加入计数,达到阈值后自动降级为休眠。
三、休眠等待:用延迟换稳定性
休眠等待的思路是失败后等待一个固定或动态计算的时间间隔,再发起下一次获取。它不会立刻尝试,因此Redis命令量大幅下降,CPU消耗也更低。代价是锁释放后,请求可能还在睡眠,导致获取锁的平均延迟上升。如果固定休眠时间太长,临界区可能已经空闲很久,但等待者还没有醒来。
固定间隔休眠实现简单,但容易引发惊群:多个实例同时苏醒,同时竞争同一把锁。更好的做法是指数退避,每次失败后等待时间翻倍,并加入随机抖动,把不同实例的重试时间错开。下面代码展示了指数退避加随机抖动的实现方式。
const BASE_SLEEP_MS = 50;
const MAX_SLEEP_MS = 1000;
const MAX_WAIT_MS = 10000;
function getBackoff(attempt) {
const exp = Math.min(BASE_SLEEP_MS * Math.pow(2, attempt), MAX_SLEEP_MS);
const jitter = Math.floor(Math.random() * exp * 0.3);
return Math.min(exp + jitter, MAX_SLEEP_MS);
}
async function acquireWithSleep() {
const start = Date.now();
let attempt = 0;
while (Date.now() - start < MAX_WAIT_MS) {
const token = await tryAcquireLock();
if (token) return token;
const delay = getBackoff(attempt);
await sleep(delay);
attempt += 1;
}
throw new Error('acquire lock timeout');
}
休眠策略更适合临界区执行时间较长、请求并发量较大的场景。比如批量文件处理、第三方支付回调等操作,锁可能持有数百毫秒甚至数秒。此时牺牲一点延迟换取Redis和CPU的稳定,通常比盲目自旋更划算。指数退避还能避免重试风暴,使系统在竞争激烈时保持可预期。
四、混合策略与释放安全
实际业务中,锁的持有时间并不是固定的。短临界区适合自旋,长临界区适合休眠,但同一个服务里两种场景可能同时存在。可以将二者组合:先经过一个短暂的自旋阶段,争取在锁释放后的第一时间拿到锁;如果自旋阶段耗尽仍未成功,则转入指数退避休眠,降低后续资源消耗。
下面的混合实现先用200毫秒自旋,每次间隔5毫秒,如果仍然失败则切换到指数退避。这样既能保留低延迟特性,又避免长时间自旋对Redis造成持续压力。
async function acquireWithHybrid() {
const start = Date.now();
let attempt = 0;
// 第一阶段:短自旋,快速响应锁释放
while (Date.now() - start < 200) {
const token = await tryAcquireLock();
if (token) return token;
await sleep(5);
}
// 第二阶段:指数退避休眠,减少Redis压力
while (Date.now() - start < MAX_WAIT_MS) {
const token = await tryAcquireLock();
if (token) return token;
const delay = getBackoff(attempt);
await sleep(delay);
attempt += 1;
}
throw new Error('acquire lock timeout');
}
不管使用哪种等待策略,锁的安全释放都不能忽略。释放前必须校验锁的持有者身份,避免进程A的锁过期后,进程B获取到锁,而进程A仍然执行删除操作。可以使用Lua脚本把校验和删除合并为原子操作。
const RELEASE_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
async function releaseLock(token) {
await redis.eval(RELEASE_SCRIPT, 1, LOCK_KEY, token);
}
除了身份校验,还要考虑锁续期。临界区执行时间可能超过设置的TTL,虽然可以把TTL设置得足够长,但过长又会拖延故障恢复。一般做法是启动一个定时器,在任务执行期间周期性地为锁延长有效期,任务完成后取消续期并释放。等待策略与锁续期相互配合,才能形成可靠的分布式互斥方案。
五、两种策略的对比与选型建议
自旋与休眠并不是非此即彼,它们代表的是延迟和资源消耗之间的权衡。自旋的获取延迟低,但Redis命令量大,适合锁持有时间短、竞争不算极端的场景。休眠的获取延迟高,但资源占用低,适合锁持有时间长、并发量大的场景。混合策略则适用于锁持有时间不确定的通用服务。
| 对比维度 | 自旋等待 | 休眠等待 | 混合策略 |
|---|---|---|---|
| 获取锁延迟 | 低 | 较高 | 较低 |
| Redis命令量 | 高 | 低 | 中等 |
| CPU占用 | 高 | 低 | 中等 |
| 事件循环阻塞风险 | 需异步实现 | 低 | 需异步实现 |
| 适用临界区 | 短任务 | 长任务 | 不确定 |
从工程角度看,建议先评估锁的平均持有时间。如果P99持有时间在50毫秒以内,可以使用自旋,并设置上限。如果超过200毫秒,优先选择指数退避休眠。如果无法准确评估,直接采用混合策略通常不会出错。除了等待策略,还要为整体获取锁设置最大等待时间,超过后返回明确错误,避免请求无限等待。
在Node.js服务中,还要特别注意Redis客户端的连接池配置。自旋阶段会产生大量命令,如果连接池过小,命令会排队,反而增加延迟。休眠阶段对连接压力较小,但退避函数需要限制最大休眠时间,防止长时间不重试。通过监控Redis命令量、拿锁平均耗时、锁竞争失败率,可以持续调整自旋时长和退避参数,使分布式锁在竞争激烈时依然保持稳定。
Node.js分布式锁自旋等待休眠重试修改时间:2026-09-21 21:20:20