在 Node.js 中处理异步任务时,如果同时发起大量请求,很容易消耗完文件句柄或数据库连接。我一般先从确认现象入手,看日志里是否有超时、ECONNRESET 等错误,再检查是否缺少并发控制。下面是一套基于异步队列控制并发的排查和实现思路,供你参考。
典型的实现会定义一个 Queue 类,包含 `push` 方法用于添加任务,`_next` 方法用于驱动队列。`push` 中将任务推入数组,如果当前正在执行的任务数小于并发上限,立即调用 `_next`。`_next` 从数组头部取出任务,执行并传入回调,回调中递减计数器并再次调用 `_next`。需要注意,如果任务执行中抛出异常,必须确保回调依然被调用,否则队列会永远卡住。
先确认现象
我会先查看应用日志,确认有没有大量 pending 状态的 Promise 未 resolve,或者出现类似“too many open files”的报错。如果发现同一时间发起的异步请求数量远大于预期,比如一个接口循环请求 1000 个外部 API,系统同时发出 1000 个 HTTP 请求,那八成就是没有做并发限制。另一个线索是 CPU 和内存使用率突然飙升,也可能是并发过高导致上下文切换频繁。
容易误判的地方
有时候开发者会误以为用 Promise.all 并行跑所有任务就行了,但 Promise.all 是并发启动所有任务,不会限制同时执行的数量。还有人把并发队列和普通数组混为一谈,以为 push 进去就自动排队执行,实际上没有驱动逻辑。更隐蔽的坑是,队列实现了,但任务回调里忘了处理异常,导致计数器卡死,队列永远不推进。
建议的处理顺序
正确的做法是从实现一个基本的异步队列开始,逐步加上错误处理和动态控制。实现并发控制的异步队列,通常维护一个任务列表和一个计数器。每次从队列中取出任务执行时,计数器加一;任务完成时,计数器减一并检查是否有等待的任务。当计数器达到设定的并发上限时,新任务不能立即执行,必须等待某个任务完成释放槽位。这样可以防止同时消耗过多系统资源。
典型的实现会定义一个 Queue 类,包含 push 方法用于添加任务,_next 方法用于驱动队列。push 中将任务推入数组,如果当前正在执行的任务数小于并发上限,立即调用 _next。_next 从数组头部取出任务,执行并传入回调,回调中递减计数器并再次调用 _next。需要注意,如果任务执行中抛出异常,必须确保回调依然被调用,否则队列会永远卡住。
下面给出一个简单的代码示例,方便你快速验证思路:
class Queue {
constructor(concurrency) {
this.concurrency = concurrency;
this.tasks = [];
this.running = 0;
}
push(task) {
this.tasks.push(task);
this._next();
}
_next() {
if (this.running >= this.concurrency || this.tasks.length === 0) return;
const task = this.tasks.shift();
this.running++;
task(() => {
this.running--;
this._next();
});
}
}这个实现里,task 是一个接收回调的函数,回调必须在任务完成后调用。如果任务抛出异常,回调不会执行,队列就会卡住。因此必须在 task 内部用 try/catch 包裹业务逻辑,保证回调一定被调用。
配置或命令示例
如果队列中的任务是一个返回 Promise 的异步函数,漏写 .catch 或 try/catch 会导致 Promise 的 reject 未被处理,计数器不会递减,队列永久阻塞。标准做法是在 Promise 链末尾添加 .catch(e => callback(e)),或者在 async/await 中用 try/finally 保证 callback 被调用。即使是同步任务,也要将整个执行体包裹在 try/catch 中,确保无论何种异常都能推进队列。
举个例子,如果任务是 async 函数:
const task = (callback) => {
(async () => {
try {
await doSomething();
callback(null);
} catch (err) {
callback(err);
}
})();
};
queue.push(task);这里用 try/catch 捕获错误,并传给 callback。注意 callback 调用后队列才会继续。
验证方法
验证队列是否正常工作,最简单的方式是添加日志,打印当前 running 数量。在 _next 入口处输出 running 和 tasks.length,观察并发数是否始终不超过上限。还可以故意让一些任务失败(例如抛异常),确认 callback 依然被调用,队列继续执行后续任务。另一个验证点是监控系统资源,用 top 或 process.memoryUsage() 看看并发数调高后资源占用是否合理。
后续维护
并发数并非越高越好。过高的并发可能耗尽文件句柄、数据库连接池或内存,导致系统响应变慢。一般建议从 1 开始逐步调大,观察 CPU 和内存使用率,找到资源饱和点。对于 I/O 密集型任务,并发数可适当大些(如几十到几百);对于 CPU 密集型任务,建议不超过 CPU 核心数。同时,队列排队过长会消耗内存,可在入队前检查队列长度并触发背压或拒绝策略。
如果需要动态调整并发数,可以在 Queue 中暴露 setConcurrency(n) 方法,更新上限后立即检查当前任务数是否小于新上限,如果小于则从等待队列中取出相应数量的任务执行。降低并发数时,只需设置新上限,不中断正在执行的任务,它们完成后自然按新上限运行。但注意如果新上限为 0,队列会暂停,需提供 resume 逻辑。
最后提醒一下,队列实现完成后,最好加上单元测试,覆盖正常执行、异常处理、并发上限、空队列等边界情况。这样下次遇到类似问题,可以直接复用,减少排查时间。