← 中文资源

简体中文版本

Node.js — 请求超时但 CPU 空闲:被阻塞的事件循环

找到阻塞 Node.js 事件循环的同步操作,用 monitorEventLoopDelay 与 blocked-at 计时定位,并把工作移出主循环。

阅读英文原文

症状

  指标里的悖论就是线索:请求超时,CPU 却近乎空闲,下游服务也报告没有压力。给事件循环本身装上仪表:monitorEventLoopDelay 以 20ms 分辨率持续采样,每 5 秒输出 p99 延迟。正常服务的循环 p99 以微秒计,事故期间会看到秒级。这一个数字就把“循环被阻塞”与“依赖慢”分开:慢数据库只拖慢相关路由,被阻塞的循环在同一瞬间拖慢所有请求。

诊断思路

  先量化循环延迟确认停顿属于循环本身;再用 blocked-at 以 200ms 阈值捕获超时执行的同步调用栈,直接指认代码行;最后为请求打端到端计时——当连 /health 这种平凡端点都出现同一停顿窗口,延迟归属于循环而非某个慢处理器。常见元凶:pbkdf2、zip 等同步加密压缩调用、300MB 级 JSON.parse、灾难性回溯的正则、百万行内存排序。CPU 低是关键特征:一次卡住的调用并不计算,它只是阻塞着让排队工作堆积。

根因

  一个长时间运行的同步操作占据了唯一的 JavaScript 线程。所有挂起的回调——包括健康检查与无关请求的处理器——都排在它后面。CPU 空闲是因为阻塞不是计算密集,而是纯粹的等待。

修复与验证

  按负载类型选逃生门:加密用异步变体(跑在线程池)、大 payload 用流式 JSON 解析、正则先做输入长度守卫、大集合分批处理并在批间 setImmediate 让出循环;真正计算密集的算法交给 worker_threads——主循环保持微秒级,池里并行计算。验证时重放触发停顿的负载,三项达标:循环 p99 低于预算(如 50ms)、健康检查全程不超时、总吞吐持平或更好(因为工作从串行变成重叠)。把 monitorEventLoopDelay 导出为常规指标,让这类回归在未来由循环仪表而不是客户超时来报警。

代码示例

import { monitorEventLoopDelay } from 'node:perf_hooks';

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
  console.log('loop p99 ms:', percentile(histogram, 99) / 1e6);
  histogram.reset();
}, 5000);
BLOCKED_AT_THRESHOLD_MS=200 node --require blocked-at/register server.js
// crypto: prefer the async variant — it runs on the threadpool
crypto.pbkdf2(password, salt, iters, 32, 'sha256', cb);

// big payloads: stream the parse instead of JSON.parse on 300 MB
await pipeline(fs.createReadStream(path), jsonParseStream(), sink);

// big collections: batch with setImmediate to yield between chunks
for (let i = 0; i < rows.length; i += 50_000) {
  processChunk(rows.slice(i, i + 50_000));
  await new Promise((r) => setImmediate(r));
}

// regex: guard input size before matching
if (input.length > 10_000) throw new Error('input too large for regex');