线上一个 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;
});
避坑清单
- 常驻 event loop delay 监控,p99 > 100ms 告警
- 避免 *Sync API:fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync 等
- 大对象 JSON.parse 用 stream 替代
- 正则用 re2,或者写之前过 safe-regex 检测
- CPU 密集计算用 worker_threads,不要在主线程跑
- 大数组操作分批 + setImmediate 让出
- libuv 线程池默认 4,IO 重的服务调到 8-16(UV_THREADPOOL_SIZE)
- 清理过期 Promise 链(没 await 的 Promise 不会被 GC,资源泄漏)
- error 路径也要测试,异常带来的同步操作往往最容易卡 event loop
- 线上 0x / clinic 慎用,会增加性能开销,跑完就关
总结
Node.js 单线程的好处是简单,坏处是任何同步阻塞都直接打死所有请求。这次排查从"延迟突发"到"event loop 被卡"再到"找到三个同步罪魁",走了完整的链路。最有效的工具是 monitorEventLoopDelay,装一次终生受益。日常开发养成习惯:写 JS 时问自己"这行会不会同步跑超过 10ms"。CPU 密集的活给 worker_threads,IO 密集的活让 libuv 跑。守住这两个边界,Node.js 服务 p99 跌不下来。
—— 别看了 · 2026