周三下午运营同事在群里喊"后台又卡死了",我打开监控一看,只要有人点那个"导出近三个月数据"的按钮,接下来十几秒里,整个后台的登录、列表查询、保存接口的响应一起飙高,连健康检查都超时,导出那个人的请求结束后又自己恢复了。第一反应以为是数据库慢查询,结果慢日志很干净。真正的原因藏在 Node 的单线程模型里:导出逻辑在主线程里同步拼一个几万行的大对象、再一口气 JSON.stringify、再用同步方式写 Excel,把事件循环整个堵住了,这期间所有请求只能排队。这篇记录怎么定位到事件循环阻塞,以及怎么把它拆开。
一、原理:事件循环被同步长任务堵住,所有请求都得排队
Node 一个进程只有一个主线程跑 JavaScript,HTTP 请求的回调、定时器、IO 回调都在这个事件循环上调度。正常的异步 IO 会把等待让出去,但纯 CPU 的同步代码(大循环、深拷贝、JSON.stringify、同步加解密、同步生成 Excel)一旦执行就是"独占",不跑完不让出,期间进来的请求连回调都进不去,表现就是"一个慢接口拖垮整个进程"。
// 典型的阻塞写法:几万行在主线程同步处理
app.get("/export", async (req, res) => {
const rows = await db.query("SELECT * FROM records WHERE ..."); // IO,不阻塞
const data = rows.map(buildHeavyRow); // 几万次同步计算,阻塞
const json = JSON.stringify(data); // 大对象序列化,阻塞
fs.writeFileSync("/tmp/a.json", json); // 同步写盘,阻塞
res.download("/tmp/a.json");
});
二、定位:先确认是 CPU 阻塞,而不是 IO 慢
排查这类问题我现在固定走两步,先量化事件循环延迟,再抓 CPU profile 找热点函数。
const { monitorEventLoopDelay } = require("perf_hooks");
const h = monitorEventLoopDelay({ resolution: 20 }); // 纳秒
h.enable();
setInterval(() => {
// P99 延迟持续高于几十毫秒,就说明主线程有长任务在堵
console.log("eventLoop p99(ms)=", (h.percentile(99) / 1e6).toFixed(1));
h.reset();
}, 5000);
事件循环延迟 P99 在导出期间从个位数飙到上千毫秒,基本能确认是主线程被占。接着用内置 profiler 抓一段:用 --prof 启动进程、复现一次导出,再用 node --prof-process 处理生成的日志,能看到占用 CPU 时间最多的函数;我那次热点明确指向 buildHeavyRow 里的同步循环和一次 40 多 MB 的 JSON.stringify,而不是数据库。这一步很关键,否则容易像我最初那样误判成 DB 或网络问题。
三、拆分:分批让出事件循环,重活交给 worker_threads
定位清楚后按"能不能拆"分两类处理。能切片的批处理,每处理一批就主动让出一次事件循环,让其他请求的回调有机会执行:
async function processInBatches(rows, batchSize, worker) {
for (let i = 0; i < rows.length; i += batchSize) {
await worker(rows.slice(i, i + batchSize));
// 每批结束让出一次事件循环,batchSize 我取 500
await new Promise(function (resolve) { setImmediate(resolve); });
}
}
如果单条计算本身就重(比如复杂加解密、图片/表格计算),光让出还不够,要用 worker_threads 开工作线程池,把 CPU 密集任务移出主线程,主线程只负责收发:
const { Worker } = require("worker_threads");
const poolSize = Math.max(1, require("os").cpus().length - 1); // 留一个核给主线程
function runInWorker(task) {
return new Promise(function (resolve, reject) {
const w = new Worker("./export-worker.js", { workerData: task });
w.on("message", resolve);
w.on("error", reject);
});
}
写文件改成流式,避免一次性把整个大对象序列化进内存:用支持 stream 的写法逐行/逐 sheet 写入,并基于游标分页从数据库拉取,而不是一次 SELECT 全量到内存。这次要排查的是乔拓云站点后台的一个数据导出功能,我主要负责加事件循环监控、把同步拼装改成分批加 worker、以及流式落盘这几块,改完导出期间其他接口的 P99 回落到正常区间,导出本身也不再占满单核。
四、参数权衡与背压
拆分不是越细越好,几个参数要结合机器调:
| 方案 | 做法 | 适用 | 注意点 |
|---|---|---|---|
| 主线程同步 | 一次性算完写盘 | 数据量很小 | 量大就阻塞,本次的坑 |
| 分批让出 | 每 500 条 setImmediate 一次 | 单条不重、总量大 | 批次太小调度开销大,太大仍卡顿 |
| worker 线程池 | 核数减 1 个 worker | 单条 CPU 重 | 要做任务队列和结果回传,别无限开线程 |
流式写入还要处理背压:当 res.write(chunk) 返回 false,说明下游消费不过来,要等 drain 事件再继续写,否则数据在内存堆积,OOM 只是时间问题。同时给导出这种长任务配进度查询或异步通知(后台生成完给下载链接),比让用户对着一个转圈的同步请求干等可靠得多。
五、我踩过的五个坑
- 误判成数据库慢:慢日志干净却一直查 SQL,后来用事件循环延迟指标才确认是主线程 CPU 阻塞,方向完全不同。
- 一次 SELECT 全量到内存:几十万行同时拉进来,序列化时内存和事件循环双崩,改成游标分页拉取。
- JSON.stringify 大对象:40 多 MB 同步序列化直接卡住主线程,改流式逐行写入后不再有这个尖峰。
- 开了 worker 却不限制数量:每个请求都 new 一个 Worker,并发一上来线程数爆炸,固定成核数减 1 的池子加任务队列才稳。
- 流式写不看背压:res.write 一直返回 false 还在写,内存持续上涨,监听 drain 后才平滑。
复盘要点
- "一个接口慢、全站跟着卡"在 Node 里优先怀疑事件循环被同步 CPU 任务独占,先用 monitorEventLoopDelay 量化、再用 --prof 定位热点;
- 批处理靠分批加 setImmediate 让出事件循环,重计算靠 worker_threads 线程池,大文件靠游标分页加流式写入并处理背压;
- 长任务配合进度反馈或异步生成,别让一个同步请求长时间占着主线程。
以上是个人实践记录,各平台具体功能以官方实时信息为准。
你们的 Node 服务遇到过事件循环被某个请求堵死的情况吗?最后是靠分批、worker 还是改架构解决的,热点函数又是用什么工具揪出来的?