用Node.js写文件下载服务,如果只是处理几张图片、几个文档,大多数人会直接用fs.readFileSync读出内容再塞给响应对象,简单粗暴也能跑。可一旦文件变成几个G的安装包或者视频资源,这套写法立刻翻车:进程内存瞬间被撑爆,系统开始疯狂swap,最后V8直接抛出堆内存不足的错误退出。解决这个问题的核心思路只有一个字——流。把文件当成一股源源不断的水流,读一段、发一段、释放一段,内存占用从头到尾都维持在一个极低的水平。本文结合实际项目经验,详细拆解流式下载的实现细节和容易踩的坑。

为什么readFileSync处理大文件必然内存溢出
先看一段典型的错误代码,很多初学者写的下载接口长这样:
const express = require('express');
const fs = require('fs');
const app = express();
app.get('/download', (req, res) => {
const data = fs.readFileSync('./big-file.zip'); // 一次性读进内存
res.setHeader('Content-Type', 'application/octet-stream');
res.send(data);
});
app.listen(3000);这段代码的问题在于readFileSync会把整个文件内容完整地加载进V8堆内存。一个2GB的文件意味着进程至少要临时持有2GB的堆空间,而Node.js默认的堆内存上限通常在1.5GB到4GB之间(取决于版本和系统位数),超过限制就直接抛出JavaScript heap out of memory。更麻烦的是,即便文件没有超过堆上限,Buffer的分配、GC的压力也会让服务响应变得极慢,其他请求被阻塞。
再补充一点:即使换成异步的fs.readFile,问题依旧存在,它只是不阻塞事件循环,文件内容最终还是完整地驻留在内存里。所以真正的解法不是换异步API,而是改变数据处理模式——不要一次性持有全部数据,改成边读边发。这正是Stream存在的意义。
用fs.createReadStream实现流式下载的正确姿势
Stream是Node.js里处理流式数据的抽象接口,fs.createReadStream创建的可读流会按块读取文件,默认的highWaterMark是64KB,也就是说内存中同一时刻最多只保留一个读缓冲区的内容,不管文件是1GB还是100GB,内存占用几乎不变。
下面是一个基础但正确的实现:
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
app.get('/download', (req, res) => {
const filePath = path.join(__dirname, 'big-file.zip');
const stat = fs.statSync(filePath);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', 'attachment; filename="big-file.zip"');
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
});
app.listen(3000);这里有几个细节值得注意。第一,Content-Length头最好设置,客户端的进度条依赖它来计算百分比,不设也能下载,但用户体验会差很多。第二,Content-Disposition设置为attachment可以让浏览器直接触发另存为对话框。第三,文件路径一定要做安全校验,防止路径穿越攻击,比如把req.query.filename里的../过滤掉,或者干脆用白名单映射。
背压处理:pipe的隐藏缺陷与pipeline的登场
pipe方法虽然能跑,但它有一个著名的设计缺陷:不处理错误,也不完美处理背压。所谓背压,是指下游写入速度跟不上上游读取速度时产生的积压。TCP缓冲区写满后,res的写操作会返回false,可读流理论上应该暂停读取,pipe内部确实实现了暂停逻辑,但如果流中途出错,pipe不会自动销毁另一端的流,可能造成文件句柄泄漏或者请求挂死。
官方推荐的替代方案是stream.pipeline,它会在任一流出错时销毁所有流,并统一回调错误:
const { pipeline } = require('stream');
const fs = require('fs');
const express = require('express');
const app = express();
app.get('/download', async (req, res) => {
const filePath = './big-file.zip';
try {
await pipeline(
fs.createReadStream(filePath),
res
);
console.log('下载完成');
} catch (err) {
// 客户端中途断开也会走到这里,做清理即可
console.error('下载中断:', err.message);
}
});
app.listen(3000);配合async/await之后代码非常清爽。还需要监听res的close事件来判断客户端主动断开的情况——用户点了取消、网络闪断都会触发,此时pipeline会收到错误并清理资源,不需要额外写一堆destroy逻辑。这就是pipeline相比pipe最大的工程价值:把错误处理从手动变成自动,把内存和句柄泄漏的风险降到最低。
断点续传与Range请求的完整实现
大文件下载绕不开断点续传。HTTP协议通过Range请求头实现,客户端带上Range: bytes=1000-表示从第1000字节开始下载,服务端返回206状态码和Content-Range头。下面是一个支持Range的完整版本:
app.get('/download', (req, res) => {
const filePath = './big-file.zip';
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
// 解析 Range: bytes=start-end
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (start >= fileSize || end >= fileSize) {
res.status(416).set('Content-Range', `bytes */${fileSize}`);
return res.end();
}
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', end - start + 1);
pipeline(
fs.createReadStream(filePath, { start, end }),
res
).catch(err => console.error('续传中断:', err.message));
} else {
res.setHeader('Content-Length', fileSize);
res.setHeader('Accept-Ranges', 'bytes');
pipeline(
fs.createReadStream(filePath),
res
).catch(err => console.error('下载中断:', err.message));
}
});createReadStream的start和end选项支持指定字节区间,这是实现断点续传的关键,文件系统会自动seek到对应位置再开始读,前面的字节完全不经过内存。多线程下载工具比如IDM、aria2也是靠这个机制把文件切成多段并发拉取,服务端不需要任何额外改造。
性能验证与常见陷阱总结
写完代码一定要验证内存表现。下载时在另一个终端跑top或者pm2 monit观察进程的RSS,正确的流式实现无论文件多大,常驻内存应该在几十MB以内波动。也可以用process.memoryUsage()定时打印,对比heapUsed的变化趋势。如果发现内存随下载进度线性上涨,说明某处又把数据缓存起来了,常见嫌疑犯是手写的on('data')加数组拼接,这种写法等于把流退化成了readFile,务必改回pipe或pipeline。
最后梳理几个高频陷阱:一是忘记处理客户端断开,导致读流继续空转消耗CPU和IO;二是响应设置了gzip等压缩中间件时,某些中间件会缓冲整个响应体,需要确认其对Stream的透传行为;三是Windows环境下路径要写成path.join(__dirname, 'files')这类形式,避免手拼反斜杠出错;四是静态文件场景其实可以直接用express.static或res.download,它们内部已经实现了流式发送和Range支持,没必要重复造轮子,自己实现的场景通常是需要鉴权、限速或记录下载日志的时候。掌握pipeline加createReadStream这套组合拳,大文件下载的内存问题就基本告别了。
Node.js流式下载内存溢出Stream管道修改时间:2026-09-09 16:25:16