服务具备错误隔离、认证授权和输入边界以后,低流量下已经像一个可靠系统。但密码哈希、令牌校验、日志、数据库访问和业务计算都有真实成本;当并发继续上升,单个 Node.js 进程的事件循环、内存和连接池会依次暴露容量边界。
真正危险的是用整机平均指标掩盖单进程瓶颈,再用盲目扩容复制这个瓶颈。
那天的告警很有迷惑性:接口 p99 从 120 毫秒冲到 3.6 秒,网关开始出现 504,可监控里的整机 CPU 只有 18%。我盯着那条平缓的 CPU 曲线,第一反应是数据库慢了。查询耗时没有明显变化,连接池也没有耗尽;第二反应是网络抖动,结果同机房探测依然稳定。
真正刺眼的数据藏在进程维度:承接流量的 Node 进程恰好打满了一个 CPU 核,事件循环延迟已经超过一秒。一个报表接口在主线程里聚合大数组,随后又同步完成排序和 JSON.stringify()。只要它跑起来,同一进程上的健康检查、登录和普通查询都得排队。
我们当时做了一个很自然、也很危险的动作:先加进程。延迟短暂下降,几分钟后却变成八个进程一起繁忙。超时请求触发客户端重试,每个进程的任务队列持续增长;进程内缓存各存一份数据,数据库连接数和内存也随着进程数成倍增加。很快,几个进程先后超过内存限制,被拉起后又立刻接住积压流量,现场从“一个核很忙”升级成了重启风暴。
转折不是某个神奇参数,而是我们终于停止猜测,把问题拆成了四个问题:请求在哪里排队,主线程为何不能及时前进,内存为什么随并发上涨,多进程究竟放大了什么。下面就沿着这次排查继续走,直到把单进程优化、Worker、多进程和水平扩展接成一套可重复执行的方法。
事故发生后的第一张报表显示平均响应时间只有 190 毫秒,看起来谈不上严重。把数据按接口和实例拆开后,真相才露出来:大多数请求仍然很快,少数落到繁忙进程的请求却要等几秒。平均值把这批用户抹平了,p95 和 p99 才留下了排队的形状。
我后来固定同时看四组信号:

这四组信号还要带上路由、状态码、实例、版本等维度。标签并非越多越好,用户 ID、完整 URL 一类高基数字段会让指标系统本身先撑不住。请求 ID 更适合放进日志和追踪,再通过样本关联回指标。
压测也从这里开始,而不是先把并发数拉满。我会固定数据规模、机器规格、Node 版本和请求比例,完成预热后记录基线;每轮只改变一个变量,再比较相同持续时间里的 p95、p99、吞吐和错误率。登录、列表、下载、导出成本不同,混成一个总 QPS 几乎无法指导优化。测试流量必须进入隔离环境,或经过明确的容量保护,不能拿真实依赖做无边界试验。
为了验证“主线程被堵住”这个判断,我先接入了 Node 自带的 node:perf_hooks。monitorEventLoopDelay() 记录事件循环延迟直方图,数值单位是纳秒;performance.eventLoopUtilization() 返回一段时间内事件循环处于活跃状态的比例。再加上 process.cpuUsage() 和 process.memoryUsage(),不用复杂探针也能得到一组足以缩小范围的进程级信号。
下面这段 ESM 代码适合当最小起点。线上不应只写控制台,而应交给指标系统,并附上实例与版本信息。
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import process from "node:process";
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let previousElu = performance.eventLoopUtilization();
let previousCpu = process.cpuUsage();
const toMb = (bytes) => Number((bytes / 1024 / 1024).toFixed(1));
setInterval(() => {
const currentElu = performance.eventLoopUtilization();
const elu = performance.eventLoopUtilization(currentElu, previousElu);
previousElu = currentElu;
const currentCpu = process.cpuUsage();
const cpuDelta = {
user: currentCpu.user - previousCpu.user,
system: currentCpu.system - previousCpu.system,
};
previousCpu = currentCpu;
const memory = process.memoryUsage();
console.log(
JSON.stringify({
eventLoopUtilization: Number(elu.utilization.toFixed(3)),
eventLoopDelayP99Ms: Number((delay.percentile(99) / 1e6).toFixed(2)),
cpuUserMs: Number((cpuDelta.user / 1000).toFixed(1)),
cpuSystemMs: Number((cpuDelta.system / 1000).toFixed(1)),
rssMb: toMb(memory.rss),
heapUsedMb: toMb(memory.heapUsed),
externalMb: toMb(memory.external),
}),
);
delay.reset();
}, 5000).unref();事件循环利用率不是 CPU 利用率。它描述事件循环是否有机会空闲,JavaScript 计算、同步 API 和某些阻塞式原生调用都可能让它保持活跃;其中一些等待未必持续消耗 CPU。因此我从不靠单一阈值宣布根因,而是组合阅读:
一张能参与排障的图,至少应该回答:哪个版本的哪个实例,在什么负载下,让哪类请求发生了什么。只有全站平均响应时间的图,通常只能证明“多数时候没事”。
数据库查询发出后,Node 可以先处理其他连接,等结果就绪再执行回调。这也是它擅长承接大量 I/O 请求的原因。但回调中的 JavaScript 仍要回到所属线程执行。那次事故里,查询本身只用了 40 毫秒,查询后的数组聚合、排序和序列化却连续占据主线程 900 多毫秒。
这段时间里,定时器已经到点,网络数据已经抵达,新的请求也已经进入系统,可它们的回调都拿不到执行机会。所谓事件循环延迟,可以直观理解成“本该开始执行的工作,被前面的长任务额外拖了多久”。
“函数返回 Promise”也不等于“函数获得了一条新线程”。网络 I/O 通常由操作系统的事件通知机制协作完成,部分文件系统、DNS、加密和压缩任务会使用 libuv 线程池;Promise 的延续、定时器和绝大多数业务 JavaScript 最终还是要在事件循环线程上运行。把同步重活套进 async function,不会改变它阻塞主线程的事实。
单次 180 毫秒的同步计算,伤害的不只是发起计算的那个请求。180 毫秒内到达同一进程的健康检查、静态响应和其他接口都会多等一段时间。流量越高,受牵连的请求越多,所以平均值也许只涨一点,p99 却会突然形成尖峰。
我把排查固定成下面这条证据链:
node --cpu-prof app.mjs 采集 CPU profile,按 self time 和 total time 找热函数。事故中的关键证据就是 CPU profile:数据库客户端不在顶部,报表聚合和 JSON.stringify() 占据了大部分样本。我们先减少查询字段、把可预计算的数据提前聚合,并限制单次报表规模。剩余的纯计算再交给固定大小的 Worker 池。这个顺序很重要:并行化浪费,只会让浪费跑得更快。
如果计算能拆成小片段,片段之间偶尔使用 setImmediate() 可以把执行权还给事件循环,改善响应性。但它不会减少总计算量,也不会利用更多 CPU 核。持续且可独立的计算更适合 Worker;可以离线完成的工作则适合任务系统,而不是继续占据请求链路。
CPU 高只表示进程忙,不表示这些工作都有价值。我常见到的热点包括重复 JSON 序列化、对象反复复制、低效嵌套循环、日志格式化、模板渲染和业务规则里的组合计算。CPU profile 给出采样调用栈,比凭经验盯着某段代码可靠。
处理热函数时,我先问能不能不做:是否重复查询,是否多次序列化同一对象,是否可以分页,是否可以在写入时预聚合。然后才讨论算法和数据结构。把平方级查找换成索引通常远胜于语法微调。若时间花在外部 I/O,调查重点应转向查询计划、连接池等待、超时和批量策略,因为瓶颈可能根本不在 Node。
加进程后,我们看到 RSS 比 heapUsed 涨得更快。一开始有人据此断言是 V8 堆泄漏,但这两个数本来就不该相等:
heapUsed 是当前已使用的 V8 堆,普通 JavaScript 对象主要位于这里。heapTotal 是 V8 已经申请的堆空间,不代表其中每个字节都被存活对象占用。external 统计与 JavaScript 对象关联的 C++ 侧内存,Buffer 相关占用会反映在这里。arrayBuffers 统计 ArrayBuffer 与 SharedArrayBuffer,也覆盖 Node Buffer 使用的相应部分。rss 是进程驻留在物理内存中的总量,包含 V8 堆、代码、原生对象、线程栈等,通常显著大于 heapUsed。判断泄漏靠趋势,不能靠一个瞬时高值。进程预热时堆扩大很正常;在相似负载下,多轮 GC 后的基线仍不断抬升,同类请求结束后对象仍沿引用链存活,才值得沿泄漏方向深挖。无上限的 Map、未清理的定时器、捕获请求对象的长生命周期闭包,以及没有淘汰策略的本地缓存,都是我会优先检查的地方。
V8 能自动回收不可达对象,但回收需要 CPU,也可能让 JavaScript 暂停。大量短命对象会增加年轻代回收频率;长期存活的对象进入老生代后,后续回收通常更重。当分配速率过高时,CPU 和 p99 可能一起变坏,表面上很像单纯的计算热点。
在测试或已隔离的诊断进程中,可以这样观察 GC:
node --trace-gc app.mjs记录会显示回收类型、回收前后的堆大小与耗时。如果老生代经过多轮回收仍持续增长,我才会继续比较堆快照。生成堆快照会同步阻塞事件循环,还可能临时消耗接近当前堆大小两倍的内存;没有容量余量时,诊断动作本身就可能杀死进程。更稳妥的做法是先从负载均衡摘除一个副本,或在可复现场景中采集。
增大 --max-old-space-size
只能推迟撞上堆上限,不能修复泄漏。进程内存预算还必须给
Buffer、原生模块、线程栈和诊断操作留空间,否则容器可能在 V8
报告堆耗尽之前就终止进程。
那次重启风暴不只是 CPU 的问题。超时引发重试后,任务进入队列的速度超过了计算速度。队列没有上限,每个尚未完成的任务又保留着输入数据,于是内存跟着等待人数一起上涨。扩容虽然增加了消费者,也把无边界队列复制了更多份。
文件处理更容易看清同一个机制:读取端每秒能产出 100 MB,客户端每秒只能接收 5 MB。若代码不理会写入速度,未发送数据会持续堆在内存。最初的吞吐曲线看起来很好,随后 RSS、GC 和事件循环延迟一起恶化。
背压就是消费者明确告诉生产者“现在先别继续”。Node 可写流的内部缓冲达到 highWaterMark 或写入暂时繁忙时,.write() 会返回 false;生产者应暂停,等待 'drain' 再恢复。更常见的场景直接使用 pipeline(),让错误和背压沿流传播。

import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("access.log"),
createGzip(),
createWriteStream("access.log.gz"),
);必须直接写流时,要尊重返回值:
import { once } from "node:events";
export async function writeJsonLines(writable, rows) {
for await (const row of rows) {
const chunk = `${JSON.stringify(row)}\n`;
if (!writable.write(chunk)) {
await once
流只是背压最直观的实现。数据库连接池、任务队列、Worker 池和第三方 API 并发都需要同样的边界:最大并发、队列容量、排队超时和拒绝策略。系统过载时尽早返回 429 或 503,通常比收下所有请求、占住内存,最后让它们一起超时更可控。客户端若要重试,还必须使用退避、抖动和重试上限,避免把局部故障放大成重试风暴。
我们曾给报表结果加进程内缓存,单进程命中时效果很好。扩到八个进程后,每个 Map 都保存一份数据,同一个冷键可能同时回源八次;发布或重启又会让缓存集体变冷。这提醒我,缓存不是一行“加速开关”,而是一份带时效和一致性约束的新状态。
我会先回答四个问题:缓存键是否包含影响结果的全部条件,数据最多允许旧多久,数据变更时怎样失效,未命中时由谁回源。缓存适合读取频繁、计算昂贵且允许复用的结果,却不能代替数据库索引,也不该掩盖明显低效的查询。

大量键同一时刻过期,会形成集中回源;一个热门键过期后,大量请求同时回源,则会形成击穿。下面用 pending 合并单进程内同键的在途请求,并给 TTL 加少量随机抖动:
const cache = new Map();
const pending = new Map();
export async function getWithCache(key, load) {
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) {
return hit.value;
这段代码只展示请求合并机制。Map 仍需容量上限和淘汰策略;加载函数还应有超时,负结果是否缓存、回源失败时能否短暂使用旧值,则取决于业务语义。多进程之间不会共享 pending 和 cache。如果需要跨实例一致的缓存、失效和请求合并,应使用外部缓存或协调机制,同时继续给它设置超时、连接上限与降级路径。
报表聚合经过裁剪后仍有一段不可避免的纯计算。它与用户请求相互独立,又能清晰描述输入输出,于是我们把它放进 node:worker_threads。Worker 拥有自己的 V8 isolate 和事件循环,可以让 JavaScript 计算真正使用另一个 CPU 核;普通数据库查询已经能异步等待,为它再开 Worker 通常只会增加消息传递成本。
Worker 之间的普通 JavaScript 对象不会自动共享。消息默认经过结构化克隆;大块二进制数据可以转移 ArrayBuffer 的所有权,确有必要时也能使用 SharedArrayBuffer,但共享内存需要额外的同步协议,复杂度不会凭空消失。
下面让一个常驻 Worker 计算质数个数。质数只是载荷,关键在于主线程负责收发,CPU 循环在另一条线程执行。
prime-worker.mjs
import { parentPort } from "node:worker_threads";
function countPrimes(limit) {
let count = 0;
for (let number = 2; number <= limit; number += 1) {
let prime = true;
for (let factor = 2; factor * factor <=
cpu-worker-client.mjs
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./prime-worker.mjs", import.meta.url));
const pending = new Map();
let nextId = 1;
worker.on("message", ({ id, result, error }) =>
这个最小客户端没有实现队列限制、超时、崩溃后重建和动态伸缩,不能直接当生产级 Worker 池。真实服务不应为每个请求创建新 Worker:线程和 isolate 的创建成本不低,任务应进入固定大小的池,池前必须有有界队列。池大小可以从 os.availableParallelism() 返回的可用并行度开始试验,再结合单任务 CPU、内存和延迟目标调整,不能机械等于机器标称核心数。
事故现场最容易混淆的三个词是 Worker、多进程和多实例。它们都可能让机器使用更多核心,但状态边界、故障隔离和调度者完全不同。

选择顺序因此很清楚:若热点是可拆分的 CPU 计算,先考虑 Worker 池;若 Web 进程本身要利用单机多核,可以选 Cluster 或让外部管理多个进程;若目标是跨机器扩展和故障隔离,则需要独立实例与负载均衡。任何一种方案都不会自动修复慢查询、内存泄漏和无边界队列。
Cluster 是 Node 的稳定核心模块,适合由一个入口程序管理同端口多进程的场景。现代接口使用 cluster.isPrimary 和 os.availableParallelism():
import cluster from "node:cluster";
import { createServer } from "node:http";
import { availableParallelism } from "node:os";
import process from "node:process";
if (cluster.isPrimary) {
const workerCount = availableParallelism();
for (let index = 0; index < workerCount; index += 1) {
cluster.
这个示例只展示启动、共享端口和意外退出后的补位。生产中还要给连续崩溃设置退避与频率上限,否则 fork → 崩溃 → 立即 fork 本身就会制造重启风暴。每个 cluster worker 都有独立的堆、模块实例和事件循环;内存里的登录会话、限流计数和缓存不会自动同步。Cluster 也不会自动提供业务路由、可靠滚动发布或正确的 worker 数量,这些仍要由应用与运行环境决定。
在容器环境里,我更倾向于每个容器运行一个 Node 进程,再让编排系统复制、重启和滚动替换实例。这样健康检查、资源限制与日志归属更直观。单机进程也可以交给 systemd 或进程管理器。这里没有唯一答案,关键是只保留一层清晰的进程所有者。
不要让 Cluster 和外部进程管理器同时按 CPU 数复制,除非已经算清总进程数与内存预算。外层 8 个实例、每个实例再 fork 8 个 worker,会得到 64 个 V8 堆和 64 组连接池,而不是“更快的 8 核服务”。
那场事故里,进程退出后立即重启,看似增强了可用性,实际却把同一个确定性故障重复执行。可靠的外部管理至少要包含启动、异常退出后的退避、重启频率上限、日志接收、资源限制和停止信号。应用则负责报告状态、停止接流量并释放资源。
未捕获异常意味着进程可能已经处于未知状态。更安全的策略是同步留下必要的诊断信息,然后退出,让进程管理者按策略恢复;在 uncaughtException 后继续接请求,会把不可预测状态暴露给更多用户。不过,退出前临时追加复杂异步逻辑同样不可靠,真正的业务恢复应依赖幂等、事务和可重放任务。
健康检查还要分开回答两个问题:
进入多实例以后,指标、日志和追踪都应携带实例 ID、进程 ID、版本和请求 ID。否则所有副本只会汇成一团错误文字,无法回答长尾究竟来自哪一个进程。
当负载均衡把同一用户的连续请求分到不同副本时,结果仍应正确。做到这一点,扩容才是增加可替换的计算单元,而不是复制一组互相不知道对方状态的孤岛。

我会逐项盘点这些容易被藏进进程的状态:
数据库、共享缓存、对象存储和消息队列可以分别承接这些状态。目标不是堆叠基础设施,而是让每份状态都有明确的权威存储、生命周期和失败策略。允许短暂不一致的热点数据仍可保留小型进程内缓存,但必须有 TTL 与容量上限,并接受副本之间短时间不同步。
状态移出进程后,扩容应形成反馈回路:观察延迟、CPU、事件循环延迟和队列长度,增加副本,再验证下游是否仍有容量。只按 CPU 扩容会漏掉连接池饱和与 I/O 等待;只按请求数扩容,又会忽略接口成本差异。更可靠的策略通常至少组合一个资源或排队指标与一个服务质量指标。
我们增加 Node 进程时,数据库并没有跟着变大。每个副本若创建 20 条数据库连接,从 5 个扩到 50 个副本,理论连接数就从 100 增至 1000。缓存连接、外部 API 并发、消息消费者和 Worker 数量也会发生同样的乘法效应。
每次调整副本上限,我都会重新计算最坏预算:
总连接上限 = 实例上限 × 每实例连接池上限
总 Worker 数 = 实例上限 × 每实例 Worker 池大小
最坏内存 = 实例上限 × 单实例内存上限这些公式不是为了把所有资源刚好耗尽,而是为了在变更前暴露乘法效应。超过下游承受能力时,应降低单实例池大小、增加明确的排队与拒绝边界,或单独扩展真正的瓶颈层。否则扩容 Web 层只是更快地冲垮数据库。
进程数量稳定后,我们又在一次发布里看到少量连接被重置。原因很简单:旧实例收到 SIGTERM 就立即退出,正在处理的请求也随之中断。反过来,如果旧实例始终接受新流量,它又永远排不空。
我把退出过程设计成五步:

下面用 Node 核心模块实现一个最小版本。server.close() 停止接受新连接,并在现有请求处理结束后执行回调;兜底定时器防止异常连接让退出永久卡住。
import { createServer } from "node:http";
import process from "node:process";
let ready = true;
let shuttingDown = false;
const server = createServer(async (request, response) => {
if (request.url === "/live") {
response.writeHead(
真实服务还要单独处理 WebSocket、长轮询、消息消费者和超过宽限期的请求。就绪状态传到负载均衡也可能有延迟,运行环境应先停止路由流量,再发终止信号,并提供足够的终止宽限时间。发布成功不等于“新进程已经启动”,而是新实例已经就绪、旧实例已经排空,并且错误率与尾延迟没有恶化。
再遇到“服务变慢,扩容还没用”的场景,我不会先改进程数,而会按下面的顺序推进:
性能优化的核心不是让某个数字暂时更漂亮,而是建立一条闭环:测量真实等待,找到限制吞吐的边界,减少无效工作,再把计算和状态放到合适的位置。扩容只有在这个闭环之后才会成为容量工具;在此之前,它也可能只是把一个局部缺陷复制得更快。
当单个服务已经能测量、限流、并行计算并安全退出,扩展问题会继续向系统外移动:哪些能力仍该留在一个模块化应用里,哪些需要独立部署,网关和 BFF 应该承担什么,跨服务失败如何恢复,追踪上下文又怎样穿过消息队列。多进程只是把同一服务复制开,现代后端架构则要求这些工程约束在多个所有者之间仍然成立。