Node.js 避坑指南(六)
·
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 + 自托管
-
安装
pnpm add -D prebuild-install -
在原生包里加钩子
"scripts": { "install": "prebuild-install || node-gyp rebuild" } -
CI 先上传预构建包
- run: prebuild --runtime node --target 20 --upload-all
把二进制推到 GitHub Releases,首次安装 3 s 完成。
3.4 总结
- 任何带 C++ 依赖的库,务必提供预构建包,否则 CI 排队到怀疑人生。
- 用
@mapbox/node-pre-gyp或prebuild-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!
更多推荐



所有评论(0)