一个接口在本地压测跑到两千 QPS,部署到线上第三天凌晨被告警叫醒:进程内存从 200MB 一路爬到 1.4GB,被系统干掉后自动重启,如此循环。翻代码翻了两个小时,最后发现问题出在一个几乎没人会注意的地方——日志中间件每次请求都往一个全局数组里 push 一条记录,想着"反正后面会清理",结果从来没清理过。这就是 Node.js 项目最典型的落差:写起来太快了,快到让人忘记它背后有一整套必须理解的运行模型。
Node.js 把 JavaScript 从浏览器里解放出来,用非阻塞 I/O 加事件循环这套组合,让单线程也能扛住大量并发连接。它适合 I/O 密集的服务端场景,也适合做命令行工具、构建脚本、实时通信后端。这篇内容适合三类人:刚能写出一个 HTTP 服务、想知道下一步该学什么的新手;写了半年、遇到内存和性能问题不知道怎么下手的中级开发者;以及需要给团队定规范、做技术选型的人。我不打算按"安装—语法—框架—部署"这种目录式顺序讲,而是围绕那些真正会在项目里咬你一口的地方展开:事件循环到底怎么走、模块系统混用会出什么事、异步错误在哪几个位置被悄悄吞掉、流和背压为什么能救命、以及上线前我自己固定会过一遍的那份清单。
1. Node.js 的能力边界:什么场景该上,什么场景别硬上
1.1 一行代码起服务背后,被隐藏的那套模型
很多人对 Node.js 的第一印象是"轻"。几行代码就能起一个 HTTP 服务,不用配置容器,不用写 XML,改完文件刷新就能看到效果。这种手感确实容易让人产生一种错觉,以为它就是个"简单版的服务器语言"。但真正决定一个 Node.js 服务能不能扛住的,从来不是语法,而是它底层的运行模型:单线程执行 JavaScript,I/O 操作交给系统内核异步完成,完成后通过事件循环回调回来。
这个模型的关键在于"等"这件事被外包出去了。传统的一请求一线程模型里,一个线程去读数据库,它就得在那儿干等,等待期间这个线程什么也干不了,只能占着内存。Node.js 的做法是发起读取之后立刻返回,线程继续处理别的请求,等数据回来了再排队执行回调。所以同样一台机器,Node.js 能用更少的内存挂住更多的并发连接。
但代价也很明确:所有 JavaScript 代码都在一个线程上跑。任何一个耗时的同步计算——一次大的 JSON 序列化、一次复杂的正则匹配、一次循环几十万次的加密运算——都会把整个事件循环堵死,期间所有请求全部排队。这就是为什么很多团队在 Node.js 里写了个 CPU 密集的任务,然后发现"并发一高就卡",其实不是并发的问题,是那一段代码把整个进程卡住了。
所以判断一个场景适不适合 Node.js,核心就一句话:你的瓶颈在等待上,还是在计算上。等待多就合适,计算多就得想办法把计算挪走。
1.2 四类场景的适配判断
我在做技术选型的时候习惯把需求先归类,下面这张表是我自己用了很久的判断依据,覆盖了大部分实际会遇到的情况。
| 场景类型 | 典型例子 | 适配程度 | 关键理由 |
|---|---|---|---|
| I/O 密集、高并发连接 | 网关转发、API 聚合层、实时消息推送 | 非常适合 | 连接多但每个连接的计算量极小,单线程的事件循环正好发挥优势 |
| 前后端同栈的普通业务服务 | 中小型后台、内容管理、表单类接口 | 适合 | 开发效率高,生态成熟,团队不用维护两套语言栈 |
| CPU 密集计算 | 图像处理、大文件压缩、复杂报表计算 | 需要改造 | 必须拆到 worker_threads 或独立服务,否则主线程被堵 |
| 强事务、长事务型业务 | 复杂账务核对、大批量数据迁移 | 谨慎 | 这类场景的瓶颈常在数据库侧和事务锁上,Node.js 的优势用不上 |
表格里"需要改造"那一栏值得多说两句。CPU 密集不是不能用 Node.js,而是不能放在主线程用。我见过一个项目做图片缩略图生成,最初直接在请求处理函数里同步调用图像库,结果单机并发超过 20 就开始出现几秒的响应延迟。后来改成把任务丢进 worker_threads 线程池,主线程只负责接收和返回,同样的机器稳定跑到 200 并发以上。改造本身不复杂,难的是意识到"这里有个坑"。
还有一个常被忽略的边界是长连接的内存占用。每个 TCP 连接在 Node.js 里都是一个对象,有它自己的缓冲区和状态。一万个空闲连接听起来不多,但如果每个连接都挂着一个没清理的定时器或者一份会话数据,内存就会被放大十倍。这也是为什么做推送类服务时,连接的生命周期管理和心跳机制必须提前设计,而不是等出问题再补。
1.3 版本选择这件事,别追新也别恋旧
Node.js 的版本迭代节奏很快,奇数版本是短期试验版,偶数版本才是长期维护版,这个规则很多人知道但不当回事。我踩过一次坑:某个项目图省事装了当时最新的奇数版本,结果三个月后这个版本停止维护,一个底层依赖需要打安全补丁,只能整体升级运行时,连带把几个原生模块全部重编译,折腾了整整两天。
实际的做法应该是:生产环境只跟偶数版本,并且固定在某个小版本号上,用版本管理工具把团队的运行时统一起来,同时把版本号写进项目配置里,让新同事拉下代码就知道该用哪个版本。升级不要跟风,等社区反馈稳定了再动。至于要不要用最新特性,比如顶层 await、新的文件系统 API,判断标准很简单——团队里所有人都能看懂,且不依赖实验性开关。
2. 事件循环的六个阶段:一次 tick 里到底发生了什么
2.1 阶段顺序与 poll 阶段的阻塞行为
事件循环是 Node.js 里被讲得最多、也错得最多的部分。网上大量文章画一张循环图就完事,但你真拿它去解释代码行为时会发现对不上。我的建议是记住六个阶段的顺序和每个阶段干什么,剩下的靠实测验证。
按官方文档,一次循环依次经过:timers、pending callbacks、idle/prepare、poll、check、close callbacks。每个阶段有自己的回调队列,执行完当前阶段的队列(有上限)才进入下一阶段。
- timers:执行到期的
setTimeout和setInterval回调。注意这里判断的是"是否到期",不是"精确在几毫秒后执行",所以setTimeout(fn, 0)实际可能延后几毫秒甚至更久。 - pending callbacks:处理上一轮被推迟的系统级回调,比如某些 TCP 错误。
- idle/prepare:内部使用,业务代码基本碰不到。
- poll:核心阶段。获取新的 I/O 事件,执行 I/O 相关回调。如果这里队列空了,它会阻塞等待,直到有新事件或者被其他机制打断。
- check:执行
setImmediate的回调。 - close callbacks:执行
close事件回调,比如 socket 关闭。
poll 阶段的阻塞行为是最需要理解的:它不是一个"检查一下就往下走"的环节,而是一个"没活干就睡在这儿"的环节。这正是 Node.js 空闲时不占 CPU 的原因,也是为什么一个被setImmediate打断的循环能正常工作。
有个经典例子可以自己跑一下验证:
const fs = require('fs'); setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); fs.readFile(__filename, () => { setTimeout(() => console.log('timeout-in-io'), 0); setImmediate(() => console.log('immediate-in-io')); });在主模块顶层,timeout和immediate的先后顺序是不确定的,取决于进程启动时消耗了多少时间。但在readFile的回调里,immediate-in-io一定先于timeout-in-io。原因是 I/O 回调在 poll 阶段执行,执行完直接进入 check 阶段,而 timers 阶段要等到下一轮循环。这个结论我建议每个人自己跑十次,跑完你对"阶段"的理解就落地了。
2.2 nextTick 与 Promise 微任务,优先级不一样
六个阶段之外还有两个队列:process.nextTick队列和 Promise 微任务队列。它们不属于任何阶段,而是在每个阶段结束后、进入下一阶段前被清空。
关键在于:process.nextTick的优先级高于Promise 微任务。也就是说每一轮清空时,先把这个 tick 里累积的 nextTick 回调全部执行完,再去执行 Promise 的 then 回调。
Promise.resolve().then(() => console.log('promise')); process.nextTick(() => console.log('nextTick')); // 输出顺序:nextTick 在前,promise 在后这个差异在实际项目里的影响比想象中大。比如你在一个循环里递归调用process.nextTick,它会饿死 I/O——因为 nextTick 队列不执行完,事件循环根本进不了下一个阶段,定时器和网络回调全都被卡住。我在早期写过一段遍历目录树的代码,用 nextTick 做递归调度,想着"让它异步一点别阻塞",结果在大目录下直接把进程卡死了几十秒。正确的做法是用setImmediate把任务切到 check 阶段,让出执行权给 I/O。
递归调度任务优先用 setImmediate,不要用 process.nextTick。前者会让出事件循环,后者不会。
2.3 三个被广泛传错的结论
这里我把几个流传很广但经不起验证的说法列一下,省得你被误导。
第一个是"Node.js 是单线程的"。准确说法是:JavaScript 执行是单线程的,但 Node.js 进程里有 libuv 的线程池,文件系统操作、DNS 解析、部分加密运算都在线程池里跑。理解这一点很重要,因为线程池默认只有四个线程,如果你同时发起大量文件读取,超出部分会排队。有些团队遇到"并发读文件变慢",以为是事件循环的问题,实际是线程池被打满了,调整环境变量UV_THREADPOOL_SIZE就能缓解。
第二个是"setTimeout(fn, 0) 就是立即执行"。它只是"尽快执行",最小延迟受系统时钟精度限制,实际常常是 1 毫秒以上,而且必须在下一轮 timers 阶段才会被处理。
第三个是"async 函数不会阻塞"。async 只是写法上的语法糖,函数体里的同步代码照样占着主线程。一个async function里写了个十万次的循环,它阻塞的程度和普通函数一模一样。
3. 模块系统:require 与 import 混用时的真实麻烦
3.1 CommonJS 的缓存与循环依赖到底返回什么
CommonJS 的require有两个特性必须搞清楚:缓存和循环依赖时的部分导出。
第一次require一个模块时,Node.js 会执行它,然后把module.exports的当前值缓存起来,键是解析后的绝对路径。之后所有require都直接返回缓存。这意味着模块代码只执行一次,副作用只发生一次。很多人用这个特性做单例,比如数据库连接池,确实可行,但要注意缓存是基于路径的——同一份代码从不同路径引用(比如符号链接导致的路径差异),会被当成两个模块,各执行一次。
循环依赖的情况更微妙。假设 A 依赖 B,B 又依赖 A。加载 A 时执行到 require B,此时 A 的 exports 还是个空对象,B 拿到这个"半成品"继续执行,如果 B 在顶层就用 A 导出的函数,拿到的会是 undefined。等 B 执行完,A 继续往下把真正的导出赋上去。所以循环依赖在 CommonJS 里能跑,但拿到的值可能是残缺的。
这种情况在实际项目里非常常见,典型的形态是:工具模块引用了配置模块,配置模块又引用了工具模块做校验。表现是"启动时报某个函数不是函数",但翻代码看导出明明是对的。排查的思路就是去找这条环,把其中一边改成运行时再引用,或者把公共部分抽到第三个模块里。
3.2 ESM 引入后必须改掉的几个习惯
ESM 是官方标准模块格式,语法上是import和export。它和 CommonJS 的差异不只是写法,加载机制完全不同:ESM 在解析阶段就确定依赖关系,导出是绑定而不是值拷贝,所以循环依赖时能拿到最终值(但在初始化前访问会报暂时性死区错误)。
从 CommonJS 迁到 ESM,最容易卡住的是下面这几处,每一个我都实际遇到过:
| 差异点 | CommonJS 写法 | ESM 对应做法 |
|---|---|---|
| 目录路径变量 | 直接用__dirname、__filename | 需要用import.meta.url转换 |
| 动态导入 | require(变量)随处可用 | import()返回 Promise,且路径解析规则不同 |
| 导出方式 | module.exports = {...}可随时改 | export必须在顶层,导出是静态绑定 |
| JSON 文件 | require('./a.json')直接可用 | 需要导入断言或者读文件解析 |
| 顶层 await | 不支持 | 支持,但有执行顺序上的副作用 |
__dirname这一条几乎人人会踩。ESM 里没有这个变量,需要这样写:
import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const configPath = join(__dirname, 'config', 'app.json');顶层 await 也要谨慎。它能用,但一旦某个模块里用了顶层 await,所有依赖它的模块都得等它完成才能开始执行,启动时间会被拉长,而且依赖关系一旦出问题,报错信息会非常绕。
3.3 依赖体积和版本锁定的治理
项目做到一定规模,依赖管理的问题就会浮出来。我见过一个服务,直接依赖只有十几个包,实际安装后node_modules里躺着一千多个包,容器镜像光是依赖就占了六百多兆。启动慢、构建慢、安全扫描天天报高危。
治理的核心是两件事:看清谁带来的、锁住不变。
看清谁带来的,可以用分析命令去查某个包的依赖链,找出是哪个上层依赖把一堆用不上的东西拖进来了。常见的情况是:为了一个小功能引了个大而全的库,而那个小功能自己写只要三十行。这种做法在项目早期很方便,后期就是负担。
锁住版本则依赖 lock 文件。这个文件必须提交到代码仓库,它记录了整棵依赖树的精确版本和完整性校验值。部署时用基于 lock 文件的安装方式,而不是重新解析版本范围,否则今天装的和明天装的可能是两套东西。这一点在团队协作里尤其重要——"我本地没问题"这句话,十次里有八次是依赖版本不一致导致的。
4. 异步错误处理:异常真正被吞掉的那几个位置
4.1 三代写法的错误传播差异
Node.js 的异步写法经历了回调、Promise、async/await 三代演进。每一代的错误传播机制都不一样,而混用的时候最容易出问题。
回调时代的约定是"错误优先":回调的第一个参数是错误对象,如果非空就说明出错了。这个约定要求每个回调都显式检查错误,忘了检查,错误就永远消失了——不会抛,不会打日志,业务逻辑静静地走错分支。
fs.readFile('data.json', 'utf8', (err, content) => { if (err) { console.error('读取失败', err); return; // 这里必须 return,否则会继续往下执行 } console.log(content); });上面这个return是我 review 代码时重点看的地方。忘写 return 导致"出错了还继续处理空数据",是回调时代最高频的 bug 之一。
Promise 时代,错误通过reject传播,.catch()接住。但有个陷阱:如果创建了一个 Promise 却没有写 catch,或者在一个 async 函数里await了一个 Promise 但没有 try/catch 包裹,错误会变成一个"未处理的拒绝"。在老版本里它只是打印一条警告,进程继续跑;新版本默认行为已经改成让进程退出。这个变化让很多老代码在升级运行时后突然崩掉。
async/await 本质上是 Promise 的语法糖,try/catch能捕获await表达式抛出的错误,但捕获不到那些没被 await 的异步操作里的错误:
async function handler() { try { // 下面这行没有 await,错误不会被 catch 到 doSomethingAsync().then((r) => save(r)); const data = await fetchData(); return data; } catch (e) { console.error(e); // 捕获不到 doSomethingAsync 的错误 } }这种写法在代码里极难被发现,因为语法上完全合法。我的做法是:只要调用返回 Promise 的函数,就必须 await 或者显式挂 catch,团队里把这条写进规范,代码审查时专门盯。
4.2 并发编排:Promise.all 的失败语义与并发闸门
Promise.all的行为需要精确理解:只要有一个 Promise 失败,整个Promise.all立刻进入 rejected 状态,但其余 Promise 并不会被取消,它们还在继续跑,跑完了结果被丢弃。如果这些操作有副作用(写数据库、发请求),你会发现"明明报错了,但数据还是写进去了"。
要拿到全部结果,用Promise.allSettled,它永远 fulfilled,返回每一项的状态和值:
const results = await Promise.allSettled(tasks); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length) { console.warn(`有 ${failed.length} 个任务失败`); }另一个必须处理的问题是并发规模。写await Promise.all(urls.map(fetch))的时候,如果urls有一万个,那就等于一瞬间发起一万个请求,本地文件句柄耗尽、对端限流、内存飙升都可能发生。正确做法是加一个并发闸门,控制同时在跑的数量:
async function runWithLimit(tasks, limit = 10) { const results = []; const executing = new Set(); for (const task of tasks) { const p = Promise.resolve().then(task); results.push(p); executing.add(p); const clear = () => executing.delete(p); p.then(clear, clear); if (executing.size >= limit) { await Promise.race(executing); } } return Promise.all(results); }这段代码的思路是维护一个"在跑集合",满了就等最快的一个完成再继续。我在处理批量数据同步时用了很久,把并发从无限降到 10 之后,不仅没变慢,反而是变快了——因为对端的限流不再触发,重试次数大幅下降。
4.3 兜底处理:uncaughtException 到底该不该用
关于要不要监听未捕获异常,争论一直存在。我的做法是:监听,但只用来做收尾,不用来续命。
未捕获异常意味着程序进入了一个不确定的状态,某个操作可能执行到一半,内存里的数据结构可能被破坏。这时候继续对外提供服务,风险远大于收益。正确的处理是:记录完整的错误堆栈、把当前正在处理的任务标记为失败、在超时时间内让已有请求完成、然后退出进程,由进程管理器拉起新的实例。
process.on('uncaughtException', (err) => { logger.fatal({ err }, '未捕获异常,准备退出'); server.close(() => process.exit(1)); setTimeout(() => process.exit(1), 5000).unref(); }); process.on('unhandledRejection', (reason) => { logger.error({ reason }, '未处理的 Promise 拒绝'); });unhandledRejection我倾向于只记录不退出,因为很多情况下它来自第三方库内部的边界问题,影响可控。但记录必须要有,否则线上出了问题连线索都没有。
5. 流与背压:大文件和大响应体不炸内存的做法
5.1 为什么一次性读入内存在大文件上是灾难
fs.readFile会把整个文件读进内存,返回一个 Buffer。文件小的时候很方便,文件一大就出事。一个 500MB 的日志文件读进来,Node.js 的 Buffer 分配会直接吃掉 500MB 内存,加上解析过程中的临时对象,峰值可能翻倍。如果同时有五个请求在读不同的文件,进程就危险了。
更麻烦的是,Buffer 是在 V8 堆之外分配的内存,它不计入常规的堆内存统计,所以你看堆快照的时候发现"堆才 200MB,但进程占了 2GB",很可能就是 Buffer 用超了。这种情况排查起来特别费劲,因为常规的内存分析工具看不到。
流就是为了解决这个问题:数据分块读取,处理完一块丢掉一块,内存占用与文件大小无关,只与块大小和缓冲策略有关。四种流类型各有用途:
| 流类型 | 用途 | 典型来源 |
|---|---|---|
| Readable | 读取数据源 | 文件读取流、HTTP 请求体 |
| Writable | 写入目标 | 文件写入流、HTTP 响应 |
| Duplex | 既读又写 | TCP socket |
| Transform | 读入后转换再输出 | 压缩、加密、编码转换 |
我处理过一个导出功能,最初是把几十万行数据拼成一个大字符串再返回,服务经常在导出时内存暴涨。改成流式输出之后,内存曲线几乎是一条平线,而且用户看到的是"数据一点点出来",体验反而更好。
5.2 背压的机制与 pipeline 的接管
流的核心机制叫背压,说白了就是"生产太快,消费跟不上时,让生产方慢下来"。
具体机制是:writable.write()返回布尔值。返回true表示内部缓冲区还有空间,可以继续写;返回false表示缓冲区满了,此时必须停止写入,等drain事件触发后再继续。如果无视这个返回值继续硬写,数据会全部堆在内存里,流就退化成了"一次性读入"。
const { createReadStream, createWriteStream } = require('node:fs'); const { pipeline } = require('node:stream/promises'); const { createGzip } = require('node:zlib'); await pipeline( createReadStream('big.log'), createGzip(), createWriteStream('big.log.gz') );用pipeline的好处是它自动处理了背压、错误传播和资源释放这三件事。手动用pipe串联的时候,如果中间某一环出错,上下游不一定能正确关闭,容易留下野的文件描述符。pipeline在任意一环失败时会销毁所有流,并且把错误抛给调用方,用 Promise 版本还能直接await。
只要涉及多个流的串联,一律用 pipeline,不要手写 pipe 链。文件描述符泄漏比内存泄漏更难查,因为它在进程层面表现为"打开的文件太多"。
5.3 手写 Transform 流的几个注意点
自定义 Transform 流在处理数据清洗、格式转换时很有用,但有几个细节容易写错。
第一是_transform方法里必须调用callback,无论成功还是失败。忘了调用,流会永远挂起,表现为"请求一直不返回",非常难排查。
第二是分块边界问题。流是按字节块切割的,一个 UTF-8 中文字符可能被切成两半,分别落在两个 chunk 里。如果直接用chunk.toString()处理,会出现乱码。解决办法是在创建读取流时指定编码,或者用StringDecoder做跨块的字符拼接。
第三是highWaterMark的设置。它决定了每个块的大小,默认 16KB(可读流)。块太小会导致系统调用次数过多,块太大则失去流式处理的意义。对于日志分析这类场景,64KB 到 256KB 是比较舒服的区间,具体值建议实测,用压测结果说话,别凭感觉。
const { Transform } = require('node:stream'); class LineSplitter extends Transform { constructor(options) { super({ ...options, readableObjectMode: true }); this.buffer = ''; } _transform(chunk, encoding, callback) { this.buffer += chunk.toString('utf8'); const lines = this.buffer.split('\n'); this.buffer = lines.pop(); // 最后一段可能不完整,留到下次 for (const line of lines) { if (line.trim()) this.push(line); } callback(); } _flush(callback) { if (this.buffer.trim()) this.push(this.buffer); callback(); } }这段代码的关键在于_flush。流的最后一段数据没有换行符,如果不处理,最后一行就会丢掉。这个 bug 极其隐蔽,因为数据量大的时候只丢一行,很难被发现,但如果是配置文件解析,丢一行可能就是致命的。
6. 稳定性:内存、CPU 与多核的真实打法
6.1 内存泄漏的定位路径
Node.js 的内存泄漏有个特点:增长缓慢、症状滞后。一个每次请求泄漏几 KB 的服务,可能要跑上几天才会暴露。等告警响起来的时候,你已经很难回想起最近改了什么。
我的排查路径是固定的三步。第一步,确认是不是真的泄漏——连续观察几轮内存曲线,如果每次垃圾回收后基线都在抬高,那就是泄漏;如果只是锯齿状波动但基线稳定,那是正常的缓存行为,不用管。
第二步,抓堆快照做对比。在服务稳定运行后抓一份,等内存涨了 200MB 再抓一份,对比两份快照里的对象数量和保留大小,看哪个类型的对象数量增长最快。增长最快的那个,基本就是元凶。这一步的关键是间隔要足够,抓两次紧挨着的快照,差异太小看不出来。
第三步,顺着引用链找到持有者。快照工具会显示"谁在引用这个对象",一直往上追到根,就能看到是全局变量、闭包、还是某个没清理的监听器。
我遇到的泄漏原因,排前三的是这几种:
- 全局对象当缓存用,只增不减,没有淘汰策略。
- 事件监听器只加不减。特别是给长生命周期的对象(比如全局的 EventEmitter、数据库连接)反复加监听器,每次请求加一个,跑一天就是几万个。默认情况下单个事件超过 10 个监听器会打警告,但很多人把这个警告当噪音忽略了。
- 定时器没清除。请求进来时 setInterval 做轮询,请求结束了定时器还在跑,闭包里持有的请求上下文永远释放不掉。
第三种最难查,因为定时器在堆快照里体现为一个 Timer 对象,看不出它属于哪个请求。我的做法是给每个定时器一个可识别的引用,配合日志记录创建和销毁,出问题时对账。
6.2 worker_threads 与 cluster,分工完全不同
这两个东西名字听起来像,解决的问题完全不一样,混用会很尴尬。
cluster是用来吃满多核的。它启动多个进程,每个进程是独立的 Node.js 实例,共享同一个监听端口,由主进程负责分发连接。进程之间内存不共享,一个崩了不影响其他。适合的场景是:你的服务本身没有 CPU 瓶颈,但单核跑不满机器,想用满 8 核 16 核。
worker_threads是用来卸载 CPU 任务的。它在同一个进程内起多个线程,线程之间可以共享内存(通过 SharedArrayBuffer),也可以传消息。适合的场景是:某个操作会阻塞主线程几百毫秒以上,比如图片处理、大文件解析、复杂计算。
判断标准很简单:要更多进程还是一个不堵的线程。想吃满多核用 cluster,想解决单次计算阻塞用 worker_threads。
实操上,cluster 的进程数一般设成 CPU 核数,但要注意内存。每个进程都有独立的堆,8 个进程的内存占用接近 8 倍。容器环境下要相应调大内存上限,否则会出现"莫名其妙被 OOM 杀掉"的情况,而且因为杀的是子进程,主进程会不断重启它,形成震荡。
worker_threads 的线程池建议做成固定的几个,任务通过队列分发。每次任务都新建线程的开销很大,线程创建本身就要几十毫秒,频繁创建反而更慢。
6.3 优雅退出与健康检查,上线前必须有的两样东西
优雅退出指的是收到终止信号后,不再接受新连接,把正在处理的请求做完,然后退出。没有这个机制,滚动发布时每一次都会有请求被硬切断,用户看到的是随机的 502。
实现的关键是监听SIGTERM,然后调用服务器的close方法,等回调触发后再退出,同时挂一个兜底定时器防止卡死:
const shutdown = async (signal) => { logger.info({ signal }, '开始优雅退出'); server.close(async () => { await db.close(); process.exit(0); }); setTimeout(() => { logger.error('优雅退出超时,强制结束'); process.exit(1); }, 15000).unref(); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));那个unref()很重要,它让这个定时器不阻止进程退出。漏了这个,进程会一直挂到超时才走,部署时间被白白拉长。
健康检查要区分两个概念:存活检查和就绪检查。存活检查回答"进程还活着吗",就绪检查回答"现在能接流量吗"。很多项目只做一个接口,把数据库连接状态也塞进去,结果数据库抖了一下,所有实例同时被判死,全部重启,雪崩。正确做法是存活接口只检查进程本身,就绪接口才去检查依赖的连通性,而且要有缓存,不能每次请求都去 ping 一次数据库。
7. 上线前我固定会过一遍的清单
7.1 运行时与配置层面的检查项
这套清单是我从几次线上事故里攒下来的,现在每次上线前都会过一遍,花不了十分钟,但确实省了很多事。
- 运行时版本固定在长期维护版,并且在部署环境里验证过一遍,不是"本地能跑"就算。
- 环境变量有默认值兜底,缺失时给出明确报错而不是静默用空值跑起来。我见过因为一个配置项没配,服务连到了测试库跑了三天。
- 日志是结构化的,带请求 ID,能在海量日志里把一次请求的完整链路捞出来。
- 进程管理器配置了内存上限和重启策略,重启次数有上限,避免无限重启打爆日志盘。
- 依赖版本锁文件已提交,部署用基于锁文件的安装方式。
- 时区明确设置,不要依赖宿主机默认值。跨时区部署时这个坑非常经典。
配置这块还有个习惯我一直在坚持:所有配置项集中在一个文件里读取和校验,读取时立即校验类型和必填项,校验失败直接退出。散落在各处的process.env.XXX是维护噩梦,你永远不知道到底有哪些配置项,也没法在启动时发现问题。
7.2 可观测性三件套怎么落地
日志、指标、链路追踪,这三样东西在 Node.js 里的落地成本其实很低,但很多人拖到出问题才补。
日志的重点不是打得多,而是打对地方。请求入口打一条带方法、路径、耗时的记录;错误打完整的堆栈和上下文;业务关键节点打状态变更。千万别在循环里打日志,那是 I/O 操作,日志量大的时候本身就成了性能瓶颈。
指标至少要暴露四类:请求量、错误率、响应时间分位数、进程内存和事件循环延迟。最后这个"事件循环延迟"是 Node.js 特有的,它直接反映主线程被阻塞的程度。正常情况下应该是个位数毫秒,如果常年几十毫秒,说明有同步代码在堵,得去查了。
链路追踪在微服务架构里是必需品。一个请求经过网关、鉴权、业务服务、数据服务,出问题时没有追踪就得一个个翻日志对时间戳。埋点的关键是把追踪上下文在跨服务调用时透传下去,这一步做对了,后面的事都顺。
7.3 压测要趁早,而且要压真实链路
最后说压测。很多团队把压测放到上线前一周,结果发现问题时已经来不及改架构了。我的建议是接口开发完就压,哪怕只有单接口,也能提前暴露同步阻塞、慢查询、连接池配置不合理这些问题。
压测时要注意两点。一是压真实链路,不要只压一个返回固定值的接口,那只能测出框架的开销。二是关注分位数而不是平均值,平均响应 50ms 听起来很好,但如果 P99 是 3 秒,那 1% 的用户体验极差,而这 1% 往往是付费用户或者核心流程。
我自己踩过的一个坑是:压测环境数据库是空的,查询飞快,上线后数据量一上来,一个没建索引的查询直接把数据库拖垮。从那以后,压测环境的数据量至少要和生产一个数量级,否则压测结论没有参考价值。
最后分享一个我用了很久的小习惯:每次遇到一个 Bug,不管多小,都在项目里建一条记录,写清楚现象、原因、修复方式,只写三行。一年下来这个文件会有几百条,它比任何教程都贴合你自己的项目。Node.js 这东西,入门的门槛低到几乎不存在,但从"能跑"到"敢让它扛流量"之间隔着的东西,基本都在这些记录里。