Node.js 避坑指南(六)

适用版本:Node.js ≥ 20.15
阅读方式:每条均给出「最小复现 → 日志/截图 → 修复 diff → 性能对比」,建议边读边跑。
图文用 Mermaid,复制到 Mermaid Live Editor 即可渲染。


1. 文件句柄耗尽:EMFILE 的“无声爆炸”

关键词 EMFILE、ulimit、graceful-fs、lsof

1.1 踩坑现场

// 日志收集脚本,每次请求都 createReadStream
app.get('/api/log', (req, res) => {
  const rs = fs.createReadStream('app.log'); // 未关闭
  rs.pipe(res);
});

监控

  • 运行 6 h 后 error: EMFILE, too many open files
  • lsof | wc -l → 65 k,全被 app.log 占用
  • 新连接直接 502,重启才恢复

1.2 为什么爆

  • createReadStream 默认 autoClose=false客户端断开时仍保持 FD。
  • 系统 ulimit -n 默认 1024(容器甚至 256)。
  • 异常路径未 rs.destroy() → 句柄永不释放。

1.3 正确姿势:graceful-fs + 自动轮转 + 上限保护

import gracefulFs from 'graceful-fs';
import LRU from 'lru-cache';

const fdCache = new LRU({ max: 200, dispose: (_, fd) => gracefulFs.close(fd) });

function sendLog(req, res) {
  const fd = fdCache.has('app.log') ? fdCache.get('app.log')
           : gracefulFs.openSync('app.log', 'r');
  fdCache.set('app.log', fd);
  gracefulFs.createReadStream(null, { fd, autoClose: false }).pipe(res);
  req.on('close', () => /* fd 由 LRU 统一关闭 */ );
}

容器启动加 --ulimit nofile=65536:65536,并加 livenessProbe 检测已用 FD 比例。

1.4 总结

  • 任何 createReadStream/createWriteStream 都确保 autoClose=true 或手动 close
  • graceful-fs 遇到 EMFILE 会自动排队重试,避免一次性雪崩。
  • lsof -p <pid> | awk '{print $5}' | sort | uniq -c 快速定位泄漏类型。

2. HTTP/2 流重置:RST_STREAM 的“血崩”

关键词 RST_STREAM、nghttp2、backpressure、flow-control

2.1 踩坑现场

const http2 = require('http2');
const server = http2.createServer();
server.on('stream', (stream) => {
  fs.createReadStream('big.iso').pipe(stream); // 无 backpressure
});

现象

  • 客户端网速慢 → 流缓冲区堆积 → nghttp2 发送 RST_STREAM
  • 服务端报错 ERR_STREAM_RESET → 重试又失败 → 用户下载到 99 % 失败

2.2 原因

  • HTTP/2 每流默认 64 KB 接收窗口,客户端不读就自动重置
  • pipe 无视 stream.write() 返回值,背压失效

2.3 正确姿势:手动背压 + 窗口更新

server.on('stream', async (stream) => {
  const rs = fs.createReadStream('big.iso');
  rs.on('data', chunk => {
    if (!stream.write(chunk)) {          // 背压信号
      rs.pause();
      stream.once('drain', () => rs.resume());
    }
  });
  rs.on('end', () => stream.end());
});

或者使用 pipeline 自动背压:

import { pipeline } from 'stream/promises';
server.on('stream', (stream) => {
  pipeline(fs.createReadStream('big.iso'), stream).catch(() => {});
});

2.4 总结

  • HTTP/2 流级窗口有限,慢客户端必然 RST。
  • 任何 pipe 改为 pipeline,让背压自动传播
  • clinic.js bubbleprof 可看到 flow-control 阻塞占整个栈 50 % 以上。

3. 二进制预构建:node-gyp 在 CI 里“编译 20 分钟”

关键词 prebuild、node-gyp、github releases、nan

3.1 踩坑现场

# GitHub Actions
- run: npm ci
# 依赖 sqlite3,无预构建包 → 触发 node-gyp rebuild
# 耗时 18 min,队列排队 → 发布失败

3.2 原因

  • 默认 npm install 会回退到源码编译当无匹配预构建包。
  • 容器缺少 python/make → 编译失败 → 又触发 fallback 下载源码循环。

3.3 正确姿势:prebuild-install + 自托管

  1. 安装

    pnpm add -D prebuild-install
    
  2. 在原生包里加钩子

    "scripts": {
      "install": "prebuild-install || node-gyp rebuild"
    }
    
  3. CI 先上传预构建包

    - run: prebuild --runtime node --target 20 --upload-all
    

把二进制推到 GitHub Releases,首次安装 3 s 完成

3.4 总结

  • 任何带 C++ 依赖的库,务必提供预构建包,否则 CI 排队到怀疑人生。
  • @mapbox/node-pre-gypprebuild-install 均可,关键要上传
  • 自建 npm registry 可镜像二进制,内网离线也能秒装

4. 诊断报告:–report-on-fatalerror 的“黑匣子”

关键词 llnode、segfault、report、core dump

4.1 踩坑现场

// addon 空指针
addon.crash();
// 进程直接消失,日志只一句:
// "Segmentation fault (core dumped)"

线上无 core,无法复现无法定位

4.2 正确姿势:Node 内置诊断报告

启动加参数

node --report-on-fatalerror --report-directory=/tmp/report app.js

崩溃后生成 report.20240712.202341.19.json,包含:

  • 原生栈(llnode 可加载)
  • V8 Heap 用量、FD 列表、uv handle 计数
  • 环境变量、命令行、Node 版本

结合 llnode 看原生栈:

npm i -g llnode
llnode /usr/bin/node core.19
(llnode) v8 bt

4.3 总结

  • 生产环境必开 --report-on-fatalerror,日志目录挂到 S3。
  • 诊断报告比传统 core 文件轻 100 倍解析快 10 倍
  • --report-on-signal 可在运行期手动 kill -USR1 拉取快照,无损排查

5. 透明大页:THP 让 GC 停顿飙到 200 ms

关键词 THP、madvise、sysfs、grace-period

5.1 踩坑现场

  • 堆 3 GB 时,Young GC 从 20 ms 涨到 200 ms
  • dmesg 看到 khugepaged 占用 CPU 30 %
  • 同一机型,关 THP 后 GC 降回 18 ms

5.2 原因

  • Linux 默认 enabled=always → 任意 2 MB 连续页会被合并成大页
  • V8 的 Scavenge 需要 pin 页,THP 合并/拆分过程阻塞主线程

5.3 正确姿势:禁 THP 或显式 madvise

# 临时关
echo never > /sys/kernel/mm/transparent_hugepage/enabled

或在代码里让 V8 主动放弃大页

node --max-old-space-size=3072 --no-huge-page app.js

Node ≥ 20 已内置 --no-huge-page一键禁用

5.4 总结

  • 低延迟 API,THP 是“性能刺客”,GC 停顿可翻 10 倍
  • 容器启动时加 securityContext.sysctl 把 THP 设为 never一次解决
  • perf record -g 可看到 __collapse_thp_page 占满火焰图顶端。

7. 小结

第 6 篇把「文件句柄、HTTP/2 流、预构建、诊断报告、透明大页」五个系统级暗坑一次性摊开。
记住口诀:

FD 要排队,流控要背压,预构建要上传,崩溃要报告,大页要关闭。

《Node.js 避坑指南(七)》将聚焦:

  • HTTP/2 流重置(RST_STREAM、nghttp2、背压)
  • 二进制预构建发布(prebuild、node-gyp、GitHub Releases)
  • 诊断报告与事后调试(–report-on-fatalerror、llnode)
  • 内存大页与透明巨页(madvise、transparent huge pages)
  • 文件句柄耗尽(EMFILE、ulimit、graceful-fs)

第 7 篇见,Happy Shipping!

Logo

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

更多推荐