关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

CPU 才用了两成接口却慢了十倍:Node 服务的瓶颈多半不在 CPU,在事件循环被堵住

发布时间:2026-10-10

监控上看 CPU 两成、内存平稳、连接数正常,P99 却从几十毫秒涨到几百毫秒。这种曲线最折磨人:所有「常规嫌疑犯」都干净,加内存没用,换更大的 CPU 也没用,甚至重启之后能好一阵,过几小时又回到原样。问题在于你盯的那几个指标,跟 Node 这台机器的真实吞吐机制根本不是一回事。Node 是单主线程跑事件循环的模型,它能不能快,取决于每一圈循环有没有被卡住,而不是取决于 CPU 被用了多少。CPU 利用率低恰恰是正常的——因为在被卡住的那一刻,主线程多半不是在算东西,而是在等。

第一,Node 的延迟由「事件循环延迟」决定,不由 CPU 利用率决定。CPU 两成而 P99 翻十倍,是这个执行模型的典型症状,不是监控失灵。

第二,阻塞只有三个来源。主线程上的同步活、libuv 线程池排满、GC 停顿。三者表现相似,但改法完全不同,先分清是哪一类再动手。

第三,线程池默认只有四个格子。异步的 fs、dns.lookup、异步 crypto、zlib 全挤在这四个格子里,排满之后连「看起来跟线程池没关系」的网络请求也会一起变慢。

第四,UV_THREADPOOL_SIZE 可以调,但它不是万能旋钮。没搞清楚是谁在排队就往上加,等于把压力转嫁给磁盘和上下文切换。

第五,Node 靠多进程扩展,不靠线程。这决定了选型要优先看单核性能和核数,内存需求反而不大,本地磁盘更不是重点。

故障现场:CPU 两成、内存平稳,慢在哪

先把现场描述完整一点,方便对号入座。一个跑在容器里的 Node 网关服务,上游接十来个内部服务,做一层聚合和鉴权,返回 JSON。平时 P99 在几十毫秒量级。某个时间开始,P99 抬到几百毫秒,P50 变化不大,错误率没有明显上升,超时告警零星触发。登上机器看:CPU 在两成上下,内存曲线平稳没有泄漏迹象,活跃连接数正常,网卡流量正常,下游服务自己的监控也是绿的。

这时候最容易被带偏的一步,是去怀疑「机器不够」。理由听起来很顺:慢了嘛,那就是算力不足,那就升配。但这里有个明显的逻辑漏洞——如果是算力不足,CPU 利用率应该是高的。两成的利用率意味着这颗 CPU 大部分时间是闲着的,它在等,不是在算。你给它换一颗更强的 CPU,它只会更闲地等。

另一种常见的误判是怀疑连接池或者下游。但下游监控是绿的,而且慢的是 P99 不是 P50——如果是下游整体变慢,应该是所有分位一起抬。P99 单独抬升,说明是「有一部分请求被什么东西挡了一下」,这个「什么东西」发生的时机不规则,只在特定条件下触发。这类不规则的长尾卡顿,在 Node 上基本就指向同一个地方:事件循环的某一圈被拖长了。

这里要给一句人话:所谓事件循环,就是 Node 主线程反复执行的一段「取任务—执行—取下一个任务」的流程。它一圈一圈地转,你的业务逻辑、回调、定时器,都是在这一圈圈里面被执行的。只要某一圈里的某一段代码跑得久了,后面排着队的所有回调就都得等它,不管 CPU 是不是还有余量。

先把模型摆清楚:一个事件循环,加一个有四个格子的线程池

要理解为什么 CPU 闲着还慢,得先把 Node 的执行模型摆清楚。整个模型可以拆成两半:一个单主线程的事件循环,加一个由 libuv 维护的线程池。

事件循环这一半,按阶段划分。官方文档里的划分是:timers(处理 setTimeout、setInterval 到期的回调)、pending callbacks(上一轮遗留的系统级回调)、idle 与 prepare(内部使用)、poll(轮询 IO 事件并执行对应回调,绝大多数网络回调在这一阶段被执行)、check(执行 setImmediate 的回调)、close callbacks(关闭事件,比如 socket 关闭)。一个「tick」或者说「一轮」,就是指这一整趟走完。实际运行时循环会在这些阶段之间流转,绝大多数业务代码落在 poll 和 check 这两个阶段里。

关键点在于:每一轮里执行的所有同步代码,都是串行排队的。你的业务逻辑函数、JSON 序列化、正则匹配,一旦开始执行就不会被打断,也不会「让」给别的请求。一个请求的函数体跑了 50 毫秒,这一轮就是 50 毫秒,期间新到的请求只能在队列里等着,哪怕 CPU 还有八个核空着。这是单线程模型的固有代价:它换来了没有锁、没有竞态、写起来简单,代价就是任何一段同步代码都能拖住所有人。

那异步 IO 呢?这也是最容易被误解的地方。很多人以为「异步」就等于「另一个线程在干」,对网络 IO 来说这个理解是错的。网络 IO 用的是操作系统提供的多路复用机制——Linux 上是 epoll,macOS 上是 kqueue,Windows 上走 IOCP。内核帮你盯着一堆 socket,哪个有数据了就通知你,主线程在 poll 阶段一次性把它们收上来。这整个过程不占用任何额外线程,这也是 Node 能用很少的内存扛住大量并发连接的原因。

但并不是所有异步 API 都这么好运。有些操作操作系统本身就没提供好用的异步接口——最典型的是文件系统调用,还有 getaddrinfo 这个域名解析函数。libuv 的处理办法很直接:既然内核不提供,那我自己在用户态开几个线程,把这些活扔进去,干完再通知主线程。这就是那个线程池。它的默认大小是 4,也就是说,同一时刻最多只有 4 个这类任务在真正执行,第 5 个开始就得排队。

记一下哪些 API 会落进这个池子:所有异步的 fs 操作、dns.lookup、crypto 的异步接口(pbkdf2、scrypt、randomBytes 这类)、zlib 的压缩与解压。不落进去的:网络 IO(http、net、tls 的收发)、dns.resolve 系列(它走的是 c-ares,自己用 UDP 发 DNS 查询包,本质是网络 IO)、以及 process.nextTick 和 Promise 微任务(它们在主线程上,属于微任务队列,反而可能把循环饿死)。

要盯的指标不是 CPU 利用率,是事件循环延迟

既然瓶颈是「某一圈被拖长了」,那要量的自然就是「每一圈比预期晚了多少」,这就是事件循环延迟,英文一般叫 event loop lag 或者 event loop delay。

它的定义很朴素:你让循环在「空闲时立即」执行一个回调,比如 setTimeout(fn, 0),理论上它应该马上执行;实际执行时间减去预期时间,就是延迟。延迟越大,说明循环被别的东西占着。这个指标跟 CPU 利用率是两码事——CPU 利用率衡量的是「CPU 有多忙」,事件循环延迟衡量的是「主线程有多久没腾出手来」,后者才跟你的 P99 直接相关。

观测手段上,Node 内置了 perf_hooks 模块,其中的 monitorEventLoopDelay 方法可以直接用。它会返回一个直方图对象,持续采样循环延迟并按分位统计,你可以定期读它的 mean、p99、max 之类的值上报到监控系统。注意这里是说这是一种可用的观测方法,不是某次实测的结论——具体数值长什么样,取决于你自己的业务和负载,别人的数字对你没有参考价值。

perf_hooks 里还有一个 eventLoopUtilization,它给出的是循环时间的占用比例,可以跟 monitorEventLoopDelay 互相印证。除此之外,社区里有现成的开源库做这件事,原理也都差不多,都是拿定时器打点算差值。

把这条指标接进监控之后,判读规则很清晰。如果延迟稳定在一个不高水平,但吞吐就是上不去,说明每个实例都在正常干活、没有谁在捣乱,纯粹是实例数不够,这时候加实例是有效的。如果延迟随着流量起来而抖动,出现尖刺,特别是 P99 延迟远远高于均值,那就是有同步阻塞在作祟——因为同步阻塞的特征正是「触发时才卡、不触发时没事」。这两种情况的处置方向完全相反,先分清再动手,能省掉一大笔冤枉的硬件钱。

第一类阻塞:主线程上那些「同步」的活

第一类,也是最常见的一类,是主线程上跑的同步工作。它们的共同特征是:排在事件循环的某一圈里,不可打断。排在它们后面的所有回调,都得等。

头号嫌疑是大 JSON 的解析与序列化。JSON.parse 和 JSON.stringify 是完全同步的,耗时跟数据大小基本成正比。网关类服务最容易踩:下游返回几兆的聚合结果,你在中间 parse 一遍、裁剪一遍、stringify 一遍,两次转换加上中间的对象操作,几十毫秒就没了。而且这类代码往往藏在框架里——某个中间件的日志打点,顺手把整个 request body stringify 了一遍,流量一大就现形。

第二个是回溯型正则。正则引擎在匹配失败时会尝试各种分支,某些写法下尝试次数随输入长度指数级增长,这就是所谓的灾难性回溯。一段平时 0.1 毫秒的正则,遇到特定构造的输入可能跑上几百毫秒甚至更久。危险的地方在于它跟输入内容强相关,输入来自用户,触发时机完全不可控,正好对上「P99 单独抬升、P50 没事」的曲线。路由匹配、参数校验、日志脱敏这些地方,都值得把正则翻出来审一遍。

第三个是同步加密。crypto 模块里带 Sync 后缀的那几个接口——pbkdf2Sync、scryptSync、randomBytes 的同步调用——会直接在主线程上做大量哈希迭代。密码校验是典型场景:每次登录都要跑一次,设计上就故意做得慢,一次几十毫秒是常态。这几十毫秒是全站共享的,登录请求一多,所有其他接口跟着一起卡。改法也直接:换成异步版本让它进线程池,或者更彻底一点,挪到独立的线程或进程里。

第四个是同步文件读写。readFileSync、writeFileSync、statSync,在请求路径上出现一次就是一次磁盘往返。平时磁盘快没事,赶上磁盘抖动或者宿主机 IO 争抢,几毫秒立刻变成几百毫秒,而且你完全无法预期。启动阶段读配置用同步是可以接受的(还没开始服务),请求路径上绝对不行。

第五类比较杂:大数组的排序与遍历、深拷贝、模板渲染、复杂对象的序列化。服务端渲染的场景尤其明显——模板引擎编译和渲染都是同步的,一个复杂页面的渲染吃掉几十毫秒很正常。这类活的特征是「单次耗时长但纯 CPU」,正好适合后面要说的 worker_threads 或独立进程方案。

第二类阻塞:线程池排满之后,连网络请求都跟着慢

第二类阻塞隐蔽得多,因为它看起来是「异步」的,很多人的直觉是不会有问题。问题恰恰出在这。

前面说了,线程池默认只有 4 个。这 4 个格子被 fs、dns.lookup、异步 crypto、zlib 共享。现在想象这个场景:你的服务每次请求都要往本地写一条日志(异步 fs),都要解析一次下游域名(dns.lookup),响应还要 gzip 一下(zlib)。单看每个操作都不慢,但它们的耗时是累加到那 4 个格子上的。

并发一起来,4 个格子瞬间占满,第 5 个任务开始排队。排队的特点是延迟会被放大:单次操作本身可能只有 2 毫秒,但前面排了 20 个任务,你实际等的就是 40 毫秒以上。更麻烦的是,这个池子是所有调用方共享的,写日志的活会把解析域名的活堵住,解析域名的活又会把读配置的活堵住,谁也别想跑。

最反直觉的一点来了:网络请求也会跟着慢,哪怕网络 IO 根本不走线程池。原因是这样——一个 HTTP 请求的处理流程通常是「收到请求 → 解析域名 → 发起下游调用 → 拿到结果 → 写日志 → 返回」。网络收发这一段确实走 epoll 不占线程池,但前后的解析域名和写日志占了。线程池一堵,整个流程的链条就断了,请求卡在「等 dns.lookup 的回调」这一步,从外部看就是一个慢请求。你去查网络、查下游,什么都查不出来。

这类问题的排查难点在于,监控上几乎不留痕迹。CPU 不高(活都在少数几个线程里),内存不高,连接数正常,唯一异常的是延迟。所以判断依据得靠间接信号:如果你观察到延迟抬升的同时,磁盘 IO 或者 DNS 查询量有明显的相关性,线程池的嫌疑就很大。

确认之后的处理顺序是这样的:先减少调用次数。日志能不能批量写、能不能降级;域名解析结果能不能缓存住(多数域名解析结果的 TTL 是分钟级的,缓存收益极大);下游连接能不能复用连接池避免反复解析;gzip 能不能挪到边缘层去做,让 Node 只管出原始响应。这些做完了还紧张,再考虑动线程池大小。

UV_THREADPOOL_SIZE 能调,但先搞清楚是谁在排队

UV_THREADPOOL_SIZE 是 libuv 提供的环境变量,用来设线程池大小,默认是 4,最大可以设置到比较大的数值。设置方式是在进程启动前把它设进环境变量——注意必须在进程启动的那一刻就设好,进程内部再改 process.env 是无效的,因为池子在首次使用时就已经按当时的值建好了。

但这是个有代价的旋钮,不是免费的加速器。代价有三个。

一是内存。每个线程都要占独立的栈空间,池子从 4 扩到 32,多出来的栈内存是实打实的。虽然单个线程开销不大,但如果一台机器上跑了十几个 Node 实例,每个实例都开一个大池子,总量就不容忽视了。

二是上下文切换。线程数超过 CPU 核数之后,多出来的线程并不能真正并行,反而要靠操作系统来回切换。切换本身是有开销的,池子开得过大,你会发现 CPU 的 sys 时间上去了,业务吞吐没涨甚至还降。

三是最容易被忽略的一条:池子大了,下游压力也跟着大。4 个格子意味着最多同时 4 个文件写、4 个压缩;你把池子开到 32,就变成同时 32 个。磁盘的 IOPS 是有上限的,压缩是实打实吃 CPU 的。池子调大把队列挪走了,压力全砸到磁盘和 CPU 上,延迟可能换了个形式又回来了。

所以判断规则应该是这样的:先识别是哪类操作进了线程池,再决定要不要调。如果是 fs 为主,先看看磁盘是不是已经接近瓶颈了——是的话调池子只会更糟。如果是 dns.lookup 为主,最优解通常是加缓存而不是加线程,因为解析结果本来就可以复用。如果是 crypto 或者 zlib 这种纯计算型的活,那它们天生就不该待在线程池里,应该走 worker_threads 或者独立进程,因为它们是 CPU 密集而非 IO 密集,用线程池这个「为 IO 等待设计的机制」去跑纯计算,本来就是错位。

真要调的话,业界常见的做法是把它调到跟 CPU 核数同一个量级,不要超出太多。这是一个经验起点,不是精确公式,具体多少还得看你自己的线程池占用构成。而且调之前一定要先把监控接上,否则你没法判断调了之后是变好还是变坏。

第三类阻塞:GC 停顿会直接吃掉主循环的时间

第三类经常被漏掉,但它造成的是同一种曲线:不规则的尖刺。

V8 的垃圾回收是分代的。新生代的对象活得短,回收频繁但极快,基本无感;老生代的对象活得久,回收不频繁,但一次完整回收要走标记、清理、整理这套流程,耗时跟堆里存活对象的数量正相关。老生代的回收会暂停主线程,这段时间事件循环是完全停转的——不是变慢,是停。

这就解释了为什么堆越大,单次停顿越明显。堆里塞了几百万个对象,一次 major GC 要遍历和整理的对象就多,停顿时间自然拉长。那些「重启之后好了几小时又复现」的现象,往往就是这个:刚启动堆是干净的,跑一段时间堆里堆积了大量对象,GC 频率和停顿时间一起上升。

常见的堆堆积来源有几种:缓存没有上限,把一整张表的数据塞进内存 Map;长生命周期的闭包持引用,导致本该回收的对象一直活着;大对象反复创建销毁产生大量碎片。这些在设计的时候看起来都无害,量上来之后一起爆发。

关于 --max-old-space-size 这个参数,要说清楚它的适用和误区。它设的是老生代堆的上限,默认值在不同 Node 版本和不同内存环境下不一样。调大它可以推迟「堆溢出」这个崩溃——这是它的正当用途。但调大堆不等于提升性能,反而可能让单次 GC 停顿更长,因为回收时要处理的对象总量上限被抬高了。把它当成性能优化手段是个典型误区。

真正的改法是减少堆里的对象量和对象存活时间:给缓存设容量上限和过期策略,别让对象无限堆积;及时释放引用,尤其是挂在大对象上的闭包;考虑把大缓存挪出进程,用外部缓存服务承担。如果确实需要更大的堆,那就同时接受更长的停顿,并把这个停顿计入你的 P99 预算——这是一个取舍,不是免费的。

怎么量:三个指标和一个最小观测集

讲完三类阻塞,落到可观测上。一个最小够用的观测集,三个指标就够了。

第一个是事件循环延迟。前面说过,用 perf_hooks 的 monitorEventLoopDelay 采样。要按分位看,不要只看均值——均值会把偶发的几百毫秒尖刺抹平,而那些尖刺正是 P99 的来源。建议至少看 p50、p99、max 三个值,采样间隔取秒级,上报到你的时序监控系统里做成曲线。告警阈值怎么定没有标准答案,做法是先跑一周看基线,再按基线的倍数设。

第二个是线程池排队情况。这一项 Node 没有开箱即用的官方接口,属于要自己动手的部分。可行的做法是统计各类线程池操作的并发数与耗时——比如在 fs、dns、zlib 的调用外面包一层,记录同时在飞的数量和每个调用的等待时长,看它是不是随延迟一起抬升。嫌麻烦的话,更简单的替代是看间接指标:磁盘 IO 等待时间、DNS 查询频率、压缩操作的耗时分布。关键是要能回答「池子里现在排的是 fs 还是 dns」这个问题,否则你连该优化谁都不知道。

第三个是活跃句柄数。句柄就是 socket、文件描述符、定时器这些还在使用的资源。活跃句柄数持续上涨且不回落,通常意味着泄漏——连接没关、定时器没清、监听器没摘。泄漏本身不直接造成卡顿,但它会让堆里的对象越堆越多,间接触发前面说的 GC 问题。这也是一个趋势指标,看的是曲线形状而不是绝对值。

把这三个指标摆在一起,判读就有了抓手:延迟高而线程池和句柄都正常 → 主线程同步阻塞;延迟高且线程池操作耗时同步抬升 → 线程池排队;延迟呈周期性尖刺且跟堆大小的锯齿曲线吻合 → GC 停顿。三条路,改法完全不同。

还有一点值得强调:这套观测要建在加机器之前。没有延迟指标就去扩容,你无法判断扩容到底有没有解决问题,只能用「感觉快了点」来验收,这是运维上最糟糕的状态。观测的成本远低于一轮盲目的硬件采购。

扩展靠多进程不靠线程:这决定了机器该怎么选

理解了执行模型,机器选型就不是一个「越大越好」的问题了。

核心事实是:Node 靠多进程扩展,不靠线程。单个 Node 进程只有一个主线程,一颗 CPU 核跑满就是它的上限,再多的核它也用不上。所以标准做法是跑多个进程实例,让操作系统把负载分摊到多颗核上。实现上有几种:Node 自带的 cluster 模块,主进程监听端口再把连接分发给子进程;pm2 这类进程管理器,一条命令就能按核数拉起一堆实例并负责守护重启;或者干脆在容器层面做,跑多个容器副本,由编排平台调度。

这里要分清 worker_threads 和 cluster 的分工,很多人会搞混。worker_threads 是用来做 CPU 密集计算的,它提供的是同一进程内的真线程,线程间可以通过转移 ArrayBuffer 共享内存而不用序列化的拷贝开销,适合把一段纯计算(大 JSON 处理、加密、图像处理)挪出去,避免占着主线程。它解决的不是并发量问题——你开了 8 个 worker,主线程还是那一个,网络请求的承接能力没有变化。cluster 才是用来做并发扩展的,每个子进程有自己独立的事件循环、独立的堆、独立的线程池,一个进程卡住不会影响其他进程。两者的定位完全不同,别拿 worker 当扩容手段用。

多实例跑起来之后,前面必须有一个分发层。cluster 模式下主进程自己做分发,pm2 和容器方案则通常需要独立的反向代理(nginx 或者同类组件)来做。这一层不只是负载均衡,它还承担 TLS 终结、静态资源、请求缓冲这些活,让 Node 进程专心处理动态逻辑。顺便说一句,把 gzip 放在反向代理这一层做,正好能替 Node 省掉一批 zlib 的线程池开销。

实例数怎么定?通常的思路是按核数来,留出余量给操作系统和其他进程。比如一颗 8 核的机器,跑 6 到 8 个 Node 实例是比较常见的安排,具体留多少余量要看机器上还有没有别的常驻进程。留出余量的原因很实际:GC 停顿、日志刷盘、监控采集这些都会占用 CPU,把核塞满之后,这些后台活会跟业务抢时间,反而制造出新的卡顿。另外也不要盲目追求「核数等于实例数」,先把观测建起来,看实例的 CPU 是不是真的吃满了再决定加。

内存怎么估?按单实例的堆上限乘以实例数来算。比如你给每个实例设了 1GB 的堆上限,跑 8 个实例,那就是 8GB 的堆需求,再加上每个进程自身的常驻开销和系统余量。这个数通常远小于同等规模 Java 服务的需求——Node 单实例堆普遍不大,这是它省钱的地方。也正因为如此,Node 服务在选型上内存往往是次要项,核数和单核性能才是主要项。

单核性能为什么重要?因为每个实例就是一个单线程程序,它的快慢直接取决于单颗核的执行速度。同样的核数,主频更高、架构更新的 CPU,每个实例的处理能力更强,整体吞吐就更高。所以比选机器时,应该把「单核性能」和「核数(决定能跑几个实例)」放在一起看,而不是只看核数或者只看内存容量。

本地磁盘反而不是重点。多数 Node 应用是无状态的,业务数据都在外部的数据库和缓存里,本地只需要装系统和写日志。除非你的应用确实有大量本地文件读写——那种情况下磁盘是线程池压力的来源之一,需要另外评估——否则标准的企业级 SSD 就够,不必在本地存储上花冤枉钱。

在服务商这边比选的时候,做法就很清楚了:按核数与单核性能去横向比较,看同样的预算能拿到几核、什么主频、什么代际的 CPU,内存按上面的公式估一个够用的量即可,磁盘按最低可用档配。像一万网络这样深耕 IDC 19 年(成立于 2007 年)的服务商,机型谱系通常覆盖从几核的轻量实例到大核数物理机的多个档位,可以按这个思路去横向比选;具体有哪些机型、当前什么价格,属于需要实时询价的内容,以咨询时的最新报价为准。

会阻塞事件循环的操作清单

把前面讲的三类阻塞落到具体操作上,整理成下面这张清单。排查时按图索骥,先看哪些出现在你的请求路径里,再逐个确认。

操作 跑在主线程还是线程池 典型触发场景 表现 改法
JSON.parse / JSON.stringify 大对象 主线程,完全同步 网关聚合大响应、中间件日志打点序列化整个请求体 耗时随数据量线性上升,期间全部请求排队 改流式解析、裁剪字段、或挪到独立线程与子进程
回溯型正则匹配 主线程,完全同步 路由匹配、参数校验、用户输入驱动的日志脱敏 平时极快,遇到特定构造的输入急剧放大,P99 单独抬升 重写正则消除歧义分支、限制输入长度、加匹配超时
crypto 同步接口(pbkdf2Sync 等) 主线程,完全同步 登录密码校验、请求签名与验签 单次几十毫秒级,登录高峰拖垮全部接口 换异步版本进线程池,更彻底的做法是挪到 worker_threads
fs 同步读写(readFileSync 等) 主线程,完全同步 请求路径上读模板、读配置、写临时文件 磁盘抖动时从毫秒级放大到百毫秒级,无法预期 一律改异步版本;启动期读配置用同步则可接受
异步 fs 操作 libuv 线程池 日志落盘、上传临时文件、读静态资源 池满后排队,延迟被前序任务数量放大,整体抬升 批量写、降级日志、减少调用次数,再考虑调池大小
dns.lookup libuv 线程池 每次请求都解析下游域名、未配连接池 解析变慢会占满池子,连网络请求一起变慢 缓存解析结果、复用连接池,或改用 dns.resolve 系列
crypto 异步接口(pbkdf2、scrypt 等) libuv 线程池 批量生成密钥、批量签名、大量随机数 纯计算占着格子不放,挤压同池的 IO 类操作 限流后挪到 worker_threads,别让计算占 IO 用的池
zlib 压缩与解压 异步版走线程池,同步版在主线程 响应 gzip、解压上游返回的大包 大载荷压缩占满池子,且吃 CPU 降压缩级别、把压缩挪到反向代理层做
大数组排序、深拷贝、模板渲染 主线程,完全同步 排行榜计算、服务端渲染、复杂对象转换 单次耗时长但纯 CPU,圈内耗时陡增 分页、缓存结果,或挪到 worker_threads 与独立进程
老生代垃圾回收 主线程,期间循环停转 堆大、长生命周期对象多、无上限缓存 周期性尖刺,与堆大小的锯齿曲线吻合 给缓存设上限与过期、及时释放引用、大缓存外置
网络 IO 收发(http、net、tls) 两者都不占,走内核多路复用 正常的请求收发、下游调用 本身不阻塞,但会被上面两类连带拖慢 无需改动,慢了要去查前后链条上的其他环节

避坑:Node 服务扩容最容易做错的四件事

第一坑:看到慢就升配,不看延迟指标。这是最贵的一坑。CPU 两成的时候升配,等于买了一颗更快的 CPU 让它更闲。为什么坑:延迟指标和 CPU 利用率在 Node 上是脱钩的,用后者做扩容依据本身就是错的方向。怎么避:先把 monitorEventLoopDelay 接进监控,看延迟是稳定还是抖动——稳定型才是真的实例不够,抖动型加了机器也没用。

第二坑:把 UV_THREADPOOL_SIZE 当成性能开关一把拉满。为什么坑:池子不是越大越好,超出核数之后多出来的线程靠上下文切换硬撑,而且下游磁盘和多出来的压缩计算会一起被压垮,延迟换个形式又回来了。怎么避:先查出是哪类操作在排队,能减少调用次数就先减少,确实要调就往核数这个量级调,并且调完立刻看延迟曲线有没有真的改善。

第三坑:拿 worker_threads 当扩容手段。为什么坑:worker 解决的是「一段纯计算占着主线程」的问题,它不增加请求承接能力——主线程还是那一个,开多少 worker 都不改变这一点。想扛更多并发得靠 cluster 或者多容器副本。怎么避:先判断你的瓶颈是「单次计算太久」还是「请求太多」,前者用 worker,后者用多进程,别搞反。

第四坑:堆不够就往上调 --max-old-space-size。为什么坑:这个参数设的是堆上限,调大只能推迟溢出崩溃,反而让单次 GC 要处理的对象更多、停顿更长。把它当性能优化手段是典型的方向性错误。怎么避:先查堆里为什么堆了这么多对象——多半是无上限缓存或者引用没释放;确实需要更大的堆,就同时把更长的停顿计入 P99 预算,当作一次明确的取舍。

Node 服务跑不动的时候,最该先查的几个问题

CPU 利用率不高,是不是就说明算力还有富余?不是。在 Node 上这两个指标基本脱钩。单进程只有一个主线程,一颗核跑满就是上限,剩下的核它碰不到;而主线程大部分时间可能是在等——等线程池的回调、等磁盘返回、等一次 GC 结束。等待期间 CPU 是闲的,但请求是卡的。所以判断有没有富余,要看事件循环延迟和单实例的主线程占用,而不是整机的 CPU 利用率。这也是为什么「CPU 两成却慢十倍」这种情况在 Node 上一点都不矛盾。

怎么快速判断是同步阻塞还是实例不够?看延迟曲线的形状。延迟稳定在不高水平、只是吞吐上不去,那多半是实例数不够——每个实例都在正常干活,只是活太多了,这种情况加实例直接有效。延迟随流量抖动、P99 远高于均值、出现不规则尖刺,那就是有同步阻塞:因为阻塞的特征就是触发时才卡、不触发时一切正常。区分这两者的前提是你得先把延迟指标采上去,没有曲线就只能靠猜。

线程池默认 4 个线程,是不是一定不够用?不一定。4 个格子对多数服务是够的,前提是落在池子里的操作短且少。真正出问题的是两种情形:一是调用次数太多,比如每个请求都写日志、都解析域名;二是单次耗时太长,比如压缩一个很大的响应体。所以先别急着调数值,先数一下请求路径上有几次 fs、几次 dns.lookup、几次 zlib,这个数比池子大小更能说明问题。多数情况下,加缓存、做批量、把压缩挪到反向代理,比调池子有效得多。

dns.lookup 和 dns.resolve 到底该用哪个?两者行为不一样。dns.lookup 走的是系统解析器的 getaddrinfo,受本机 hosts 和解析配置影响,跑在 libuv 线程池里;dns.resolve 系列走的是 c-ares,自己往外发 UDP 查询包,本质是网络 IO,不占线程池。所以从避免线程池排队的视角看,dns.resolve 更友好。但它不读本机 hosts,行为跟系统解析器不完全一致,切之前要确认你的部署环境不依赖 hosts 做解析。最好的方案通常是都不频繁调用——把解析结果缓存起来。

一个进程能扛多少并发,有没有经验数字?没有通用数字。这取决于每个请求在事件循环里占多少时间:如果单请求的主线程占用是 1 毫秒,理论上限就高;如果有 20 毫秒的同步处理,上限立刻掉下来。所以不要去找什么「Node 单进程能扛多少 QPS」的表格,那些数字换一个业务就完全不成立。正确的做法是测你自己的:采事件循环延迟和单请求耗时,看延迟开始抬升时的吞吐是多少,那个点就是你这台机器的实际容量。

多实例部署时,实例数是不是等于核数最好?通常要留余量。GC、日志刷盘、监控采集、反向代理这些都要吃 CPU,把核塞满之后它们会跟业务抢时间,反而制造出新的长尾卡顿。常见的安排是核数减去一到两个给系统,具体留多少看机器上还有没有别的常驻进程。更稳妥的做法是不要一步到位:先从保守的实例数起步,看着每个实例的 CPU 占用和延迟曲线往上加,加到延迟开始抖动就停。

把同步代码改成异步,是不是就一定能快?不一定,要看它落到哪里。改成异步 fs、异步 crypto,是把活从主线程挪到线程池——主线程确实解放了,但线程池只有 4 个格子,如果这类调用很密集,压力只是换了个地方,延迟照样抬。所以对密集调用的场景,异步化只是第一步,跟着还要做批量、缓存、限流。真正能彻底解决问题的,是把这类活挪到独立的线程或进程里,让它不占那 4 个格子。

选型上,内存和核数哪个更值得加钱?多数 Node 服务是核数优先。因为扩展靠多进程,核数直接决定能跑几个实例,加核等于直接加吞吐;而单实例堆普遍不大,内存按「堆上限乘实例数」估出来的量通常远小于同规模的其他语言服务。另外别忽略单核性能——每个实例都是单线程程序,主频更高、架构更新的 CPU 会让每个实例都更快。真要比选,把单核性能、核数、内存按这个优先级排,本地磁盘按够用配就行。

先量事件循环延迟,再谈加机器

回到开头那个现场:CPU 两成、内存平稳、P99 涨十倍。这篇文章想说的是,这台机器大概率没病,病在代码在事件循环里占了不该占的时间。我的立场很明确——遇到这种情况,第一步永远是接上事件循环延迟的观测,而不是提交一份扩容申请。加机器能解决的只有「实例不够」这一种情形,而它恰恰是三种情形里最容易被误判成另外两种的:延迟稳定才加机器,延迟抖动就去查同步阻塞,线程池排队就先削减调用次数,GC 尖刺就去管堆里的对象。这四条路走对了,多半一分钱硬件钱都不用多花;走错了,加再多的核也只是让 CPU 更闲地等着。真到了确实要扩容那一步,按核数和单核性能去比选、内存按堆上限乘实例数估算、磁盘按够用配,这个顺序别搞反——像一万网络这样深耕 IDC 19 年(成立于 2007 年)的服务商,机型档位覆盖得比较全,按这个思路去横向比选即可,具体机型与价格需实时询价,以签约时的最新报价为准。

本文关于事件循环与 libuv 线程池的技术出处

本文涉及的事件循环阶段划分、libuv 线程池默认大小与 UV_THREADPOOL_SIZE 环境变量的作用、以及各类 API 是否走线程池的说明,出自 Node.js 官方关于事件循环与 libuv 线程池的文档,以及 libuv 项目的线程池说明;perf_hooks 模块中 monitorEventLoopDelay 与 eventLoopUtilization 两个接口出自 Node.js 官方 API 文档;worker_threads 与 cluster 模块的定位说明出自 Node.js 官方对应文档;V8 分代垃圾回收与 --max-old-space-size 参数的说明出自 Node.js 与 V8 的官方资料。文中提及的观测方法与排查顺序属于通用工程实践,所述场景均为典型部署思路,并非特指某一真实客户案例。涉及机型、配置与价格的部分,需向服务商确认,具体以实时询价与签约时的最新报价为准。更多行业技术内容可参见 https://www.idc10000.net/ 。


上一篇:合同上写着 100M 实际只有 3MB/s:跨境链路跑不满带宽,先算这笔带宽时延积的账

下一篇:一个四百多万人口的国家为什么会出现在拉美节点清单里:巴拿马这页的规格形态和它的位置是配套的