1. 先把 Node.js 的定位摆正:它是什么,不是什么
Node.js 这几个字在招聘要求和课程目录里出现得太频繁,反而让很多人对它的理解停在“用 JavaScript 写后端”这一句上。我带过几个刚转过来的同事,他们上手第一周最大的困惑不是语法,而是“为什么这段代码明明写在上面,却比下面那段后执行”。这个问题不解决,后面写什么都是碰运气。所以这篇东西不打算从console.log('hello world')开始堆 API,而是先把运行模型讲透,再往下铺工程化的东西。
先说结论:Node.js 是一个基于 V8 引擎的 JavaScript 运行时,它把 JavaScript 从浏览器里解放出来,让它能直接操作文件、网络、进程。它的核心竞争力不是语言本身,而是事件驱动 + 非阻塞 I/O这套模型——用一个主线程应付成千上万个并发连接,把等待时间交给操作系统,而不是让线程干等着。它适合 I/O 密集型的场景:API 网关、BFF 层、实时通信、脚手架工具、数据处理管道。它不适合 CPU 密集型场景:视频转码、大规模图像运算、复杂加密,这些活交给别的语言更划算。
这段定位决定了后面所有的技术选择。你理解了它为什么快,就知道它什么时候会卡;你知道了它只有一个主线程,就不会写出一个for循环把整个服务堵死的代码。适合读这篇的人包括:前端想往全栈走的、后端想多一门工具语言的、要写自动化脚本的,以及被npm install报错折磨过想搞明白到底发生了什么的。零基础也能看,但需要至少会写 JavaScript 的基本语法,比如函数、对象、数组方法。
1.1 单线程事件循环到底意味着什么
很多人把“单线程”理解成“只能做一件事”,这是错的。准确说法是:JavaScript 执行栈只有一个,同一时刻只有一段 JS 代码在跑;但 Node.js 进程内部有一堆东西在并行工作,包括 libuv 的线程池、操作系统的网络轮询、V8 的垃圾回收线程。你写的回调函数排队等着被执行栈取走,而等待 I/O 的那段时间,主线程是空出来去处理别的事情的。
用生活化的比喻:一个餐厅只有一个服务员(主线程),但他不会站在厨房门口等菜做好(阻塞 I/O)。他把单子递给厨房(发起异步调用),转身去招呼下一桌,菜好了厨房按铃(事件回调),他再去端。这个模式在客人点单多、做菜慢的场景下效率极高;但如果某道菜需要服务员亲自站在厨房里切一小时菜(CPU 密集任务),那整个餐厅就停摆了。这就是为什么JSON.parse一个几百 MB 的字符串、或者用正则跑一个灾难性回溯,会让整个服务无响应。
理解这一点之后,你看到下面这段代码就不会觉得奇怪:
console.log('1'); setTimeout(() => console.log('2'), 0); Promise.resolve().then(() => console.log('3')); console.log('4'); // 输出顺序:1 4 3 2setTimeout即使写 0 毫秒,也要等下一轮事件循环的 timers 阶段;而 Promise 的回调属于微任务,在当前宏任务结束后立刻清空队列。这个顺序在面试里被问烂了,但在真实项目里的价值是:你要知道在await之后拿到的数据,中间可能已经隔了好几轮事件循环,共享状态可能已经被别人改过了。
1.2 哪些场景适合,哪些场景要绕道
选型这件事,我的判断标准很粗暴:看这个服务的瓶颈在“等”还是在“算”。如果时间主要花在等数据库返回、等第三方接口响应、等文件读写,Node.js 是极优解,一个 2 核 4G 的机器撑住几千 QPS 的轻量接口不算稀奇。如果时间主要花在算——比如要做实时音视频转码、要跑复杂的推荐模型推理、要处理百万行的 Excel 计算——那用 Node.js 只能靠worker_threads硬扛,性价比不高,该换就换。
我参与过一个内容聚合类的后台项目,接口层用 Node.js 做聚合和裁剪,把上游五六个服务的返回拼成前端要的结构,这个场景简直是量身定做:全程都在等网络,主线程几乎不占 CPU,单机跑得很轻松。后来有个需求要在接口层做图片缩略图,一开始图省事用了纯 JS 的图片处理库,结果一压测 CPU 直接打满,接口 P99 从 80ms 飙到 3 秒。最后的方案是把图片处理拆成独立服务,用更适合的语言写,Node.js 只负责转发和缓存。这个教训我记了很久:不要跟运行模型对着干。
另外要提醒的是,Node.js 生态里“能跑”和“能上生产”之间隔着很远的距离。网上大量示例代码是单文件、无错误处理、无优雅退出的写法,照着抄进项目里,上线第一天就会因为一个未捕获的 Promise rejection 让进程静默退出。后面的章节会重点讲这些坑怎么填。
2. 环境与项目骨架:第一小时该做的事
新手最容易在这一步浪费时间:装了个来路不明的安装包,版本和教程对不上,npm命令报权限错误,折腾一下午连环境都没跑通。这一节把环境相关的决策一次讲清楚,能省掉大量返工。
2.1 版本选择与版本管理
Node.js 的版本分两类:偶数大版本是长期支持版(LTS),奇数大版本是尝鲜版。生产环境一律选 LTS,这个没有讨论余地。尝鲜版的生命周期很短,几个月就停止维护,你用它上线,半年后安全补丁都没人给你打。
版本管理工具我推荐用nvm(Windows 上用对应的nvm-windows)。理由很实际:你手上往往同时有好几个项目,老项目锁在 Node 14,新项目要 Node 20,靠手动卸载重装会疯。有了版本管理器,一行命令切换,项目里再放一个.nvmrc文件写清楚版本号,团队里谁拉代码谁执行nvm use,环境就对齐了。
# 安装 LTS 版本 nvm install --lts nvm use --lts node -v npm -v注意:不要用系统包管理器装 Node.js,也不要用
sudo npm install -g。全局安装加上 root 权限,迟早会遇到目录权限混乱的问题,而且后续升级会互相覆盖。真需要全局命令,用nvm管理的那份运行时自带的 npm 装,或者干脆用npx临时执行。
还有一个细节:package.json里必须写engines字段,声明项目要求的 Node 版本范围。这不会强制阻止别人用错版本,但配合.nvmrc和 CI 里的版本检查,能挡掉很大一部分“我本地是好的”这类问题。版本差异带来的坑是真实存在的,比如某些内置模块的 API 在小版本之间改了行为,本地 Node 18 跑得好好的,服务器上是 16,一部署就报错。
2.2 项目初始化与目录约定
npm init -y生成的那份package.json只是个起点,真正要手动改的是这几项:name用小写加连字符、version从0.1.0起步、type决定模块体系、main或exports指向入口、scripts里放常用命令。scripts这块值得多花点心思,把 lint、测试、启动、构建都收进去,团队协作时大家用同一套命令,能省掉大量“你那怎么跑的”的沟通。
目录结构没有标准答案,但有几条经验值得参考:源码放src,测试放test或跟源码同级的__tests__,配置放config,运行产物放dist并加进.gitignore。不要在根目录堆二三十个文件,那会让后来的人无从下手。我习惯的划分是这样:
project/ ├── src/ │ ├── routes/ # 路由定义 │ ├── services/ # 业务逻辑 │ ├── models/ # 数据访问 │ ├── middlewares/ # 中间件 │ ├── utils/ # 工具函数 │ └── app.js # 应用装配 ├── config/ ├── test/ ├── .env.example ├── .nvmrc └── package.json分层的目的不是为了好看,是为了让依赖方向单一。路由层调服务层,服务层调数据层,数据层不反过来调路由。这样你改一个业务逻辑,不用担心里面藏着一段 HTTP 相关的代码。我见过太多项目把数据库查询直接写在路由回调里,三个月后要换个数据源,改到怀疑人生。
2.3 模块系统:CommonJS 与 ESM 的现实取舍
Node.js 有两套模块系统。老的 CommonJS 用require和module.exports,新的 ESM 用import和export。新项目我当然建议 ESM,语法更清晰,支持顶层 await,静态分析也更友好。但现实是,生态里仍有大量包只提供 CommonJS 入口,你在 ESM 项目里引入它们时,默认导入的行为可能和预期不一致,需要import pkg from 'xxx'再解构,这个坑第一次遇到会很懵。
在package.json里写"type": "module"就切到 ESM,写"type": "commonjs"或不写就是 CommonJS。判断依据看文件扩展名:.js跟随type字段,.mjs强制 ESM,.cjs强制 CommonJS。混用的时候要注意,ESM 里可以importCommonJS 模块,反过来不行,CommonJS 里要加载 ESM 只能用动态import(),而且返回的是 Promise。
我的实操建议是:新项目用 ESM,如果某个关键依赖实在不兼容,用动态导入隔离它,别为了一个包把整个项目退回 CommonJS。另外 ESM 下__dirname和__filename不存在,得用import.meta.url配合fileURLToPath换算,这个几乎是每个迁移项目都会踩的一脚:
import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);3. 异步编程:Node.js 的真正门槛
语法层面的东西学两天就会,真正把新手和老手拉开差距的是异步。我判断一个人 Node.js 到什么水平,不看他会用几个框架,只看他能不能准确说出某段异步代码的执行顺序,以及出错的时候错误会不会被吞掉。
3.1 事件循环、微任务与宏任务的执行顺序
事件循环分六个阶段,实际开发中最需要记住的是三个:timers(setTimeout、setInterval)、poll(I/O 回调)、check(setImmediate)。每个阶段执行完,都会清空微任务队列(Promise 的 then、queueMicrotask、process.nextTick的特殊队列)。process.nextTick的优先级比 Promise 还高,它有自己的队列,在每个阶段切换前执行,所以滥用nextTick递归会导致 I/O 饿死,这个细节很多教程不讲。
实际写代码时,这套机制的用处在于判断“我的回调什么时候拿到最新的状态”。举个例子,你在请求进来时读了一次配置,中间有await了一个数据库查询,回来之后配置可能已经被热更新逻辑改掉了。如果这个配置是强一致的,就要在await之后重新读,或者用版本号校验,不能想当然认为变量没变。
还有一个常被忽略的点:setTimeout的延时不是精确的。它只保证“至少等这么久”,实际执行时间取决于事件循环忙不忙。所以不要用它做精确定时,也不要指望多个setTimeout按设置顺序严格执行。
3.2 并发控制:Promise.all 不是万能的
Promise.all用起来很爽,一批请求同时发出去,全部完成再继续。但它有个致命问题:只要有一个失败,整个 Promise 立刻 reject,其他还在跑的任务不会停。这意味着你发出去十个请求,第三个失败了,剩下七个照样在跑、照样在消耗连接和配额。如果失败原因是限流,那你已经又浪费了七次配额。
我的经验是分场景选:
| 方法 | 适用场景 | 注意点 |
|---|---|---|
Promise.all | 强依赖,任一失败整体失败 | 失败后剩余任务仍在执行 |
Promise.allSettled | 批量任务,允许部分失败 | 需要自己遍历结果判断成功与否 |
Promise.race | 竞速,取最快的结果 | 其余任务不会取消,只是结果被忽略 |
Promise.any | 多来源取首个成功 | 全部失败才 reject(AggregateError) |
| 手写并发池 | 大批量任务限流 | 控制同时在跑的数量 |
批量处理几十上百个任务时,我更倾向手写一个并发池,控制同时进行的数量。比如同步一万条数据到第三方接口,对方限流 20 QPS,你用Promise.all一次性发一万个请求,轻则全部失败,重则被对方拉黑。并发池的写法不复杂:
async function pool(tasks, limit = 5) { const results = []; let cursor = 0; async function worker() { while (cursor < tasks.length) { const index = cursor++; try { results[index] = { status: 'ok', value: await tasks[index]() }; } catch (err) { results[index] = { status: 'failed', error: err }; } } } await Promise.all( Array.from({ length: Math.min(limit, tasks.length) }, worker) ); return results; }这段代码里cursor++是同步执行的,所以不会出现两个 worker 抢同一个索引的问题——这是 JavaScript 单线程带来的便利,换成多线程语言就得加锁。用results[index]按原顺序放回结果,避免了完成顺序打乱顺序的问题。
3.3 错误处理的三种写法
异步代码的错误处理是重灾区。回调时代有个经典错误叫“回调地狱”,现在换成了未捕获的 rejection。核心原则就一条:每一个异步调用都必须有明确的失败路径。
第一种写法是try/catch包await。这是最清晰的,适用于你能控制调用栈的地方。注意try块里只放可能抛错的那一句,不要包一大坨,否则你不知道错在哪。
第二种是.catch()接在 Promise 后面。适合链式调用或者不需要阻塞当前流程的场景。
第三种是全局兜底。给process挂上unhandledRejection和uncaughtException监听。这里有个关键认知:这两个事件是用来记录现场然后优雅退出的,不是用来让进程继续跑的。进程状态已经不可信了,硬扛下去只会产生更难排查的问题。正确的做法是打印日志、上报错误、关闭连接池、等一会儿再退出,交给进程管理器拉起新实例。
process.on('unhandledRejection', (reason) => { console.error('未处理的 Promise 拒绝:', reason); // 上报监控,然后退出 process.exit(1); }); process.on('uncaughtException', (err) => { console.error('未捕获的异常:', err); process.exit(1); });注意:
process.exit()是立即退出,还没写完的日志和还没提交的事务都会丢。生产环境里更稳妥的做法是先停止接收新请求(关掉 server),等正在处理的请求完成或超时,再退出。这套流程一般配合进程管理器的优雅重启信号来做。
4. 手写一个能跑起来的 HTTP 服务
框架用多了容易变成“只会填空”,真出问题的时候不知道底层发生了什么。我建议每个人都至少用原生http模块写一次服务,把请求从进来到出去的链路走一遍。不用写复杂,能处理路由、能解析 JSON、能返回静态文件就够,写完再看 Express 这类框架,你会觉得它们只是在帮你整理这些重复劳动。
4.1 从原生 http 模块理解请求生命周期
一个请求进来,createServer的回调拿到两个对象:req是可读流,res是可写流。这个认知很关键——请求体不是一次性给你的字符串,而是流式到达的,你得自己收集。res.end()调用之后连接才算结束,在此之前你可以多次write分块返回。这就是为什么大文件下载可以边读边发,不用把整个文件读进内存。
响应头必须在第一次write之前设置,writeHead之后就改不了了。这个顺序问题新手经常搞错,等到想加一个 CORS 头的时候发现报ERR_HTTP_HEADERS_SENT,原因就是前面已经写过一次响应了。
import http from 'node:http'; const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); }); server.listen(3000, () => console.log('listen on 3000'));这段代码虽然短,但已经包含几个要点:用URL解析路径而不是用字符串切分(后者在带查询参数、编码字符时会出错);每个分支都return,避免继续往下走;响应体统一是 JSON。真实项目里还要处理超时、请求体大小限制、日志,这些放到下一节。
4.2 路由、中间件与请求体解析
路由的本质是“路径到处理函数的映射”。最简单可以用一个对象存,复杂一点要支持路径参数,比如/users/:id。手写的话,把路径按/切开逐段比对,遇到:开头的段就当作参数存下来。中间件则是“处理函数链”,每个函数拿到req、res、next,调next()表示交给下一个。
请求体解析要特别注意两件事:一是大小限制,不设限制的话,攻击者可以发一个几个 G 的请求体把你的内存撑爆;二是内容类型判断,不判断就直接JSON.parse,遇到表单提交会直接抛错。我一般这样写:
function readBody(req, limit = 1024 * 1024) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (chunk) => { size += chunk.length; if (size > limit) { reject(new Error('请求体超出限制')); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); }这里req.destroy()是必要的,超出限制还继续收数据纯属浪费。Buffer.concat比反复字符串拼接效率高得多,因为字符串拼接每次都新建对象,大请求体下差距明显。
4.3 静态文件与响应流的处理
返回静态文件看着简单,其实暗藏几个安全问题,最典型的是路径穿越:请求/../../etc/passwd这种,如果不做归一化校验,就把服务器上的敏感文件读出去了。防御方式是把用户传入的路径和根目录拼起来之后,用path.resolve归一化,再判断结果是否仍在根目录之下。
import path from 'node:path'; import fs from 'node:fs'; const ROOT = path.resolve('./public'); function serveStatic(reqPath, res) { const safePath = path.resolve(ROOT, '.' + reqPath); if (!safePath.startsWith(ROOT + path.sep)) { res.writeHead(403); res.end('forbidden'); return; } // 后续读文件、设置 Content-Type、用管道发送 }判断时加path.sep是为了防止ROOT是/var/www而攻击路径是/var/www-evil这种情况,光用字符串前缀会误判。这个细节我是在一次代码审计里被提醒到的,之后就养成了习惯。
5. 工程化落地:让项目从“能跑”到“能维护”
能跑起来的代码和能维护的代码之间,差的是工程约束。这部分内容在教程里常常被一笔带过,但它决定了你的项目三个月后是一堆好改的模块还是一坨不敢碰的泥球。
5.1 依赖管理与锁文件
package-lock.json必须提交到版本库,这个没有例外。它记录的是“这个包在这个版本下由哪些确切版本的子依赖组成”,没有它,npm install在不同时间、不同机器上装出来的依赖树可能不一样,问题会以“我本地没问题”的形式反复出现。
依赖版本号里^和~的区别也要清楚。^1.2.3允许升到1.x.x的任意版本,~1.2.3只允许升到1.2.x。前者接受次版本更新,好处是能自动拿到新特性和修复,风险是小版本升级也可能引入不兼容行为。我的习惯是业务项目大部分依赖用^,但对那些行为敏感的包(比如解析库、数据库驱动)锁死具体版本。
依赖体积是另一个容易被忽视的点。一个项目里堆几百个直接依赖,构建时间、安全审计、升级成本都会膨胀。定期跑一下依赖分析,看看哪些包只用了一个小函数,换成手写或者用内置模块替代。Node.js 现在的内置模块已经很全了,fetch、test runner、crypto、fs/promises都有,能少一个依赖就少一个。
5.2 配置与环境变量
配置这件事的核心原则是:代码不区分环境,配置区分。不要在代码里写if (process.env.NODE_ENV === 'production')这种分支散落在各处,应该把配置集中在一个模块里读取、校验、导出。
读取环境变量统一走process.env,但要注意它所有值都是字符串或者 undefined。PORT=3000读出来是字符串'3000',直接传给listen有时能用,但做数值比较就会出错。所以配置模块里必须做类型转换和默认值填充,还应该在启动时做一次校验,缺了关键配置就直接报错退出,别等运行到一半才发现数据库地址没配。
function required(name) { const value = process.env[name]; if (!value) { throw new Error(`缺少必需的环境变量: ${name}`); } return value; } export const config = { port: Number(process.env.PORT ?? 3000), dbUrl: required('DATABASE_URL'), logLevel: process.env.LOG_LEVEL ?? 'info', };.env文件用来放本地开发配置,必须加进.gitignore。同时提交一份.env.example,把所有键列出来但值留空,新人克隆下来照着填就行。这比在文档里描述快得多,也不会遗漏。
5.3 日志与错误兜底
console.log在开发时确实方便,但生产环境不行:没有级别、没有时间戳、没有结构化字段、性能也一般。至少要换成pino这类结构化日志库,输出 JSON 格式,方便日志系统采集和检索。
日志分级要真的用起来:debug放调试细节,info放关键业务节点,warn放可恢复的异常,error放需要人工介入的问题。不要所有东西都写info,那样等于没有分级。
更重要的原则是在能处理错误的地方处理,处理不了的地方往上抛。我在很多项目里看到try/catch包住整个函数,catch 里只写一句console.log(err)然后什么都不做,这叫吞异常,比不写还糟糕。正确的错误处理要么是恢复(重试、降级、用默认值),要么是带上上下文重新抛出,让上层统一处理。全局的错误中间件负责把错误转成统一的响应格式,并且区分“可以告诉用户的错误”和“只能记日志的内部错误”,后者对外统一返回 500,不暴露堆栈。
6. 数据层实操:数据库连接池与缓存
接口写多了你会发现,大部分性能问题不在业务逻辑,而在数据访问。这一节讲两个最常被配错的地方。
6.1 连接池参数怎么算
数据库连接是有限资源,应用服务器这边也不能无限制地开。池子的核心参数是最大连接数,配大了会拖垮数据库,配小了应用排队。一个粗算方法:最大连接数 ≈ 单实例并发处理请求数 × 每个请求同时持有的连接数。如果你的接口平均每个请求只查一次库,并发 100,那 100 个连接够用;如果事务里要持有多条连接,就得往上加。
更重要的是超时配置。连接获取超时(比如 5 秒)和查询超时(比如 10 秒)都要设,不设的话慢查询会把整个池子占满,后续请求全部卡在等连接这一步,表现出来就是“服务假死”。
const pool = createPool({ max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, });idleTimeoutMillis控制空闲连接多久回收,设太小会频繁建连,设太大数据库那边挂着很多空闲连接浪费资源。30 秒是个比较稳妥的起点。
6.2 缓存的三道防线
缓存不是“加个 Redis 就完事”。我一般按三层来思考:
第一层是进程内缓存,适合那种几乎不变的配置、字典数据、枚举映射。用Map或者轻量的 LRU 实现,命中时几乎零开销。缺点是多个实例之间不一致,所以只用于“各实例算出来的结果一样”的数据。
第二层是分布式缓存,比如 Redis。适合读多写少的业务数据,要注意缓存穿透(查不存在的数据)、缓存击穿(热点 key 过期瞬间)、缓存雪崩(大批 key 同时过期)。对应的解法是空值缓存、互斥锁或逻辑过期、过期时间加随机抖动。
第三层是 HTTP 缓存,通过ETag、Cache-Control把一部分请求挡在服务端之外。这层收益经常被低估,静态资源加了强缓存之后,带宽和 QPS 削减效果立竿见影。
注意:缓存一致性没有银弹。你能做的只有“过期时间足够短”或者“写操作主动失效”两条路。任何声称能保证强一致的方案,本质上都是在缓存前面加了一层锁,性能会退回到接近没有缓存。先想清楚业务能容忍多长时间的脏数据,再决定策略。
7. 性能与稳定性:压测、多进程与内存排查
线上出了性能问题,最怕的是没有数据瞎猜。这一节讲的是定位问题的顺序和常用手段。
7.1 cluster 与 worker_threads 的分工
单进程只能用一个 CPU 核,这是 Node.js 的先天限制。要压满多核,用cluster模块起多个子进程,它们共享同一个端口,由主进程分发连接。最简单的方式是不自己写,直接用进程管理器按核数起多个实例,省掉手写 cluster 的复杂度。
worker_threads解决的是另一个问题:把 CPU 密集的任务挪到其他线程,避免阻塞主线程。它和 cluster 的区别很好记:cluster是为了分摊请求,worker_threads是为了不卡住当前请求。
用worker_threads要注意通信成本。线程之间的数据传递默认是结构化克隆,大对象传递的序列化开销可能比计算本身还大。传大块二进制数据时用SharedArrayBuffer或者transferList转移所有权,避免拷贝。
7.2 内存泄漏与 CPU 飙高的排查路径
内存泄漏的典型症状是内存曲线只涨不跌,重启之后恢复,跑一段时间又涨回去。排查顺序我通常是:先看监控确认增长趋势,再用--inspect挂上调试器拍堆快照,对比两个时间点的快照,找对象数量异常增长的类型。
常见的泄漏原因有几个:全局数组或 Map 只增不减(比如用来做统计的计数器);事件监听器注册了没移除(EventEmitter默认最多 10 个监听器的警告就是在提醒你);闭包意外持有了大对象;定时器没清理。最后这个特别隐蔽,一个setInterval引用了一个大对象,只要定时器不clear,那个对象就永远释放不掉。
CPU 飙高则相反,通常是某个循环或者正则出了问题。用--prof生成性能分析文件,或者用clinic这类工具生成火焰图,能直接看到热点函数。我遇到过一次线上 CPU 打满,最后定位是一个正则表达式在处理超长字符串时发生灾难性回溯,把输入截断之后就正常了。这类问题的通用建议是:正则不要嵌套量词,处理用户输入前先做长度限制。
8. 常见问题速查与避坑实录
这些是我在实际项目里反复遇到、也反复帮别人解决的问题,整理成表格方便对照。
8.1 高频报错对照表
| 报错信息 | 常见原因 | 处理方向 |
|---|---|---|
Cannot find module | 依赖没装、路径大小写不对、ESM 缺扩展名 | 检查node_modules,核对路径,ESM 下补.js |
ERR_HTTP_HEADERS_SENT | 响应头设置晚于第一次写入 | 统一在写响应体之前设置头 |
EADDRINUSE | 端口被占用 | 换端口或找出占用进程结束掉 |
ERR_REQUIRE_ESM | CommonJS 里require了纯 ESM 包 | 改用动态import()或降级依赖版本 |
heap out of memory | 一次性加载了超大文件或数据 | 改流式处理,调大内存上限只是临时方案 |
MaxListenersExceededWarning | 监听器重复注册没移除 | 检查注册逻辑,必要时显式移除 |
ECONNRESET | 对端提前关闭连接 | 检查超时设置和连接复用逻辑 |
表格里每一条我都在真实场景中见过。特别说下heap out of memory,很多人第一反应是加--max-old-space-size,把上限调大,这只是把崩溃时间往后推。真正的解法是找出谁在一次性持有大对象,改成流式或者分批。
8.2 线上问题的排查顺序
出问题的时候最忌讳上来就改代码。我的固定顺序是:先确认影响范围(多少用户、哪些接口、从什么时候开始),再看监控指标(QPS、错误率、响应时间、CPU、内存、连接数),然后看最近有没有发布或配置变更,最后才去翻日志定位具体代码。
日志要带上请求 ID,从入口生成,一路透传到下游调用,这样一次请求的所有日志能串起来。没有请求 ID 的日志在并发场景下基本没法用,你分不清哪条日志属于哪个请求。
还有一个经验:先止损再定位。如果确认是新版本引入的问题,直接回滚,不要在现场一边排查一边改。回滚之后在测试环境慢慢复现,压力会小很多。我见过太多次为了“找到根因”硬扛着不回的,结果故障时间被拉长了十倍。
最后分享一个我一直在用的习惯:新项目初始化的时候就把这几件事做完——.nvmrc锁版本、.env.example列全配置、scripts里配好 lint 和 test、错误处理中间件写好、优雅退出逻辑加上。这些加起来不到一小时,但能在后面省掉无数个深夜排查的夜晚。技术选型上别追新,工具的稳定性比功能多寡重要得多,一个三天两头出问题的框架,再时髦也不值得放进生产环境。