Node.js event loop 阻塞排查实录:p99 从 8s 降到 250ms

Node.js BFF p99 飙到 8 秒但 CPU 仅 30%,event loop max delay 2.4 秒。本文实录 5 大阻塞场景:JSON.parse 大对象 / 同步 crypto / 正则灾难 / fs.*Sync / 大数组操作。配合 perf_hooks / clinic.js / 0x / worker_threads 完整工具链。

线上一个 Node.js 后端 BFF 偶发响应延迟从 50ms 飙到 8 秒,QPS 高峰段尤其明显。日志看不出问题,CPU 占用也才 30%,但接口就是慢。最终定位是 event loop 被几个隐藏的同步操作阻塞,叠加 GC 抖动放大了延迟。本文实录排查全过程,讲透 Node.js 主线程阻塞的 5 大场景和工具链。

现象

监控发现:
- p50 延迟:50ms(正常)
- p99 延迟:8000ms(异常,平时 200ms)
- QPS:1500 → 3000 时延迟急剧恶化
- CPU:30% 左右,看起来不忙
- 内存:稳定 1.2GB(无泄漏)

接口:Node.js 18 + Fastify + 5 个下游 HTTP 调用
特征:延迟突发,不是慢慢涨

第一招:event loop 延迟监控

// 用 perf_hooks.monitorEventLoopDelay 实时采样
const { monitorEventLoopDelay } = require('node:perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();

setInterval(() => {
  const stats = {
    min_ms: (histogram.min / 1e6).toFixed(2),
    mean_ms: (histogram.mean / 1e6).toFixed(2),
    p50_ms: (histogram.percentile(50) / 1e6).toFixed(2),
    p99_ms: (histogram.percentile(99) / 1e6).toFixed(2),
    max_ms: (histogram.max / 1e6).toFixed(2)
  };
  console.log('event_loop_delay', stats);

  // 上报 Prometheus
  prom.gauge('event_loop_delay_p99_ms').set(parseFloat(stats.p99_ms));
  prom.gauge('event_loop_delay_max_ms').set(parseFloat(stats.max_ms));

  histogram.reset();
}, 10000);

// 实际输出:
// event_loop_delay { min_ms: '0.01', mean_ms: '1.20', p99_ms: '8.30', max_ms: '2400.00' }
// max 2400ms!! event loop 被卡了 2.4 秒

第二招:--inspect + clinic.js

# clinic doctor:总体诊断
$ npm install -g clinic
$ clinic doctor --on-port 'autocannon -d 30 -c 100 localhost:3000' -- node app.js

# 输出报告告诉你:
# - CPU 是否瓶颈
# - I/O 是否瓶颈
# - event loop 是否被阻塞
# - GC 是否压力过大

# clinic flame:火焰图(找 CPU 热点)
$ clinic flame -- node app.js
# 跑完打开 .html,鼠标悬停看每个函数耗时

# clinic bubbleprof:异步耗时
$ clinic bubbleprof -- node app.js
# 看每个异步操作的耗时分布

定位:5 个常见的阻塞场景

场景 1:同步 JSON.parse 大对象

// 错:同步 parse 几 MB 的 JSON
app.post('/import', async (req, reply) => {
  const data = JSON.parse(req.body);   // body 是 5MB 字符串
  // JSON.parse 同步阻塞,5MB 大概 80ms
  // 高 QPS 下连环阻塞,event loop 全卡
  return processData(data);
});

// 对:用流式 JSON 解析
const StreamArray = require('stream-json/streamers/StreamArray');
const { pipeline } = require('stream/promises');

app.post('/import', async (req, reply) => {
  const items = [];
  await pipeline(
    req.raw,
    StreamArray.withParser(),
    async function* (source) {
      for await (const { value } of source) {
        items.push(value);
        if (items.length >= 100) {
          await processBatch(items.splice(0));
          // 让出 event loop
          await new Promise(r => setImmediate(r));
        }
      }
    }
  );
  if (items.length) await processBatch(items);
  return { ok: true };
});

场景 2:同步 crypto 操作

// 错:同步 PBKDF2(密码哈希)
const crypto = require('node:crypto');

function hashPassword(password, salt) {
  return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512');
  // 100000 次迭代,同步耗时 ~150ms
  // 主线程被卡死
}

// 对:异步版本
async function hashPasswordAsync(password, salt) {
  return new Promise((resolve, reject) => {
    crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, key) => {
      if (err) reject(err);
      else resolve(key);
    });
  });
  // pbkdf2 异步在 libuv 线程池跑,主线程不阻塞
}

// 更进一步:bcrypt 也要用 async
const bcrypt = require('bcrypt');
// 错:bcrypt.hashSync(pwd, 12)   ← 阻塞 200ms+
// 对:await bcrypt.hash(pwd, 12) ← 线程池执行

场景 3:正则灾难(catastrophic backtracking)

// 错:嵌套量词的正则
const re = /^(a+)+$/;
re.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!');
// 这个简单字符串能让正则跑几秒到几分钟
// CPU 100%,event loop 完全卡死

// 我们生产遇到的真实案例:
const urlRe = /(https?:\/\/)?([\w-]+(\.[\w-]+)+)+(\/[\w-]+)*\/?/g;
// 用户输入 "wwww................................." 这种
// 这个正则跑几秒

// 排查:Node.js 22+ 的 --report-on-fatalerror 会输出
// 或者用 safe-regex 检测
const safe = require('safe-regex');
console.log(safe(urlRe));  // false

// 修复:
// 1. 用线性时间正则引擎(re2)
const RE2 = require('re2');
const re = new RE2(/(https?:\/\/)?([\w-]+(\.[\w-]+)+)+(\/[\w-]+)*\/?/);
// 2. 加超时(worker_thread 跑正则)
// 3. 输入长度限制 + 字符白名单

场景 4:同步 fs 操作

// 错:同步读文件
app.get('/config', (req, reply) => {
  const data = fs.readFileSync('/etc/app/config.json', 'utf8');
  return JSON.parse(data);
});
// 每次请求都读盘,IO 阻塞 event loop

// 对:启动时一次性读 + 监听变化
let cachedConfig = null;
fs.promises.readFile('/etc/app/config.json', 'utf8')
  .then(data => { cachedConfig = JSON.parse(data); });

fs.watch('/etc/app/config.json', async () => {
  const data = await fs.promises.readFile('/etc/app/config.json', 'utf8');
  cachedConfig = JSON.parse(data);
});

app.get('/config', (req, reply) => {
  return cachedConfig;
});

场景 5:大数组操作(map / filter / sort)

// 错:对 10w 元素数组同步 sort
function rankUsers(users) {
  // users.length = 100000
  return users.sort((a, b) => b.score - a.score).slice(0, 100);
  // sort 单次同步,O(n log n) 大概 50-80ms 阻塞
}

// 对:用 worker_threads
const { Worker } = require('node:worker_threads');

function rankUsersAsync(users) {
  return new Promise((resolve, reject) => {
    const w = new Worker('./sort-worker.js', { workerData: users });
    w.on('message', resolve);
    w.on('error', reject);
  });
}

// sort-worker.js
const { parentPort, workerData } = require('node:worker_threads');
const sorted = workerData.sort((a, b) => b.score - a.score).slice(0, 100);
parentPort.postMessage(sorted);

// 或者更简单:分批处理 + setImmediate 让出
async function sortInBatches(items) {
  const result = [];
  for (let i = 0; i < items.length; i += 1000) {
    result.push(...items.slice(i, i + 1000));
    await new Promise(r => setImmediate(r));   // 让出 event loop
  }
  return result.sort((a, b) => b.score - a.score);
}

定位工具链:逐步定位

# 1. 看 event loop delay(线上常驻)
$ curl -s localhost:3000/metrics | grep event_loop

# 2. 触发火焰图(线上短时)
$ npm install -g 0x
$ 0x -- node app.js
# 跑一会儿 Ctrl+C,自动生成火焰图

# 3. 看堆栈快照
$ kill -USR1 $(pidof node)         # 发送信号触发 inspector
$ chrome://inspect 抓 CPU profile

# 4. Node.js Diagnostic Report
$ node --report-on-fatalerror --report-on-signal app.js
$ kill -USR2 $(pidof node)         # 触发 report
# 生成 report-.json,看 javascriptStack

# 5. 用 async_hooks 追踪长任务
const { AsyncLocalStorage, createHook } = require('node:async_hooks');
const hook = createHook({
  before(asyncId) {
    this.start = process.hrtime.bigint();
  },
  after(asyncId) {
    const dur = process.hrtime.bigint() - this.start;
    if (dur > 100_000_000n) {  // > 100ms
      console.warn('slow_async', { asyncId, dur_ms: Number(dur)/1e6 });
    }
  }
});
hook.enable();

我们的真凶

复盘下来三个原因叠加:

1. 用户上传 5-10MB JSON 走同步 JSON.parse(80-200ms 阻塞)
   修复:streaming 解析,大对象分块

2. 一个老 module 用同步 crypto.pbkdf2Sync 校验签名(150ms)
   修复:换 async 版本

3. URL 校验正则有 catastrophic backtracking,某些恶意 URL 让正则跑 2-5 秒
   修复:换 re2 + 长度限制

修复后:
- p99 从 8000ms 降到 250ms
- event loop delay max 从 2400ms 降到 30ms
- 同样 QPS 下 CPU 反而下降(不再忙等)

线上常驻监控代码

// metrics.js
const promClient = require('prom-client');
const { monitorEventLoopDelay } = require('node:perf_hooks');

const elDelayP99 = new promClient.Gauge({
  name: 'nodejs_event_loop_delay_p99_seconds',
  help: 'Event loop delay p99 (seconds)'
});
const elDelayMax = new promClient.Gauge({
  name: 'nodejs_event_loop_delay_max_seconds',
  help: 'Event loop delay max (seconds)'
});

const hist = monitorEventLoopDelay({ resolution: 10 });
hist.enable();

setInterval(() => {
  elDelayP99.set(hist.percentile(99) / 1e9);
  elDelayMax.set(hist.max / 1e9);
  hist.reset();
}, 5000);

// GC 监控
const { PerformanceObserver } = require('node:perf_hooks');
const gcTotal = new promClient.Counter({
  name: 'nodejs_gc_duration_seconds_total',
  help: 'Total GC time',
  labelNames: ['kind']
});

const obs = new PerformanceObserver(items => {
  for (const entry of items.getEntries()) {
    const kind = ['minor', 'major', 'incremental', 'weakcb'][entry.detail.kind - 1] || 'unknown';
    gcTotal.inc({ kind }, entry.duration / 1000);
  }
});
obs.observe({ entryTypes: ['gc'], buffered: true });

告警规则

# Prometheus alert
- alert: NodejsEventLoopDelayHigh
  expr: nodejs_event_loop_delay_p99_seconds > 0.1
  for: 1m
  labels: { severity: warning }
  annotations:
    summary: 'Node.js event loop delay p99 > 100ms'

- alert: NodejsEventLoopBlocked
  expr: nodejs_event_loop_delay_max_seconds > 1
  for: 30s
  labels: { severity: critical }
  annotations:
    summary: 'Node.js event loop blocked > 1s'

用 worker_threads 卸载 CPU 密集任务

// pool.js:worker pool 模式
const { Worker } = require('node:worker_threads');
const os = require('node:os');

class WorkerPool {
  constructor(workerScript, size = os.cpus().length) {
    this.workers = [];
    this.queue = [];
    for (let i = 0; i < size; i++) {
      const w = new Worker(workerScript);
      this.workers.push({ worker: w, busy: false });
    }
  }

  runTask(data) {
    return new Promise((resolve, reject) => {
      const idle = this.workers.find(x => !x.busy);
      if (idle) {
        idle.busy = true;
        idle.worker.once('message', result => {
          idle.busy = false;
          resolve(result);
          this.drain();
        });
        idle.worker.once('error', reject);
        idle.worker.postMessage(data);
      } else {
        this.queue.push({ data, resolve, reject });
      }
    });
  }

  drain() {
    if (!this.queue.length) return;
    const idle = this.workers.find(x => !x.busy);
    if (!idle) return;
    const { data, resolve, reject } = this.queue.shift();
    idle.busy = true;
    idle.worker.once('message', result => {
      idle.busy = false;
      resolve(result);
      this.drain();
    });
    idle.worker.once('error', reject);
    idle.worker.postMessage(data);
  }
}

module.exports = WorkerPool;

// 用法:
const pool = new WorkerPool('./compute-worker.js', 4);
app.post('/heavy', async (req, reply) => {
  const result = await pool.runTask(req.body);
  return result;
});

避坑清单

  1. 常驻 event loop delay 监控,p99 > 100ms 告警
  2. 避免 *Sync API:fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync 等
  3. 大对象 JSON.parse 用 stream 替代
  4. 正则用 re2,或者写之前过 safe-regex 检测
  5. CPU 密集计算用 worker_threads,不要在主线程跑
  6. 大数组操作分批 + setImmediate 让出
  7. libuv 线程池默认 4,IO 重的服务调到 8-16(UV_THREADPOOL_SIZE)
  8. 清理过期 Promise 链(没 await 的 Promise 不会被 GC,资源泄漏)
  9. error 路径也要测试,异常带来的同步操作往往最容易卡 event loop
  10. 线上 0x / clinic 慎用,会增加性能开销,跑完就关

总结

Node.js 单线程的好处是简单,坏处是任何同步阻塞都直接打死所有请求。这次排查从"延迟突发"到"event loop 被卡"再到"找到三个同步罪魁",走了完整的链路。最有效的工具是 monitorEventLoopDelay,装一次终生受益。日常开发养成习惯:写 JS 时问自己"这行会不会同步跑超过 10ms"。CPU 密集的活给 worker_threads,IO 密集的活让 libuv 跑。守住这两个边界,Node.js 服务 p99 跌不下来。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Spring Boot 3 升级两个月实录:javax→jakarta + 30 万行代码 + 40 个微服务

2026-5-19 12:02:40

技术教程

Prometheus 高基数治理实战:1850 万 series 砍到 180 万

2026-5-19 12:06:43

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索