Node.js完全教程:事件循环、异步I/O与生产实践 1. 先把 Node.js 是什么讲透它到底解决了什么问题很多人搜Node.js 完全教程从入门到精通脑子里想的是一条线性的路装环境、写console.log、起个服务、连个数据库、上线完事。我带过几届新人也接手过几个半路转过来的项目真正卡住人的地方几乎从来不是语法而是对运行时模型的理解偏差。语法两天就能抄会模型理解错了后面写出来的每一行代码都在给自己挖坑。所以这篇不讲流水账我按照一个从业者真正需要掌握的顺序来铺先讲清楚它是什么、为什么这么设计再讲怎么装、怎么写、怎么调、怎么稳。Node.js 本质上是把 Chrome 的 V8 引擎单独拎出来配上 libuv 这个跨平台异步 I/O 库做成一个可以在服务端跑 JavaScript 的运行时。它解决的 core 问题是用同一门语言同时处理大量并发连接而不必为每个连接开一个线程。传统的那套「一个请求一个线程」的模型在连接数上到几千就开始吃力线程上下文切换和内存开销都很疼。Node.js 换了个思路单线程跑业务逻辑I/O 全部交给操作系统和线程池异步处理主线程只管在事件就绪时回来执行回调。这套模型适合谁我的判断标准很直接I/O 密集型、连接数多、单次计算不重的场景闭眼上CPU 密集型、需要大量同步计算的场景要么别用要么老老实实拆成 worker 或者独立服务。这篇内容面向的是已经会一点 JavaScript、想把它真正用到生产里的读者也面向那些写了半年 Node 但总觉得「跑起来心里没底」的人。1.1 单线程与非阻塞 I/O 的真面目「Node.js 是单线程的」这句话坑了太多人。准确说法是你的 JavaScript 代码跑在单个主线程上但运行时内部并不是单线程。libuv 维护了一个默认大小为 4 的线程池可以通过UV_THREADPOOL_SIZE调整文件读写、DNS 解析、部分加密计算都会丢进去跑网络 I/O 在 Linux 上走的是 epoll在 macOS 上是 kqueue在 Windows 上是 IOCP这些是操作系统层面的多路复用机制压根不需要额外线程。理解这一点非常关键因为它直接决定了你写代码时该担心什么、不该担心什么。举个最常见的误解有人觉得「Node 单线程那我读个文件会不会把整个服务卡住」不会读文件是丢给线程池的主线程只是发起调用然后继续干别的。但如果你读的是一个 500MB 的 JSON 文件然后JSON.parse它那确实会卡住因为解析是在主线程上同步执行的。I/O 不卡CPU 才卡这句话请刻在脑子里。再往深一层非阻塞的核心价值在于「等待」这件事被外包了。传统模型里线程发起了读操作就得原地等着等的时候啥也干不了只能靠开更多线程来提升吞吐。Node.js 的模型是发起读操作注册一个回调立刻返回去处理下一个请求等数据到了再由事件循环调度回来执行。等待的时间被充分利用了所以同样的硬件能扛更多连接。代价是编程模型变复杂了你得习惯「现在拿不到结果等会儿再说」这种思维方式。注意很多人把async/await理解成「开了一个新线程」这是彻底的误读。await只是语法糖底层还是回调加状态机它让代码看起来像同步但执行依然在主线程上。写个死循环放到async函数里服务照样卡死。1.2 V8、libuv 与事件循环的分工要把 Node.js 用明白得知道这几块各自管什么。V8 负责把 JavaScript 编译成机器码并执行顺带管内存分配和垃圾回收libuv 负责所有和操作系统打交道的脏活包括事件循环本身、线程池、文件系统、网络、定时器Node.js 的 C 绑定层现在大部分走 N-API把这两者粘起来再往上给你暴露fs、net、http这些内置模块。事件循环是理解一切异步行为的钥匙。它分几个阶段轮转timerssetTimeout、setInterval到期回调、pending callbacks部分系统操作的回调、poll等待并执行 I/O 回调这是耗时最长的阶段、checksetImmediate、close callbacksclose事件。每一轮结束后会清空微任务队列包括process.nextTick队列和 Promise 回调队列。这里有个经典面试题也是实战里真会踩的坑setTimeout(fn, 0)和setImmediate(fn)谁先执行答案是不确定取决于进入事件循环时的耗时。如果主代码执行超过 1ms定时器可能已经到期那 timers 阶段就先跑如果很快就先到 check 阶段。而process.nextTick永远比 Promise 的then先执行因为它的队列优先级更高。// 实测一下顺序这段输出的结果很能说明问题 process.nextTick(() console.log(nextTick)); Promise.resolve().then(() console.log(promise)); setImmediate(() console.log(setImmediate)); setTimeout(() console.log(setTimeout), 0); // 常见输出nextTick - promise - setTimeout/setImmediate后两者顺序不固定我把这段代码贴给过好几个同事看多数人第一次都猜错。猜错不丢人但如果你在写一个需要严格控制执行顺序的初始化逻辑时还搞不清那就会出线上问题。比如你在模块加载时用nextTick去读配置而另一个模块在 Promise 里依赖这份配置顺序搞反了就是 undefined。1.3 适合与不适合 Node.js 的场景清单选型这件事与其听别人说「Node 性能好」不如看一张对照表。下面这张表是我这些年做技术评审时用的判断依据你可以直接拿去对照自己的项目。场景类型是否合适原因API 网关、BFF 层非常合适大量并发请求转发I/O 密集逻辑轻实时通讯长连接推送非常合适单机可维持数万连接内存占用低服务端渲染合适模板拼接是字符串操作配合缓存效果好文件上传下载、转码调度合适流式处理天然契合注意转码本身要拆出去图片处理、视频编码不合适CPU 密集会阻塞事件循环应交给专门服务大数据量计算、报表汇总不合适同步计算时间长除非拆 worker强事务型金融核心谨慎生态和事务模型不是强项需大量额外设计表格里最后一行的「谨慎」我要多啰嗦两句。不是说完全不能用而是这类场景对数据一致性的要求极高Node 的生态里缺少那种经过长期验证的重量级事务框架你得自己补很多功课。我见过一个团队硬上最后在分布式事务上折腾了三个月最后还是把核心逻辑挪到了别的技术栈只留 Node 做前置层。技术选型最贵的成本不是学习成本是走错路之后的返工成本。2. 入门第一关环境搭建与项目骨架环境搭建这块很多人觉得没技术含量随手装个最新版就开工。我踩过的坑告诉我这一步的决策会影响后面一两年的维护成本。版本选错某个依赖编译不过包管理器选错团队里三个人三个 lock 文件目录结构随手糊三个月后自己都不想打开这个项目。2.1 版本管理与 Node 安装的取舍我的建议非常明确别用系统包管理器装 Node用版本管理工具。原因很实际你手上大概率同时有好几个项目老项目锁在 16.x新项目要 20.x 往上系统级的安装方式没法同时满足。版本管理工具能让你在目录之间自由切换cd进去自动切版本这是刚需不是讲究。选型上主流的两三个工具都行核心看两点能不能读.nvmrc或.node-version这类版本文件支不支持自动切换。团队协作时把版本文件提交到仓库新人拉下来一条命令就能对齐环境省掉无数「在我机器上是好的」的扯皮。至于选哪个大版本我的原则是选当前 LTS且是最新的那个 LTS 小版本。奇数版本是实验性的生命周期短生产环境不要碰。LTS 版本有 30 个月的支持期前 18 个月积极维护后 12 个月只修安全问题这个节奏足够覆盖绝大多数项目的迭代周期。# 装好版本管理工具后进入项目目录 node -v # 确认当前版本 npm -v # 顺带看一眼包管理器版本 # 项目根目录放一个 .nvmrc写上版本号 echo 20.11.0 .nvmrc注意升级大版本之前先在分支上跑一遍完整测试和构建。V8 引擎升级会带来行为变化某些在旧版本能跑的边界代码在新版本可能报错尤其是涉及正则、Unicode、Intl的部分。我遇到过一次升级后日期格式化输出变了排查了半天才发现是 ICU 数据更新导致的。2.2 npm、package.json 与依赖锁定的门道package.json这个文件看着简单实际上有几个字段决定了项目的可复现性。dependencies和devDependencies的区分大家都懂但很多人不知道peerDependencies的坑你写一个库依赖了某个框架把它放dependencies会导致用户装了两个版本放peerDependencies才是正解。版本号前面的符号更是重灾区。^1.2.3表示允许升级到1.x的最新版~1.2.3表示只允许1.2.x的补丁更新不带符号就是精确锁定。我个人的习惯是应用项目里所有直接依赖都用精确版本靠 lock 文件保证可复现开源库才用范围版本给使用者留余地。听起来保守但线上出过一次「某个次要版本改了默认行为」的事故之后你就知道保守是划算的。lock 文件必须提交到仓库这个没有讨论空间。它记录了整棵依赖树的精确版本和完整性校验值是「昨天能跑今天不能跑」这类问题的第一道防线。还有个细节npm ci和npm install是两回事前者严格按 lock 文件安装会先删掉node_modules适合 CI 环境和生产构建后者会尝试更新 lock 文件适合本地开发加依赖。{ name: demo-service, version: 1.0.0, private: true, engines: { node: 20.11.0 }, scripts: { start: node src/index.js, dev: node --watch src/index.js, test: node --test }, dependencies: { fastify: 4.26.2 } }上面这个package.json里有几个我刻意加的东西。private: true防止误发布到公共仓库engines声明运行时约束配合 CI 检查能提前拦截版本不对的情况node --watch是较新版本内置的监听重启小项目里完全可以替代第三方热重载工具少一个依赖少一份风险。2.3 一个最小可运行服务的从零搭建我不建议新手一上来就上重型框架先用内置的http模块写一遍你会对请求响应这件事有更实在的感觉。下面这段代码不到二十行但五脏俱全路由分发、JSON 响应、错误兜底、优雅退出。import http from node:http; const server http.createServer((req, res) { const url new URL(req.url, http://${req.headers.host}); if (req.method GET url.pathname /health) { res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify({ status: ok, uptime: process.uptime() })); return; } res.writeHead(404, { Content-Type: application/json }); res.end(JSON.stringify({ error: not found })); }); server.listen(3000, () { console.log(listening on 3000); }); // 优雅退出收到信号后停止接收新连接处理完存量再退出 process.on(SIGTERM, () { server.close(() { console.log(closed); process.exit(0); }); setTimeout(() process.exit(1), 10000).unref(); });这段代码里最容易被忽略的是最后那个setTimeout(...).unref()。它的作用是给优雅退出加一个兜底超时如果十秒内还有连接没处理完强制退出避免进程挂死在那里。.unref()让这个定时器不阻止事件循环退出不然进程会一直等着它。这种小细节文档里往往一笔带过但线上真遇到「发版时进程卡住不退出」的时候你会感谢自己写过这一行。跑起来的方式也很简单node src/index.js就行。想边改边看效果加--watch参数改完保存自动重启。注意这个内置监听和第三方工具的行为不完全一样它不会做热替换只是重启进程所以内存里的状态会丢这一点要心里有数。3. 异步编程从回调地狱到 async/await异步是 Node.js 的灵魂也是新手最容易写得一团糟的地方。我见过太多项目回调套了四五层缩进已经到屏幕外错误处理全靠运气。这一章把演进路径捋一遍重点不在语法在于每种写法背后的控制流到底怎么走以及什么时候该用哪种。3.1 回调、Promise、async 的演进与本质回调时代的核心问题是控制反转你把函数交给别人别人什么时候调、调几次、出错怎么办全凭对方自觉。多个异步操作串行时就形成了所谓的回调地狱。Promise 的价值不在于写得好看而在于它把「结果」变成了一个可以传递、可以组合的一等对象错误统一走reject组合靠then链。async/await则是把 Promise 的链式写法还原成同步的阅读顺序。它的本质是生成器加自动执行器编译器把await后面的代码切成一段一段等 Promise 落定后再继续。所以它不会阻塞主线程只是让代码看起来像阻塞。理解这一点你就不会写出「await一个耗时计算然后就以为不卡了」这种代码。// 串行一个接一个总耗时是两者之和 async function serial() { const a await fetchUser(1); // 假设 100ms const b await fetchOrders(a); // 假设 150ms return b; // 总耗时约 250ms } // 并行互不依赖的两个请求同时发 async function parallel() { const [user, config] await Promise.all([ fetchUser(1), fetchConfig(), ]); return { user, config }; }这两段代码的差别看起来只是写法实际耗时能差一倍。判断能不能并行标准很简单后一个操作需不需要前一个的结果。不需要就Promise.all一起发需要就老老实实串行。我审过不少代码明明是独立的两个接口调用硬是写成串行白白多等一倍时间。3.2 错误处理的三种姿势与陷阱错误处理是异步代码里最容易出事的环节。我把它归纳成三类同步抛错、Promise 拒绝、事件回调里的错误三者的捕获方式完全不同混用就会漏。同步代码抛的错try/catch能接住await的 Promise 拒绝try/catch也能接住因为await本质上会把拒绝转成抛出但回调形式的异步错误try/catch接不住因为回调执行的时候try块早就退出了。这是新手最常见的误解。try { // 错误示范这个 catch 接不住回调里的错误 fs.readFile(/some/path, (err, data) { throw new Error(这里的错误会跑到外面去); }); } catch (e) { console.log(永远不会执行); }正确姿势有三种。包裹式await配合try/catch分流式promise.then(ok).catch(err)还有一种是先处理后抛出在服务层统一捕获记录上下文再往上抛让上层决定怎么响应。我个人偏好第三种在业务层只负责「这个错误是什么、有没有副作用要清理」在中间件层统一转换成 HTTP 状态码。还有一个陷阱是未处理的 Promise 拒绝。某个async函数调用后没await也没加.catch()出错时进程会打印警告新版本 Node 默认直接崩溃。这个行为变化坑过不少人本来只是「警告一下」升级后变成「进程退出」。想提前发现可以在测试环境挂一个监听process.on(unhandledRejection, (reason) { console.error(未处理的拒绝, reason); // 生产环境建议记录日志后主动退出让编排系统重启 process.exit(1); });注意不要在生产环境把unhandledRejection只记录不处理那等于把问题藏起来。进程此时的状态已经不明确了继续跑下去可能产生脏数据主动退出交给编排层重启才是稳妥做法。3.3 并发控制的实操Promise.all 与分批处理Promise.all好用但它有两个坑一是失败即整体失败哪怕其他请求都成功了二是没有并发上限一次性发一万个请求可能把对端打挂也可能把自己的连接池耗尽。这两个坑在批量处理场景里都会踩到。先说第一个坑想要「都跑完成功的留成功的失败的留失败的」用Promise.allSettled返回的每一项都带状态不会中断。这个在数据同步、批量导入场景里特别合适。第二个坑需要手写并发控制。思路很简单维护一个运行中的任务数组保持在容量之内每完成一个就补一个上去。async function pool(tasks, limit 5) { const results new Array(tasks.length); let next 0; async function worker() { while (next tasks.length) { const i next; try { results[i] { status: ok, value: await tasks[i]() }; } catch (e) { results[i] { status: error, reason: e }; } } } const workers Array.from({ length: Math.min(limit, tasks.length) }, worker); await Promise.all(workers); return results; }这段代码不到二十行但覆盖了并发控制的核心。我实测过一个批量调用外部接口的场景不做限制一次性发 500 个对端直接返回 429加上limit10之后稳稳跑完总耗时反而更短因为没有重试和排队。并发不是越大越好超过对端和自身的承载能力就是自找麻烦。限流的具体数值要看对端文档和自己连接池配置一般从 5 或 10 起试观察错误率和延迟再调整。4. 模块系统与工程化项目从 demo 变成正经工程的分界线我觉得是模块化。代码散在一堆文件里靠全局变量通信那是玩具能清楚地划分边界、声明依赖、控制加载顺序才谈得上工程。4.1 CommonJS 与 ES Module 的差异与迁移CommonJS 是 Node 的老牌模块规范核心是require和module.exports。它的特点是同步加载、运行时执行所以可以在任何地方写require甚至写在if分支里。ES Module 是语言标准特点是静态分析、编译时确定依赖import必须写在顶层不能动态。这个差异带来的连锁反应很多比如 ESM 里能用 tree-shaking打包时删掉没用的导出CJS 就不行。迁移这件事我的建议是新项目直接上 ESM老项目能不迁就不迁实在要迁就分模块慢慢来。理由很实际CJS 生态里大量包只提供 CJS 入口虽然现在的 Node 版本已经能在 ESM 里import它们但涉及循环依赖、__dirname、require.resolve这些特性时还是会撞墙。__dirname在 ESM 里没有得用import.meta.dirname或者自己从import.meta.url推导。// ESM 里拿当前文件目录 import path from node:path; import { fileURLToPath } from node:url; const __dirname path.dirname(fileURLToPath(import.meta.url)); // 较新版本可以直接用 import.meta.dirname还有一点经常被忽略模块加载是有缓存的。第一次require之后后续拿到的都是同一个对象引用。这带来两个后果一是模块顶层的副作用只执行一次适合做初始化二是不同地方修改同一个模块导出的对象会互相影响这是共享状态问题的常见源头。想清空缓存重新加载得手动删require.cache的条目但 ESM 的缓存没法这样清这也是迁移时需要考虑的现实约束。4.2 目录结构设计与分层目录结构没有标准答案但有明显的坏味道。把所有逻辑塞在routes里、工具函数到处散落、配置文件被业务代码直接require这些都是坏味道。我一般按职责分层来组织大致是入口层、路由层、服务层、数据访问层、公共层五块。src/ index.js // 进程入口只做启动和信号处理 app.js // 应用装配注册中间件和路由 routes/ // 请求参数校验、调用服务、组装响应 services/ // 业务逻辑不关心 HTTP repositories/ // 数据访问不关心业务规则 lib/ // 纯工具函数无副作用 config/ // 配置读取与校验这个划分的关键在于依赖方向单一上层依赖下层下层不知道上层的存在。路由层不认识数据库服务层不认识 HTTP 请求对象。这么设计的好处很实在服务层可以脱离 HTTP 单独写测试换框架时只需改路由层那一薄层数据源变更只影响仓储层。我接手过一个把 SQL 直接写在路由处理函数里的项目后来换 ORM 改了三十多个文件如果当初有仓储层改动能压缩到十个以内。4.3 配置管理与环境变量配置这件事新手常犯的错误是把配置硬编码在代码里或者随手提交一个带真实密钥的配置文件。前者导致改个端口都要发版后者直接就是安全事故。正确做法是代码里只读环境变量启动时统一校验缺了就快速失败。// config/index.js const required [PORT, DB_URL, JWT_SECRET]; for (const key of required) { if (!process.env[key]) { throw new Error(缺少必要环境变量: ${key}); } } export default { port: Number(process.env.PORT), dbUrl: process.env.DB_URL, jwtSecret: process.env.JWT_SECRET, };这里我特意用throw而不是打日志因为配置不全的进程不应该活着。让它启动失败编排系统会报错问题立刻暴露让它带着默认值跑起来可能连到了测试库、用了弱密钥出问题时根本想不到是配置的锅。Number()转换也别省环境变量永远是字符串3000直接传给某些库会出问题。本地开发时用.env文件加载环境变量npm较新版本自带--env-file参数不需要额外依赖。记得把.env加进.gitignore仓库里只放一份.env.example说明有哪些变量、格式是什么。5. 核心能力实战HTTP 服务、中间件与数据层前三章打基础从这一章开始进入实战。绝大多数人用 Node.js 就是写 HTTP 服务而服务写得好不好差别就在中间件模型和数据层的处理细节上。5.1 从 http 模块到框架的选型内置http模块足够理解原理但生产项目基本都会上框架。选型的维度我看四个性能、生态、学习曲线、团队熟悉度。前三个决定了能不能用最后一个是决定用不用得起来。生态里主流的两三个框架我都用过具体的差别主要体现在中间件风格和内置能力上有的偏 Express 式的链式use有的用插件加钩子写法差异不小。我的实际建议是接口数量少于二十个、团队只有一两个人直接用内置http加少量手写工具函数也行依赖少、启动快、没有版本升级包袱。超过这个规模框架带来的路由、校验、序列化、错误处理这些内置能力就开始划算了能省掉大量重复代码。不管选哪个有几个能力必须确认框架是否提供请求体大小限制、超时控制、错误统一处理、序列化和反序列化的定制钩子。这几项缺了任何一项你都得自己补补的时候容易写出安全漏洞。比如没有请求体大小限制一个恶意的超大 JSON 就能把内存打爆。5.2 中间件模型与请求生命周期中间件的本质是一条处理链请求进来依次经过响应再原路返回。理解这个「洋葱模型」很重要因为它决定了你在哪个位置能做哪些事。// 伪代码展示中间件的执行顺序 async function middleware(ctx, next) { console.log(1 进入); const start Date.now(); try { await next(); // 交给下一个中间件或路由 console.log(2 返回耗时, Date.now() - start); } catch (e) { console.log(捕获下游错误, e); throw e; } finally { console.log(3 收尾); } }这个顺序意味着在next()之前写的是请求预处理之后写的是响应后处理。日志、计时、错误捕获放外层鉴权、参数校验放中间业务逻辑放最内层。搞反了顺序比如把鉴权放在计时器之后你统计到的耗时会包含被拒绝的请求数据就失真了。还有些细节要注意。中间件里不要重复调用next()会导致下游执行两次行为不可预测不要在下游已经写响应之后再去改响应头会抛错异步中间件必须await next()忘了await的话外层会先于内层返回计时和错误捕获全错。5.3 数据库连接池与查询实践数据层是性能瓶颈的高发区。连接池的配置不当轻则响应变慢重则连接耗尽把服务拖死。核心参数就三个最大连接数、空闲超时、获取连接的超时。最大连接数不是越大越好数据库那边的并发承载能力是有限的你这边开一百个数据库可能直接拒绝或者变慢。我的经验算法是这样的先看数据库的max_connections给每个应用实例分配一个合理的份额再留一点余量给运维和监控。假设数据库允许 200 个连接有 4 个应用实例那每个实例最多配 40 到 45 个。配多了多个实例同时高负载时会互相挤兑。// 连接池配置示意 const pool createPool({ max: 20, // 最大连接数 idleTimeoutMillis: 30000, // 空闲 30 秒回收 connectionTimeoutMillis: 5000, // 获取连接最多等 5 秒 });查询层面最容易出问题的是N1 查询。列表接口查出十条记录然后循环里每条再查一次关联表一次请求打了十一次数据库。数据量一大这个接口就废了。解决办法是批量查询先拿到所有主记录的 ID再用WHERE id IN (...)一次性取关联数据在内存里做映射。这个改动能把接口耗时从几百毫秒压到几十毫秒是我优化列表接口时的第一刀。注意批量查询的IN列表不要无限长几万个 ID 拼成一条 SQL 会导致解析超时甚至栈溢出。我一般按 500 到 1000 分片分批查询再合并结果。分片大小取决于数据库的承受能力可以实测调整。6. 流与文件处理大文件场景的必修课小文件随便读大文件必须用流。这个道理简单但真动手时坑很多。我用过一个最直观的类比流就像水管数据一段一段流过来你处理一段放走一段一次性读文件就像拿桶接水桶不够大就溢出来。6.1 stream 的四种类型与背压Node 的流分四种Readable可读比如文件读取流、Writable可写比如写入流、Duplex双向比如网络套接字、Transform转换读进来处理完写出去比如压缩流。日常用得最多的是 Transform压缩、加密、格式转换都是它。流的核心机制是背压。上游产出速度超过下游消费速度时会通知上游暂停避免数据在内存里堆积。这个机制是自动的但前提是你用对 API。如果用on(data)手动监听就绕过了背压数据会不受控地涌进来。正确做法是用pipe或者更好的pipeline。import { pipeline } from node:stream/promises; import fs from node:fs; import zlib from node:zlib; // 读大文件 - 压缩 - 写到新文件全程流式内存占用恒定 await pipeline( fs.createReadStream(big.log), zlib.createGzip(), fs.createWriteStream(big.log.gz) );pipeline相比pipe的优势在于错误处理和资源清理。pipe在上游出错时不会自动销毁下游容易造成句柄泄漏pipeline会把整条链路上的流都正确关闭并且返回 Promise 方便await。这个差别在长时间运行的服务里很致命一个泄漏的文件句柄积累下来进程迟早出问题。6.2 大文件上传与导出实操文件上传这个场景不用流的话服务端会先把整个文件读进内存再处理。一个 2GB 的文件就能让内存直接爆掉。用流之后内存占用和文件大小基本无关只和缓冲区大小有关。上传的核心是边收边写。请求体本身就是个可读流直接pipeline到文件写入流即可。但要注意几个细节文件名不能直接用客户端传来的路径穿越风险要生成随机名要限制大小收的过程中累计字节数超了就中断要处理客户端中途断开的情况否则会留下残缺文件。import { pipeline } from node:stream/promises; import fs from node:fs; import crypto from node:crypto; import path from node:path; const MAX_SIZE 100 * 1024 * 1024; // 100MB async function saveUpload(stream) { const name crypto.randomUUID(); const target path.join(/data/uploads, name); let size 0; const counter new (await import(node:stream)).Transform({ transform(chunk, _enc, cb) { size chunk.length; if (size MAX_SIZE) return cb(new Error(文件过大)); cb(null, chunk); }, }); await pipeline(stream, counter, fs.createWriteStream(target)); return { name, size }; }导出方向反过来把数据库查询结果转成 CSV 或 JSON 逐行写出。关键点是不要先把所有数据查出来再写用游标或者分页查询边读边写。我做过一个导出百万行数据的接口全量查询直接内存溢出改成游标流式输出之后内存平稳耗时也可控。处理方式内存占用适用文件大小备注一次性读取与文件等大10MB 以下简单但上限很低分块读取与块大小相关任意需要自己管理边界流式管道恒定缓冲区大小任意首选配合 pipeline内存映射虚拟内存映射大文件随机读需要原生模块场景特殊7. 性能与稳定性从能跑到跑得稳代码能跑通只是及格线。真正拉开差距的是出问题时你能不能快速定位流量上来时服务会不会雪崩。这一章讲的是我这些年排查问题用得最多的几招。7.1 事件循环阻塞的排查前面反复强调事件循环这里说怎么量化它。事件循环延迟是个关键指标定时器预期 10ms 触发实际 50ms 才触发说明中间被什么同步任务占住了。生产环境可以周期性采样把延迟打到监控里。let last Date.now(); setInterval(() { const now Date.now(); const delay now - last - 1000; if (delay 100) { console.warn(事件循环阻塞, delay, ms); } last now; }, 1000).unref();这个采样很粗糙但足够发现明显问题。一旦发现延迟频繁超过阈值就要去找是哪个同步操作在作祟。常见的嫌疑犯大 JSON 的解析和序列化、复杂的正则匹配、同步的文件读取、加密计算、超大数组的排序。定位具体位置最直接的办法是用性能分析工具生成 CPU profile看看火焰图里哪一段占得最宽。另一个办法是在可疑代码前后加时间戳先做个二分定位缩小范围再细查。我个人的习惯是先把可疑范围缩到函数级别再上工具看细节比一上来就抓全局 profile 效率高得多。7.2 内存泄漏与堆快照内存泄漏在 Node 里表现很隐蔽进程不会立刻崩而是内存曲线缓慢爬升跑几天之后突然 OOM。常见的泄漏源有几个全局数组或 Map 只增不减、事件监听器注册了没移除、闭包持有大对象、定时器没清理。排查的第一步是看内存趋势。process.memoryUsage()能拿到堆使用量周期性打印画成曲线是缓慢上升还是锯齿状波动一眼就能分辨。锯齿状是正常的 GC 行为持续上升才是泄漏。setInterval(() { const m process.memoryUsage(); console.log( heapUsed, (m.heapUsed / 1024 / 1024).toFixed(1), MB, rss, (m.rss / 1024 / 1024).toFixed(1), MB ); }, 10000).unref();第二步是抓堆快照对比。间隔一段时间抓两份对比两份之间新增了哪些对象、被谁引用着。工具能看到引用链沿着链条往上找通常能定位到那个只增不减的容器。这个过程需要点耐心但比瞎猜快得多。注意抓堆快照本身会暂停主线程生产环境操作要谨慎尽量在流量低谷做或者临时摘掉流量再抓。大型堆的快照文件可能几百 MB别存在容器里容易把磁盘撑满。7.3 日志、监控与优雅退出日志这块我的底线是结构化、带 traceId、分级。非结构化的console.log在排查时几乎没用你没法按字段筛选也没法统计。用 JSON 格式输出每条日志带请求 ID就能把一次请求涉及的所有日志串起来。监控指标至少要有请求量、错误率、延迟分位数P50、P95、P99、事件循环延迟、内存和 CPU、依赖服务调用耗时。分位数比平均值有用得多平均值 50ms 可能藏着 P99 达到 5 秒的长尾用户感知到的就是偶尔巨慢。优雅退出前面提过一次这里补齐完整流程收到退出信号后先把健康检查标记为不健康让负载均衡摘掉流量然后停止接收新连接等待存量请求处理完关闭数据库连接池和其他资源最后退出进程。整个过程要设总超时防止卡死。async function shutdown(signal) { console.log(收到信号, signal); setHealthy(false); // 健康检查返回失败 server.close(); // 停止新连接 await Promise.race([ drain(), // 等待存量请求 new Promise((r) setTimeout(r, 15000)), ]); await pool.end(); // 关闭连接池 process.exit(0); } process.on(SIGTERM, () shutdown(SIGTERM)); process.on(SIGINT, () shutdown(SIGINT));这段代码看着简单但每个环节都对应一次真实的线上事故。少了健康检查标记流量会在退出过程中打到正在关闭的实例上用户看到一批 502少了连接池关闭数据库那边会残留连接慢慢耗尽少了超时兜底遇到卡死的请求进程就退不出去。8. 常见问题与排查技巧实录这一章是我这些年攒下来的问题清单都是实际遇到过、排查过、解决过的。新手照着排查能省不少时间老手可以当检查表过一遍。8.1 高频报错速查表报错信息常见原因排查方向ENOENT: no such file or directory路径写错或工作目录不对打印process.cwd()检查相对路径EADDRINUSE端口被占用查占用进程或换端口ERR_REQUIRE_ESM在 CommonJS 里require了纯 ESM 包改import或降级包版本CALL_AND_RETRY_LAST/ 堆内存不足内存泄漏或一次性处理数据过大抓堆快照改流式处理MaxListenersExceededWarning同一事件监听器注册过多检查是否循环里重复注册服务响应越来越慢事件循环阻塞或连接池耗尽看延迟指标和连接池状态ECONNRESET对端关闭连接检查超时配置和网络中间层EADDRINUSE这个错误我见过最哭笑不得的一次是本地起了两个终端都跑了npm run dev自己和自己抢端口。排查的时候先看日志里有没有重复启动再看是不是有残留进程没退干净。MaxListenersExceededWarning这个警告很多人直接忽略但它往往是泄漏的前兆。典型场景是在处理函数里给同一个全局事件对象注册监听每次请求注册一个跑久了就上千个。正确做法是注册一次或者用完及时移除。8.2 依赖安装与包版本问题依赖问题分两类装不上、装上了但行为不对。装不上通常是网络、镜像源、编译工具链的原因行为不对通常是版本冲突或者 lock 文件不一致。装不上的排查顺序我一般这样走先确认镜像源配置再确认有没有需要本地编译的包需要编译工具链最后看是不是某个包的某个版本被撤回了。需要编译的包在换机器、换平台时最容易出问题尤其是在容器里构建时缺少构建工具。解决办法要么补上构建工具要么在构建阶段把产物装好再拷进运行镜像后者更干净运行镜像能小很多。行为不对的情况先比对各环境的 lock 文件是不是同一个再查有没有包在小版本升级里改了行为。定位方法是在本地把可疑包锁定到旧版本逐个排除。这个过程比较笨但有效。我个人的习惯是升级依赖单独提交、单独验证别和业务改动混在一起出问题好回滚。8.3 部署上线的那几个坑最后说说上线。代码在本地跑得好好的上了容器就出问题原因基本跑不出这几个。时区。容器默认是 UTC日期格式化输出跟你本地差 8 小时。解决方式是在镜像里设置时区或者在代码里统一按 UTC 处理、在展示层转换。我倾向于后者存储和计算全用 UTC只在最后一步转成本地时间这样跨时区部署不会乱。文件路径大小写。开发机可能是大小写不敏感的文件系统import ./Utils和./utils都能找到部署到大小写敏感的机器上就报模块找不到。这个坑踩过一次就会记住构建时打开严格检查能提前发现。环境变量缺失。前面讲配置时提过启动时校验、缺了就失败是最省事的做法。再配合健康检查实例起不来就不会接流量问题在发版阶段就暴露了。健康检查设计不当。检查逻辑太重会拖慢探针太轻又发现不了问题。我的做法是分两个端点存活检查只判断进程还在不查依赖就绪检查判断能否接流量会查数据库连接和关键依赖。这样依赖抖动时不会导致容器被反复重启只是暂时摘流量。注意就绪检查里查数据库时一定要设超时别让探针卡在那里等到天荒地老。探针超时会导致容器被判定为不健康重启风暴就是这么来的。我个人在带项目和排查问题这些年的体会是Node.js 的门槛从来不在语言本身JavaScript 谁都会写几行。真正的门槛在于对异步模型的理解深度以及对运行时边界的敬畏。知道什么操作会阻塞主线程、知道连接池该配多大、知道进程退出的正确姿势这些才是从入门走到精通的分水岭。语法可以现查模型和边界只能靠一次次踩坑和复盘攒下来。写代码的时候多问一句「这一步会不会卡住别人」很多问题在写的时候就避免了。