一个上传接口在小文件时一直正常,换成几百兆的压缩包后,容器内存开始明显上涨。原因并不隐蔽:代码把请求体完整读进 Buffer,校验后再一次性写入存储。文件多大,单次请求就至少占用多大一块内存,并发几次自然吃不消。
我们并不需要同时拥有整个文件
上传的目标是把客户端来的字节交给文件或对象存储。除了少数必须看完整内容的处理,大部分校验都能随着数据到达逐段完成。Stream 就像一条传送带:上游不断放小箱子,下游逐个接走,内存里只需要保留正在处理的那部分。
Node 的 HTTP 请求本身就是可读流,文件写入是可写流。最小实现不需要手动监听所有事件,用 Promise 版本的 pipeline 把它们连接起来即可。
优先使用 pipeline
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
await pipeline(
request,
createWriteStream(targetPath, { flags: "wx" })
);
pipeline 会把上游错误传递出来,并在失败时销毁相关流,比手写 readable.pipe(writable) 后分别处理事件稳妥。若要计算哈希或限制大小,可以插入一个 Transform 流,让每个数据块经过检查再继续。
let received = 0;
const guard = new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length;
if (received > MAX_SIZE) {
callback(new Error("file too large"));
return;
}
callback(null, chunk);
}
});
背压就是“你先慢一点”
如果网络读入很快、磁盘写入很慢,无限制读取仍会把数据堆在内存。可写流的内部缓冲达到阈值后会告诉上游暂停,等缓冲排空再继续,这就是背压。用 pipeline 或正确的 pipe 时,这套配合已经存在,不需要自己实现队列。
Stream 节省内存的前提,是整个链路都尊重背压。中间某一步把所有 chunk 收集进数组,优势就消失了。
成功写入之外,还要处理半成品
客户端中断、磁盘写满、大小超限都会留下部分文件。接口捕获错误后要删除临时文件,并确保最终文件名只在完整成功后出现。更稳妥的方式是先写随机临时名,完成校验后再原子重命名。
还要限制请求总时长和并发数。Stream 让单个请求更节省内存,但不会自动阻止大量慢连接占满文件描述符。资源边界仍然需要在服务器和反向代理两层设置。