一个导出请求把整站后台拖卡:Node 事件循环阻塞的定位与拆分记录

简介: 运营点导出近三个月数据后,整个 Node 后台十几秒内所有接口一起飙高、健康检查超时,DB 慢日志却很干净,根因是几万行同步拼装、大 JSON.stringify、同步写 Excel 堵住了事件循环。记录定位与拆分:用 perf_hooks monitorEventLoopDelay 量化 P99 延迟、--prof 抓 CPU 热点;分批每500条 setImmediate 让出、worker_threads 线程池(核数减1)跑重计算、游标分页加流式写入并处理背压,附方案对比表和五个踩坑。

周三下午运营同事在群里喊"后台又卡死了",我打开监控一看,只要有人点那个"导出近三个月数据"的按钮,接下来十几秒里,整个后台的登录、列表查询、保存接口的响应一起飙高,连健康检查都超时,导出那个人的请求结束后又自己恢复了。第一反应以为是数据库慢查询,结果慢日志很干净。真正的原因藏在 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 只是时间问题。同时给导出这种长任务配进度查询或异步通知(后台生成完给下载链接),比让用户对着一个转圈的同步请求干等可靠得多。

五、我踩过的五个坑

  1. 误判成数据库慢:慢日志干净却一直查 SQL,后来用事件循环延迟指标才确认是主线程 CPU 阻塞,方向完全不同。
  2. 一次 SELECT 全量到内存:几十万行同时拉进来,序列化时内存和事件循环双崩,改成游标分页拉取。
  3. JSON.stringify 大对象:40 多 MB 同步序列化直接卡住主线程,改流式逐行写入后不再有这个尖峰。
  4. 开了 worker 却不限制数量:每个请求都 new 一个 Worker,并发一上来线程数爆炸,固定成核数减 1 的池子加任务队列才稳。
  5. 流式写不看背压:res.write 一直返回 false 还在写,内存持续上涨,监听 drain 后才平滑。

复盘要点

  • "一个接口慢、全站跟着卡"在 Node 里优先怀疑事件循环被同步 CPU 任务独占,先用 monitorEventLoopDelay 量化、再用 --prof 定位热点;
  • 批处理靠分批加 setImmediate 让出事件循环,重计算靠 worker_threads 线程池,大文件靠游标分页加流式写入并处理背压;
  • 长任务配合进度反馈或异步生成,别让一个同步请求长时间占着主线程。

以上是个人实践记录,各平台具体功能以官方实时信息为准。

你们的 Node 服务遇到过事件循环被某个请求堵死的情况吗?最后是靠分批、worker 还是改架构解决的,热点函数又是用什么工具揪出来的?

相关文章
|
1月前
|
缓存 监控 搜索推荐
同样的问题别反复调模型:语义缓存的相似度阈值、分层命中与误判兜底
客服问答接口高峰期 P95 四五秒,翻日志发现六成提问是重复或近重复,而简单精确缓存换个说法就 miss。记录语义缓存完整做法:问题归一化加精确层(Redis、TTL 24h)、embedding 向量 topK 检索加余弦相似度阈值(用200对标注样本在0.85和0.95之间校准到0.92)、精确/语义/负缓存三层与 TTL 抖动,以及个性化强时效问题不缓存、点踩反馈收阈值等误命中兜底,附五个踩坑。
|
1月前
|
人工智能 自然语言处理 Java
【AI】Agent 专项进阶|RAG 混合检索策略
本文介绍混合检索在知识召回中的应用,结合向量检索(语义匹配)与BM25关键词检索(精确匹配),克服单一方法的局限。通过建立双索引、并行召回、统一排名(采用RRF融合)及可选精排,显著提升召回准确率。实战测试显示,混合检索在顶1、顶3命中率上优于单一方案,尤其擅长处理术语、编号等关键信息。代码实现基于LangChain,强调领域词典加载与分词优化,适用于垂直场景下的高质量问答系统构建。
221 0
|
Web App开发 JavaScript 前端开发
低代码系列——js沙箱设计(一)
由于自己参与过低代码平台开发,所以希望能把我自己开发低代码中遇到的问题或者一些设计思路进行总结汇总,这是开始写的第一篇,也是比较基础的一篇,关于低代码平台的介绍会放在介绍篇章,这篇就不做过多介绍。
816 0
|
23天前
|
存储 人工智能 弹性计算
阿里云免费中心指南:免费云服务器、AI模型、云数据库、云存储等免费攻略
阿里云免费中心提供超176款云产品新用户免费试用,涵盖ECS、OSS、RDS、函数计算、无影云电脑、PAI等,覆盖计算、存储、AI、安全等15大类。个人/企业新用户享300/660元额度,部分产品如OSS、CDE、PAI-DSW等也有专属免费资源。注意:仅限首次使用该产品的账号,停机不暂停计费,到期未释放将产生费用。(239字)
|
7月前
|
自然语言处理 PyTorch 算法框架/工具
大模型太慢?别急着上 GPU 堆钱:Python + ONNX Runtime 优化推理性能实战指南
大模型太慢?别急着上 GPU 堆钱:Python + ONNX Runtime 优化推理性能实战指南
1061 10
大模型太慢?别急着上 GPU 堆钱:Python + ONNX Runtime 优化推理性能实战指南
|
7月前
|
人工智能 关系型数据库 Serverless
方案实践 | Supabase +Qcoder一站式构建云上应用
基于阿里云PolarDB Supabase、Qoder与函数计算,一站式构建轻量级AI应用与SaaS平台,支持MVP快速验证,实现开发、部署、调试闭环,显著提升敏捷交付效率。
|
人工智能 自然语言处理 前端开发
让AI学会"边做边想":ReAct的实战指南
还在为AI的「知其然不知其所以然」而烦恼?ReAct技术让AI不仅会思考,更会行动!通过模拟人类的思考-行动-观察循环,让AI从书呆子变身为真正的问题解决专家。几行代码就能构建智能Agent,告别AI幻觉,拥抱可追溯的推理过程!
1361 7
|
JavaScript Java API
基于代理和单例模式的 electron 多窗口管理方案
基于代理和单例模式的 electron 多窗口管理方案
1396 0
|
编解码 并行计算 JavaScript
Nodejs 第十六章(ffmpeg)
Nodejs 第十六章(ffmpeg)
597 0