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-parserTransform 默认 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/promisespipeline,自动传播错误与背压。
  • 下游异步耗时,必须返回 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-eslodash 消失 → 线上爆炸。

3.2 加固方案:eslint-plugin-node + pnpm

  1. 安装

    pnpm add -D eslint-plugin-node
    
  2. .eslintrc.json

    {
      "plugins": ["node"],
      "rules": {
        "node/no-extraneous-require": ["error"],
        "node/no-missing-require": ["error"]
      }
    }
    
  3. 使用 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); // 子进程抢同端口
}

异常
滚动重启时,老进程未完全退出,新进程 listenEADDRINUSE,导致 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!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐