在单线程的Node.js环境中处理并发请求时,保证同一个执行上下文内多次获取同一把锁而不导致死锁是一个复杂的技术挑战。传统的分布式锁往往只关注跨进程的互斥,却忽略了同进程内重入的需求。当业务逻辑中存在嵌套调用时,如果每次调用都去请求外部锁,极易造成资源浪费甚至死锁。为了解决这一痛点,我们可以引入类似ThreadLocal的上下文存储机制。通过在异步执行链路中传递锁的持有状态,Node.js也能像多线程语言一样优雅地实现可重入分布式锁。

为什么Node.js需要可重入分布式锁?
Node.js虽然以单线程事件循环著称,但在处理高并发请求时,依然需要分布式锁来协调多个进程或实例对共享资源的访问。普通的分布式锁通常基于Redis的SETNX命令实现,这种锁能确保同一时刻只有一个客户端持有锁。然而,这种简单的互斥机制在面对复杂的业务嵌套场景时会显得力不从心。可重入性意味着同一个线程或执行上下文在已经持有锁的情况下,可以再次获取该锁而不会被阻塞,只需在内部记录重入次数即可。
假设我们有一个处理订单的服务,入口函数需要获取订单锁以防止并发修改。在处理过程中,该函数调用了库存扣减的子服务,而库存扣减服务为了自身安全也尝试获取同一把订单锁。如果锁不具备可重入性,子服务将无法获取锁,导致请求被阻塞甚至死锁。这种业务逻辑的嵌套在实际开发中非常普遍,因此可重入锁是解决此类问题的必要手段。
要实现可重入,锁服务必须知道当前请求加锁的到底是谁。在Java中,我们可以依靠线程ID来唯一标识持有者,因为每个请求都有独立的线程承载。但在Node.js中,所有请求都在同一个主线程执行,无法通过线程ID区分不同的请求链路。如果使用一个全局变量存储当前持有者,不同请求之间会发生数据污染。因此,我们需要一种能够在异步调用链路中传递的唯一标识,这就是类似ThreadLocal的存储机制发挥作用的地方。
基于ThreadLocal思想的上下文存储设计
ThreadLocal在多线程语言中的核心思想是为每一个线程提供独立的变量副本,使得各线程间的数据相互隔离。Node.js虽然没有多线程,但其异步执行机制同样存在上下文切换的问题。为了在异步调用链中保持上下文信息,Node.js在核心模块中提供了AsyncLocalStorage。它允许我们在一个异步操作开始时存储数据,并在该操作引发的后续所有异步回调中访问这些数据,而不会与其他请求的数据混淆。
利用AsyncLocalStorage,我们可以为每一个进入系统的请求生成一个唯一的请求ID,这个ID就充当了当前执行上下文的标识。当业务代码尝试获取分布式锁时,锁模块会首先从AsyncLocalStorage中取出这个请求ID。如果当前请求已经持有该锁,那么只需增加重入计数即可;如果未持有,则向Redis发起加锁请求,并将锁的持有者设置为该请求ID。这种设计完美契合了Node.js的异步特性,实现了逻辑上的线程隔离。
下面展示如何初始化AsyncLocalStorage并在中间件中注入上下文ID。通过这段代码,我们可以确保每一个请求都拥有独立的执行上下文,为后续的可重入锁判断提供基础数据支撑。
const { AsyncLocalStorage } = require('async_hooks');
const crypto = require('crypto');
// 创建全局的异步上下文存储实例
const asyncLocalStorage = new AsyncLocalStorage();
// 上下文中间件,为每个请求分配唯一标识
function contextMiddleware(req, res, next) {
const requestId = crypto.randomUUID();
// 将请求ID存入上下文,后续所有异步操作均可获取
asyncLocalStorage.run({ requestId }, () => {
next();
});
}
// 获取当前上下文ID的工具函数
function getCurrentContextId() {
const store = asyncLocalStorage.getStore();
return store ? store.requestId : null;
}
module.exports = { contextMiddleware, getCurrentContextId };
实现可重入锁的核心逻辑与Lua脚本保障
在Redis中实现可重入锁,通常采用Hash结构来存储锁的信息。Hash的key为锁的名称,field为持有者标识(即我们的请求ID),value为重入次数。当请求加锁时,如果锁不存在,则创建Hash并设置重入次数为1;如果锁存在且field为当前请求ID,则将重入次数加1;如果锁存在但field不是当前请求ID,则返回加锁失败。解锁逻辑则相反,将重入次数减1,当次数为0时删除该Hash。
上述判断和操作必须保证原子性,否则在并发下会导致数据错乱。Redis的单一命令无法满足这种复杂的条件判断,因此必须借助Lua脚本。Lua脚本在Redis中是原子执行的,能够将多个操作打包成一个不可分割的整体。通过编写完善的Lua脚本,我们可以确保加锁和解锁过程的绝对安全。
下面给出完整的Node.js实现,结合AsyncLocalStorage和Lua脚本执行加锁与解锁操作。代码中展示了如何获取上下文ID,并将其作为锁的持有者标识传递给Redis,从而实现同一线程内的锁重入机制。
const redis = require('redis');
const { getCurrentContextId } = require('./context');
const client = redis.createClient({ url: 'redis://127.0.0.1:6379' });
// 加锁的Lua脚本
const LOCK_SCRIPT = `
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[1], 1)
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
end
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[1], 1)
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
end
return 0
`;
// 解锁的Lua脚本
const UNLOCK_SCRIPT = `
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
return 0
end
local count = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if count > 0 then
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
else
redis.call('del', KEYS[1])
return 1
end
`;
async function acquireLock(lockKey, timeout = 30000) {
const contextId = getCurrentContextId();
if (!contextId) {
throw new Error('无法获取执行上下文ID');
}
const result = await client.eval(LOCK_SCRIPT, 1, lockKey, contextId, timeout);
return result === 1;
}
async function releaseLock(lockKey, timeout = 30000) {
const contextId = getCurrentContextId();
if (!contextId) {
throw new Error('无法获取执行上下文ID');
}
const result = await client.eval(UNLOCK_SCRIPT, 1, lockKey, contextId, timeout);
return result === 1;
}
module.exports = { acquireLock, releaseLock };
这种基于AsyncLocalStorage和Lua脚本的可重入锁方案具有极高的实用性。优点在于对业务代码侵入性小,开发者无需手动传递上下文,且保证了高并发下的数据一致性。然而,它也存在一定的局限性,AsyncHooks本身会带来微小的性能损耗,在极端高并发场景下需要权衡。此外,如果异步链路中出现了未正确绑定的回调,可能会导致上下文丢失,从而使得重入判断失败。因此,在业务开发中必须严格遵循异步编程规范,确保上下文的正确传递。