Node.js 避坑指南(二)
Node.js 避坑指南(二)
适用版本:Node.js ≥ 16
阅读建议:先 clone 示例仓库 → 逐条跑测试 → 对照火焰图/日志/内存快照 → 再阅读“总结”固化肌肉记忆。
1. 流反压:highWaterMark 的背压血案
| 关键词 | backpressure、pipe、 drain |
|---|
1.1 踩坑现场
// 需求:把 30 GB 的 CSV 导入数据库
const fs = require('fs');
const readStream = fs.createReadStream('30g.csv', { highWaterMark: 16 * 1024 * 1024 }); // 16 MB
const parse = require('csv-parser');
const mysql = require('mysql2/promise');
const pool = mysql.createPool({ connectionLimit: 10 });
readStream
.pipe(parse())
.on('data', async row => {
// 直接 await 插入,毫无背压
await pool.execute('INSERT INTO orders SET ?', row);
});
现象
- 内存占用 5 分钟飙到 3.8 GB
- 插入速率从 8 k 行/s 掉到 200 行/s
- 最终 OOM,容器重启,30 GB 需重新导
1.2 发生了什么?
csv-parser 的 Transform 默认 highWaterMark=16 kB,而 Readable 是 16 MB。
下游 data 事件里又 await 异步 IO → 事件循环被拖慢 → 上游继续灌水 → 内存爆仓。
误区:“流就是省内存”→ 没有背压的流=泄洪管。
1.3 正确姿势:官方 pipeline + 手动背压
const { pipeline } = require('stream/promises');
const { Transform } = require('stream');
let pending = 0;
const insert = new Transform({
objectMode: true,
highWaterMark: 128, // 控制并发
transform(chunk, _, cb) {
if (pending > 128) { cb(); return; } // 简单限流
pending++;
pool.execute('INSERT INTO orders SET ?', chunk)
.then(() => { pending--; cb(); })
.catch(cb);
}
});
(async () => {
await pipeline(
fs.createReadStream('30g.csv'),
parse(),
insert
);
console.log('导入完成');
})();
1.4 总结
- 永远用
stream/promises的pipeline,自动传播错误与背压。 - 下游异步耗时,必须返回
cb()或push(null)通知上游暂停。 - 通过
clinic.js bubbleprof可直观看到“水柱”堆积点。
2. ESM ↔ CJS 混搭:忽然找不到 exports
| 关键词 | type:module、require(esm)、双包陷阱 |
|---|
2.1 踩坑现场
// package.json
{ "type": "module" }
// utils.js ESM
export function foo() {}
// legacy.cjs
const { foo } = require('./utils.js'); // Error [ERR_REQUIRE_ESM]
2.2 官方规则速记
| 引用方 | 被引方 | 结果 |
|---|---|---|
| CJS | CJS | ✅ |
| ESM | CJS | ✅ (默认导出=module.exports) |
| CJS | ESM | ❌ require() 不支持 |
| ESM | ESM | ✅ |
2.3 正确姿势:双入口 + 条件导出
{
"name": "super-utils",
"type": "module",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"scripts": {
"build": "rollup -c" // 把 src 同时编译成 cjs & esm
}
}
提示:若只想渐进迁移,可把文件后缀改成
.mjs,保留package.json不声明"type":"module",这样老代码require()照常用,新代码import也能用。
2.4 总结
- CJS 永远无法
require纯 ESM(Node ≥ 16 无计划支持)。 - 库作者务必提供
exports双入口,避免“双包陷阱”导致重复代码。 - 应用级项目建议一次性全迁 ESM,可享 Top-level await、Tree-shaking 等红利。
3. 幽灵依赖:node_modules 里的“薛定谔包”
| 关键词 | hoisting、peerDependencies、pnpn |
|---|
3.1 踩坑现场
// 项目里没有直接安装 lodash
const _ = require('lodash'); // 居然能跑?
原因是依赖图:my-app → A → lodash
npm 默认把 lodash 提升到顶层,看似可用,但某天 A 升级改用 lodash-es,lodash 消失 → 线上爆炸。
3.2 加固方案:eslint-plugin-node + pnpm
-
安装
pnpm add -D eslint-plugin-node -
.eslintrc.json{ "plugins": ["node"], "rules": { "node/no-extraneous-require": ["error"], "node/no-missing-require": ["error"] } } -
使用 pnpm 默认非扁平 node_modules,幽灵包直接报错。
3.3 总结
- 任何直接引用的包必须写进
dependencies,哪怕它现在“刚好”被提升。 - 库作者把工具库放到
peerDependencies,让宿主决定版本。 - pnpm + eslint 能在 CI 阶段把“幽灵依赖”彻底拍死。
4. 集群端口抢占:EADDRINUSE 重启抖动
| 关键词 | cluster、SO_REUSEPORT、zero-downtime |
|---|
4.1 踩坑现场
const cluster = require('cluster');
const http = require('http');
if (cluster.isMaster) {
for (let i = 0; i < 4; i++) cluster.fork();
} else {
http.createServer((req, res) => {
res.end('ok');
}).listen(8080); // 子进程抢同端口
}
异常
滚动重启时,老进程未完全退出,新进程 listen 报 EADDRINUSE,导致 k8s 多次重启失败。
4.2 正确姿势:Node ≥ 16 支持 reusePort
http.createServer(handler).listen({
port: 8080,
reusePort: true // 内核级负载均衡
});
老版本可用 master 唯一监听 + 文件描述符传递 方案,或直接用 pm2 / egg-cluster。
4.3 总结
- 多进程监听同一端口时,必须开启
SO_REUSEPORT或让 master 统一持 fd。 - 在容器环境,优雅关闭顺序:先摘流 → 关闭 server → 退出进程。
- 用
stmgr/kubernetes/graceful-shutdown探针配合server.close()保证零中断。
5. GC 调优:V8 的“停顿”惊喜
| 关键词 | scavenge、mark-sweep、heap snapshot |
|---|
5.1 踩压现场
// 日志聚合服务:缓存 5 分钟日志在内存
const cache = new Map();
setInterval(() => {
const now = Date.now();
for (const [k, v] of cache.entries()) {
if (now - v.ts > 300000) cache.delete(k);
}
}, 1000);
监控
- 堆 1.4 GB 时,Scavenge GC 每次 80 ms
- 接口 p99 从 120 ms 飙到 550 ms,用户明显卡顿
5.2 诊断
$ node --trace-gc --inspect app.js
Chrome DevTools → Memory → Take heap snapshot
发现 Map 里持有 3.2 M 个字符串,总大小 900 MB,老生代不断触发 mark-sweep。
5.3 正确姿势:LRU + 弱引用 + 增量清理
const LRU = require('lru-cache');
const cache = new LRU({
max: 500 * 1024 * 1024, // 500 MB
length: (n) => n.length,
dispose: (k, v) => {
// 可选:同步刷盘
}
});
// 取消定时全表扫描,改用 LRU 自动淘汰
若必须手动管理,用 setImmediate 做增量删除:
let keys = [];
function incrementalClean() {
const batch = keys.splice(0, 1000);
batch.forEach(k => { /* delete */ });
if (keys.length) setImmediate(incrementalClean);
}
5.4 总结
- 老生代 > 1 GB 时,GC 停顿可达百毫秒,对 API 是灾难。
- 把“大对象池”改成 LRU + 弱引用,或拆分到 worker_threads 私有堆。
- 用
--max-old-space-size=2048限制老生代,超限即重启,fail fast 比卡顿更友好。
尾声
第二篇继续围绕流、模块化、依赖治理、多进程、GC 五个核心场景深挖。
把这些示例加入你的 code-review checklist,可让线上故障率再降一个量级。
《Node.js 避坑指南(三)》将带来:
- DNS 查询阻塞与
http.Agent池 - TLS 内存泄漏与 Session Resumption
- Native Addon 异常崩溃堆栈
- 日志写爆磁盘 async_hooks
- 冷启动性能与 Snapshot 编译
敬请期待,Happy Shipping!
更多推荐



所有评论(0)