症状
崩溃信息本身很泛,但触发点很具体:在搬运大负载的“看似流式”代码里出现 JavaScript heap out of memory。相隔几秒抓两个堆快照,若被保留对象主要是 writable stream 持有的 Array 缓冲,即可确认是读端跑得比写端快。用 setInterval 打印 out.writableLength 做实时观测:有界且周期性归零说明背压健康;持续逼近输入总量则说明每个分块都被积压在内存里。
诊断思路
先确认堆增长形态排除监听器或闭包泄漏;再全局审计写入调用,任何热路径上不检查 write 返回值、也没用 pipeline 的位置都是嫌疑点;最后观察 writableLength 的走势,无界增长就是缺背压的直接证据。同时检查中间 transform 是否无状态——在 transform 里做字符串拼接、或先用 await streamToString(body) 收齐整个响应体再回复的封装,都会把流式设计退化成全量缓冲。
根因
可读端产出数据的速度快于可写端刷写速度,而代码没有传播 write 返回 false 或 drain 事件,所有来不及写出的分块都被排入可写流的内部队列。于是尽管架构上是流式的,实际内存占用仍然随完整输入大小线性增长,最终触发堆耗尽。
修复与验证
优先用 stream.pipeline 或 Readable.pipe 接管管道:它自动处理 destroy、错误传播与 drain 语义。手工循环时严格遵守两条规则:write 返回 false 就停止读取,直到 drain 触发再继续。为 createReadStream 设置合理 highWaterMark,并保证 transform 无状态。验证时用至少三倍堆上限的输入重跑导出任务:进程应顺利完成,heapUsed 全程低于设定上限,writableLength 在零附近震荡而非持续爬升。同时补一个回归测试,防止不等待 drain 的写法在代码评审中回归。不要用调大 –max-old-space-size 掩盖症状——4 GB 堆面对 6 GB 输入依然会崩。
代码示例
FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory
setInterval(() => {
console.log('queued bytes', out.writableLength);
}, 500);
import { pipeline } from 'node:stream/promises';
await pipeline(
fs.createReadStream(inputPath, { highWaterMark: 1 << 20 }),
new LineTransform(), // stateless: emits per line, never accumulates
fs.createWriteStream(outputPath)
);
for await (const chunk of source) {
if (!out.write(chunk)) {
await once(out, 'drain');
}
}