请求集体卡顿、单核打满:Node.js 事件循环阻塞避坑

一个平时快得没存在感的 Node.js 服务,偶尔会"集体卡顿"——几乎所有接口在同一瞬间一起超时几秒,然后又一起恢复,像什么都没发生。病来得没规律,日志翻不出半个 ERROR,内存也稳,唯一可疑的是卡顿那几秒总有一个 CPU 核心瞬间被打满到 100%,其它核都闲着。这个"单核打满、其它核闲置"的画面,正是 Node 单线程特性在喊话:某个请求里藏着一坨不让出的同步重活,把唯一的那根线程牢牢占住,让排在后面的所有请求一起干等。这篇文章从这次事故彻底复盘:事件循环与宏/微任务模型、请求路径里的同步重活、Promise executor 同步执行的真相、分片 + setImmediate 让出、worker_threads 处理重 CPU、带 Sync 的 API 陷阱、日志与序列化的隐形卡点,以及用 event loop lag 探针主动预警。

那是一个看起来岁月静好的 Node.js 服务,平时响应快得像没有存在感。可偶尔,它会"集体卡顿"——监控上一片飘红,几乎所有接口在同一瞬间一起超时,持续几秒钟,然后又一起恢复正常,像什么都没发生过。最折磨人的是这病来得没规律:可能一小时一次,也可能一天都安稳。日志里翻不出半个 ERROR,内存稳得很,唯一可疑的是——卡顿那几秒,有一个 CPU 核心会瞬间被打满到 100%,而其它核心都在闲着晒太阳

"单核打满、其它核闲置"——这个画面盯久了,我后背有点发凉。因为它几乎是在向我喊话:你的活儿全挤在一根线程上跑呢。是的,Node 那个最著名、也最容易被当耳旁风的特性,这次结结实实给我上了一课:它是单线程的。JavaScript 代码跑在一个线程上,任何一段没让出控制权的同步代码,都会把这唯一的线程牢牢占住,让排在后面的所有请求干等着。那几秒的"集体卡顿",就是某个请求里藏着的一坨同步重活,把整个服务按在地上摩擦的几秒。

这篇文章,是我把那次"单核打满、全员卡死"的事故复盘之后,整理出的一份 Node.js 事件循环避坑指南。它不堆术语,只讲清楚一件事:在单线程的世界里,你写的每一行同步代码,花的都是所有人共用的那点时间——花超了,大家一起卡。

先掰扯几个关于 Node 的常见误解

动手之前,先把几个我曾经深信、后来被这次卡顿狠狠纠正的误解摆出来。如果你也这么以为,这篇大概率能帮你提前避开那个会"卡死全场"的坑。

常见误解 真相
Node 能扛高并发,肯定是多线程的 你的 JS 代码跑在单线程上;高并发靠的是异步 I/O,不是多线程并行算
只要用了异步/回调,就不会阻塞 异步的是 I/O;一段纯 CPU 的同步计算(大循环、大 JSON.parse)照样霸占线程
CPU 没满,就不是性能问题 单核打满时,整体 CPU 占用可能才 20%,但事件循环已经被卡死了
setTimeout(fn, 0) 会立刻执行 fn 它只是把 fn 丢进宏任务队列,得等当前同步代码全部跑完才轮到它
Promise 里的代码是"并行"跑的 Promise 不开新线程;它只是把回调排进微任务队列,仍在同一根线程上排队执行
同步 API 用着方便,问题不大 fs.readFileSync / 同步加密这类操作,在请求路径里就是一颗"卡全场"的雷

第一件事:先看懂那根唯一的线程在忙什么

要理解"一个请求为什么能卡死所有请求",得先看清 Node 的运行模型。它的核心是一根单线程,上面跑着一个永不停歇的事件循环(event loop)。你可以把它想象成一个只有一个窗口的银行柜台:所有顾客(任务)排成队,柜员(线程)一次只能服务一个,handle 完这个才叫下一个。

这个"队"其实分两种。一种是宏任务队列(macrotask:定时器、I/O 回调、setImmediate 等),一种是微任务队列(microtask:Promise 的 then/catch、queueMicrotask)。规则很关键:柜员每处理完一个宏任务,会先把当前积压的微任务全部清空,再去叫下一个宏任务。而无论哪种任务,只要它内部是一段不让出的同步代码,柜员就得死等它跑完——这期间,后面排再长的队都一动不动。我那次的"卡顿几秒",就是有个顾客(请求)往柜台上拍了一摞要当场算完的活,柜员埋头算的那几秒里,整条队列彻底冻住。

看懂这张图,事故的本质就清楚了:Node 的"高并发",建立在每个任务都快进快出、frequent 地把线程让出来的前提上。一旦某个任务赖在调用栈上做长时间的同步计算,这套精巧的轮转就当场停摆——不是它变慢了,是它被一个任务劫持了那几秒单核打满,就是劫持正在发生的铁证。接下来要做的,就是把这些"会劫持线程的同步重活"一个个揪出来,或干掉、或拆开、或请出主线程。

第二件事:揪出请求路径里那坨"埋头猛算"的同步代码

顺着"单核打满"这条线索,我把那个偶发卡顿的接口扒了出来。它要对一批数据做个聚合,代码大致是一个嵌套循环加一堆字符串拼接——平时数据量小,几毫秒就跑完,神不知鬼不觉;可一旦赶上某个大客户的数据,这个同步循环要跑好几秒。就这几秒,它独占了那根唯一的线程,把同时进来的所有请求全摁住了。

// ❌ 反例:请求路径里一段纯 CPU 的同步重活,数据一大就卡死全场
app.get('/report', (req, res) => {
  const rows = loadRows(req.query.id)   // 假设拿到几十万行
  let result = ''
  for (const r of rows) {               // 这个同步循环可能跑好几秒
    result += heavyTransform(r)          // 期间事件循环完全停摆
  }
  res.send(result)                       // 算完才响应,别的请求一直在等
})

// ✅ 思路:CPU 密集的活,要么拆片让出、要么丢出主线程(见后文)
//        请求路径里,绝不写"会跑很久且不让出"的同步代码

这里最坑的一点是:它在小数据量下完全正常,测试和 demo 都发现不了,只有生产环境撞上大数据才暴雷。所以"会不会阻塞"不能只看代码长相,得看它最坏情况下要连续算多久。一个经验阈值:任何一段同步代码,如果在最坏输入下可能跑超过几十毫秒,就该警惕了——因为这几十毫秒是从所有并发请求的响应时间里硬生生抠出来的。除了大循环,常见的"隐形重活"还有:对大对象的 JSON.parse / JSON.stringify、同步的加解密(如 crypto.pbkdf2Sync)、大数组的 sort/map 链式调用。它们都长着一副"人畜无害的同步函数"的脸。

第三件事:搞懂"为什么我以为异步了,其实没让出"

排查中我一度困惑:不是包了 Promise 吗,怎么还卡?后来才想明白——把同步代码塞进 Promise,它依然是同步执行的;Promise 只改变了"结果什么时候被消费",没改变"计算什么时候发生"。这块的执行顺序是 Node 面试常考、也最容易把人绕晕的地方,用一段代码讲最清楚。

console.log('1 同步')

setTimeout(() => console.log('2 宏任务 setTimeout'), 0)

Promise.resolve().then(() => console.log('3 微任务 then'))

// ⚠️ 注意:这个 Promise 的 executor 是【同步】跑的!
new Promise((resolve) => {
  console.log('4 这行同步执行,根本没让出')   // 包在 Promise 里也照样卡
  resolve()
}).then(() => console.log('5 微任务 then'))

console.log('6 同步')

// 实际输出顺序:
// 1 同步  →  4 这行同步执行  →  6 同步      (先跑完所有同步代码)
// 3 微任务 →  5 微任务                        (再清空微任务队列)
// 2 宏任务                                    (最后才轮到宏任务)

看清这个顺序,两个关键认知就立住了:其一,new Promise(executor) 里的 executor 是同步跑的,你以为"包进 Promise 就异步了",其实那段计算还在死死占着线程;其二,setTimeout(fn, 0) 永远排在所有微任务后面,它不是"立刻",而是"等当前这一轮同步代码和微任务全清完之后"。真正能"让出线程"的,是把重活切开放进不同的宏任务轮次里,让事件循环在每两段之间有机会去 handle 别人的请求——这正是下一节要做的事。

第四件事:把大循环切成小片,让事件循环喘口气

对那个聚合接口,我的第一刀是分片处理。核心思路是:别让一个同步循环一口气跑完几十万行,而是每处理一小批就主动让出一次——通过 setImmediate 把"下一批"丢进下一个宏任务轮次。这样事件循环就能在每两批之间挤进去 handle 别的请求,卡顿从"几秒一动不动"变成"几乎无感的细碎让步"。

// ✅ 把大循环切片,每批之间用 setImmediate 让出线程
function processInChunks(rows, chunkSize = 1000) {
  return new Promise((resolve) => {
    let i = 0
    let result = ''
    function next() {
      const end = Math.min(i + chunkSize, rows.length)
      for (; i < end; i++) {
        result += heavyTransform(rows[i])   // 一次只算一小批
      }
      if (i < rows.length) {
        setImmediate(next)   // 关键:让出线程,下一轮再继续算下一批
      } else {
        resolve(result)
      }
    }
    next()
  })
}

app.get('/report', async (req, res) => {
  const rows = loadRows(req.query.id)
  const result = await processInChunks(rows)   // 期间别的请求能正常被处理
  res.send(result)
})

分片的代价是单个请求的总耗时会略微变长(多了让出和恢复的开销),但换来的是整个服务不再因为它而集体卡死——这笔账在多数线上场景里都划算。要注意 chunkSize 的取舍:太小,让出太频繁、开销大;太大,每批又会占住线程偏久。我的经验是从 1000 起步,压测时盯着"最长一次同步耗时"调,把它压到 50ms 以内就比较安全了。

第五件事:真·CPU 密集的活,请它离开主线程

分片解决的是"能拆开的活"。但有些计算天生拆不动,或者本身就极重(图像处理、大规模加解密、复杂数学运算)——这种就别在主线程上较劲了,直接丢给 worker_threads,让它在另一个线程上跑,主线程只管收结果。这才是 Node 处理 CPU 密集任务的正解。

// worker.js —— 在独立线程里干重活
const { parentPort, workerData } = require('worker_threads')
const result = doHeavyCompute(workerData)   // 随便它算多久,卡的是这个线程
parentPort.postMessage(result)

// main.js —— 主线程只负责派活和收结果,自己不卡
const { Worker } = require('worker_threads')
function runHeavy(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', { workerData: data })
    worker.on('message', resolve)
    worker.on('error', reject)
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`worker 退出码 ${code}`))
    })
  })
}

app.get('/heavy', async (req, res) => {
  const result = await runHeavy(req.query)   // 主线程在这 await,完全不阻塞
  res.send(result)
})

线程不是越多越好,频繁创建销毁 worker 本身也有开销,生产里通常配一个worker 线程池(如 piscina 库)复用线程。一个判断标准:能用分片让出搞定的轻中度计算,优先分片;只有那种"拆也拆不动、跑就是慢"的重活,才值得请出主线程交给 worker。别一上来就 worker 化,那是用复杂度换性能,得用在刀刃上。

第六件事:那些名字带 Sync 的"温柔陷阱"

复盘时我特意全局搜了一遍 Sync,结果在好几个不起眼的角落都揪出了同步 API。它们用起来确实顺手——不用 await、不用回调,一行搞定——但每一个都是在请求路径里同步阻塞的雷。Node 标准库里凡是带 Sync 后缀的,几乎都是"会卡住事件循环"的版本。

// ❌ 反例:请求路径里用同步 API,文件一大 / 调用一频繁就卡全场
const fs = require('fs')
app.get('/config', (req, res) => {
  const raw = fs.readFileSync('./big-config.json', 'utf8')  // 同步读盘,阻塞
  const cfg = JSON.parse(raw)                                // 大对象解析,也阻塞
  res.json(cfg)
})

// ✅ 正例:改用异步 API,I/O 期间线程让出去服务别人
const fsp = require('fs/promises')
app.get('/config', async (req, res) => {
  const raw = await fsp.readFile('./big-config.json', 'utf8') // 不阻塞
  res.json(JSON.parse(raw))
})

当然,同步 API 不是一律禁用——在服务启动阶段(还没开始接请求时)读一次配置文件,用 readFileSync 反而更简洁,因为那时卡谁也卡不到。关键是分清场景:"请求路径"(会被高频并发触发的代码)里坚决不用 Sync;"一次性初始化"里则无所谓。把这条刻进肌肉记忆,以后看到请求 handler 里冒出个 Sync,手就会先停一下。

一张图收束:服务卡顿了,顺着它往下查

把这次排查的思路串成一条决策路径,下次再遇到"请求集体卡顿、单核打满",照着走一遍,基本不会跑偏。

这套打法的内核就一句话:先找出"是谁在长时间霸占主线程",再按它的性质对症下药——I/O 改异步、计算能拆就拆、拆不动就请出主线程。万变不离其宗,都是为了让那根唯一的线程别在任何一个任务上停留太久

七条铁律,直接抄进你的 code review 清单

最后把这次事故沉淀成七条可以直接执行的铁律。它们不深奥,但每一条背后都对应着一次"单核打满、全员卡死"的可能——抄下来贴在 review 模板里,比读十篇原理文章都管用。

  1. 牢记 JS 是单线程的:你写的每行同步代码,花的都是所有请求共用的那点时间。
  2. 请求路径里不写长时间同步计算:最坏输入下可能超过几十毫秒的,就要拆或挪。
  3. 大循环分片 + setImmediate 让出:别让一个循环一口气跑到底。
  4. 拆不动的重 CPU 计算交给 worker_threads:用线程池复用,别一上来就 worker 化。
  5. 请求路径里禁用带 Sync 的 API:readFileSync 等只许在启动初始化阶段用。
  6. 别误以为"包进 Promise"就异步了:executor 是同步跑的,异步的是结果消费的时机。
  7. 给事件循环装监控:盯住 event loop lag,延迟一上来就告警,别等用户投诉。

三种"解卡"手段,到底该选哪个

分片让出、worker 线程、异步 I/O——这三招我那次都用上了,但它们各有各的地盘,用错了要么白费力气,要么徒增复杂度。复盘时我把选型逻辑列成了一张表,贴出来供你对号入座。

手段 最适合的场景 代价 / 注意点
改异步 I/O(去掉 Sync) 瓶颈是读文件、网络、数据库这类等待型操作 几乎零代价,优先选;I/O 期间线程本就该让出去
分片 + setImmediate 能拆开的中等 CPU 计算(遍历、聚合、拼接) 单请求总耗时略增;要调好 chunkSize,把每片压到 50ms 内
worker_threads 拆不动的重 CPU 活(加解密、图像、复杂算法) 有线程开销和数据拷贝成本;最好配线程池,别滥用
外置 / 异步队列 耗时极长、不需即时返回的任务(报表、批处理) 架构变重;但能把重活彻底移出请求生命周期

一个朴素的优先级:先问"这是 I/O 还是 CPU"——是 I/O,改异步基本就解决了,这是性价比最高的一刀;是 CPU,再问"拆得动吗"——拆得动就分片,拆不动才上 worker;要是这活儿压根不需要让用户当场等结果,那最优解是把它丢进异步队列、让后台慢慢消化,请求秒回。我那个聚合接口最终就是"分片 + 大报表走队列"两招并用,卡顿才算彻底根治。

顺手装一个"事件循环延迟"探针

修完之后我最后悔的一件事是:这个卡顿其实早有征兆,只是我一直没有一个直接盯着"事件循环健不健康"的指标。后来补上了一个极简的探针——本质就是定时器的"应到时间"和"实到时间"之差,这个差(event loop lag)一旦变大,就说明线程被什么东西占住了。

// 极简事件循环延迟探针:每秒测一次"定时器迟到了多久"
let last = Date.now()
setInterval(() => {
  const now = Date.now()
  const lag = now - last - 1000   // 本该 1000ms 后触发,多出来的就是 lag
  last = now
  if (lag > 100) {                // 迟到超过 100ms,说明事件循环被卡过
    console.warn(`[event-loop-lag] 延迟 ${lag}ms,有同步重活在霸占主线程`)
  }
}, 1000)

这几行代码上线后,那种"偶发、无规律、难复现"的卡顿,第一次有了肉眼可见的抓手:lag 一飙高,日志立刻就喊,我顺着时间点去对请求,真凶很快现形。生产环境里更推荐用 Node 自带的 perf_hooks.monitorEventLoopDelay 做更精确的统计,接到监控面板上设个告警阈值。对单线程的 Node 服务来说,event loop lag 几乎是比 CPU、内存更该被盯住的头号健康指标——它直接回答了那个最要命的问题:我那根唯一的线程,现在还转得动吗?

一个被忽略的"卡点":日志和序列化

根治了聚合接口之后,我又揪出一个没人留意的卡点——日志。当时为了排错方便,有个中间件会把整个请求和响应体 JSON.stringify 出来打进日志。平时无所谓,可一旦响应体是个几 MB 的大对象,这个 stringify 本身就是一段不小的同步计算,而且它每个请求都跑、跑在请求路径正中央。等于我为了观测性能,亲手埋了个拖慢性能的雷。

解决也不复杂:大对象别整个序列化进日志,只记关键字段和体积摘要;真要全量留存,异步地写、或采样地写。这件事给我提了个醒——"同步重活"常常不只藏在业务逻辑里,也藏在那些你以为无害的"辅助代码"中:日志、序列化、深拷贝(JSON.parse(JSON.stringify(obj)) 这种)、模板渲染。它们单看都人畜无害,可只要长在请求路径上、又赶上数据变大,就会变成压垮事件循环的又一根稻草。复盘时不妨把请求路径从头到尾捋一遍,问每一行:你在最坏情况下,要占用主线程多久?

写在最后

这次事故让我对 Node 的"单线程"四个字,有了和读文档时完全不同的体感。以前我把它当成一句要背的八股,现在我知道,它意味着我写的每一段同步代码,都不是"我一个人的事"——它花掉的是所有并发请求共用的、唯一的那点线程时间。一个在小数据下毫秒级返回的循环,撞上大数据就能让整个服务集体卡死几秒;一个图省事的 readFileSync,藏在请求路径里就是一颗等着大文件来引爆的雷。

所以现在写 Node,我多了一根弦:每写一段计算,先掂量它"最坏要连续跑多久";每用一个 API,先瞄一眼它是不是带 Sync;每上一个新接口,先想清楚里头有没有可能霸占主线程的活。这些念头谈不上高深,却把"事件循环"这件事,从"出事后救火"前移到了"写代码时设防"。而当监控再次出现单核打满时,我也不再两眼一抹黑——event loop lag 的探针会替我指出是哪一刻、哪个请求,把线程攥在了手里。从"对着飘红的监控干瞪眼"到"顺着延迟指标三步定位",这大概就是这场卡顿留给我最值钱的东西。

如果你也维护着一个 Node 服务,却从没量过它的 event loop lag,不妨今天就把那几行探针贴进去看一眼。要是那个数字时不时往上蹿,别等用户投诉——回头照着这篇里的四种手段,把那些霸占主线程的同步重活一个个请走。单线程的世界很公平:你对它温柔,它就让所有请求都跑得飞快。

回过头看,这场卡顿教会我的,其实是一种"为单线程着想"的写法直觉:写每一段代码时,脑子里都站着一排正在排队、等着被服务的其他请求。你多占线程一毫秒,它们就多等一毫秒。当你真正把这排队的人放在心上,那些会卡死全场的同步重活,在落键的那一刻就会被你拦下——根本轮不到它在某个深夜,把整个服务和你的睡眠一起拖下水。

单线程不是 Node 的软肋,而是它的契约:你守住"每个任务都快进快出"这一条,它就还你极致的并发吞吐;你哪怕只破例一次、让某段重活赖在线程上,代价就是所有人陪它一起卡。把这份契约刻进每天的编码习惯里,远比事后救火来得从容。

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

GC 语言也会内存泄漏:Python 服务被 OOM 反复杀死的排查

2026-5-29 23:01:02

技术教程

进程偶发猝死、recover 拦不住:Go 并发读写 map 避坑

2026-5-29 23:13:22

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