Node.js 流式处理大文件 HTTP 响应的实践
背景
在 Node.js 服务里下载或转发大文件时,最容易踩的坑是把整个响应读进内存。本文结合一次真实排查,介绍如何用流(Stream)正确处理 HTTP 响应体,以及几个容易忽略的细节。
问题现场
服务需要从对象存储拉取几百 MB 的压缩包,再通过 HTTP 接口提供给下游。最初实现用了 axios 默认行为:
const res = await axios.get(url);
fs.writeFileSync(out, res.data);
文件小的时候测试都通过。真实环境出现 800 MB 的包时,Node 进程内存涨到 1.5 GB 后被系统杀掉。原因是 axios 默认把响应体完整读进内存,writeFileSync 又是同步写盘,两个问题叠加放大了内存峰值。
改成流式处理
正确做法是让数据以管道方式流动,而不是一次性落内存。Node.js 的 http/https 模块本身就返回流式响应,配合 fs.createWriteStream 即可:
const fs = require("fs");
const https = require("https");
function download(url, dest) {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
const file = fs.createWriteStream(dest);
res.pipe(file);
file.on("finish", () => file.close(resolve));
file.on("error", reject);
});
req.on("error", reject);
});
}
关键点有三个:res 是可读流,不需要手动拼接 chunk;pipe 会自动处理背压(backpressure),写盘速度跟不上时暂停网络读取;statusCode 不为 200 时调用 res.resume(),避免连接悬挂。
如果项目里已经在用 axios,也可以加一个 responseType 配置:
const res = await axios.get(url, { responseType: "stream" });
res.data.pipe(fs.createWriteStream(dest));
这样 axios 返回的 data 是可读流,而不是 Buffer。
转发场景:两个流之间架管道
另一个常见需求是把上游响应原样转发给客户端,比如做下载代理。直接把上游响应 pipe 给下游响应即可:
app.get("/proxy", async (req, res) => {
const upstream = await axios.get(url, { responseType: "stream" });
res.setHeader("Content-Type", upstream.headers["content-type"]);
res.setHeader("Content-Length", upstream.headers["content-length"]);
upstream.data.pipe(res);
});
要把 Content-Length 从上游透传给下游,否则客户端无法显示进度。如果上游使用了 chunked 编码,则不要设置 Content-Length,让 Node 自动处理。
常见坑
忘记处理错误事件:流中途断开时,如果不监听 error,进程可能崩溃或文件残缺。生产代码里建议用 pipeline 代替 pipe。
错误后不销毁文件流:写盘失败时目标文件可能只写了一半,需要清理临时文件。
忽略 Content-Length 校验:下载完成后对比字节数,能及早发现被截断的响应。
在 async 函数里忘记 await:pipe 是异步的,但很多人当成同步调用,导致后续逻辑在文件写完前执行。
Node 10 之后官方推荐 stream.pipeline,它会自动处理错误传播和流清理:
const { pipeline } = require("stream/promises");
await pipeline(res, fs.createWriteStream(dest));
验证结果
改造成流式后,800 MB 文件的下载内存峰值从 1.5 GB 降到 90 MB 左右,基本就是 TCP 缓冲区和高水位线的开销。同时用 curl 验证了下载文件的 SHA256 与源文件一致,确认没有截断。
小结
处理大文件 HTTP 响应的核心思路就一句话:别让数据在你手里堆积。用 stream 把网络读取、磁盘写入、下游转发串成管道,让每个环节只持有少量缓冲。代码量并没有增加多少,但内存曲线和稳定性完全不同。
- 点赞
- 收藏
- 关注作者
评论(0)