外观
4GB 内存稳住 10GB 上传:Node.js 流式处理与大文件上传的内存控制实录
文件服务上线第三天,容器在凌晨被 K8s 以 OOMKilled 重启了七次。排查发现根源很朴素:上传接口把整个请求体读进内存再写盘——前端把一个 3GB 的视频文件整包 POST 过来,Node 进程内存瞬间冲破容器限额。后面还排着一批 10GB 级的数据集上传需求,不改架构这条路根本走不通。
这篇文章完整记录那次改造:从「为什么整包必炸」的内存模型,到流式三件套的正确用法,再到分片上传与断点续传的服务端实现。改造后,同一个服务在 4GB 内存限额下稳定承接 10GB 文件上传,全程内存占用平稳。
为什么整包处理必炸:Buffer 不住在 V8 堆里
先补一块内存模型的底。Node.js 里 fs.readFile 读出的 Buffer 分配在 V8 堆外的内存(由底层直接分配),它不受 V8 堆限制,因此 --max-old-space-size 调堆参数救不了它;同时单个 Buffer 有硬性长度上限(GB 级量级,不同 Node 版本数值不同),超限直接抛异常。更致命的是并发:内存占用 = 并发数 × 文件大小,两个 2GB 上传撞在一起,4GB 限额的容器就到了悬崖边。
而流式处理的内存模型完全不同:数据以 chunk(默认 64KB 量级)为单位流动,任意时刻内存里只有正在处理的几个 chunk。10GB 文件和 10KB 文件占用的内存没有本质区别——这就是「4GB 内存稳住 10GB 上传」的数学基础。
流式三件套:pipeline 是唯一正解
流处理的标准组合是 Readable(产生数据)→ Transform(加工数据)→ Writable(消费数据)。下面是日志压缩的典型场景,边读边压缩边写盘,内存占用恒定:
js
// stream-demo.mjs:10GB 日志边读边压缩,内存占用恒定
import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("access-10gb.log"),
createGzip(), // Transform:流式压缩,不攒全量数据
createWriteStream("access-10gb.log.gz")
); // pipeline 内部自动:错误传播到整条链、任一环节失败时关闭全部流为什么强调用 pipeline 而不是老的 pipe?因为 pipe 不转发错误:中间某个流出错,其他流不会自动销毁,文件句柄泄漏、进程挂起等诡异问题由此而来。node:stream/promises 的 pipeline 把「错误传播 + 资源清理」都包了,调用侧一个 await 一个 catch 搞定:
js
try {
await pipeline(/* ... */);
console.log("[流处理] 压缩完成");
} catch (err) {
console.error("[流处理] 失败:", err.message); // 统一在此收口
}大文件上传:分片 + 断点续传的服务端实现
流式解决了「单个请求的内存」,分片再进一步:把大文件切成固定大小的分片(通常 4~10MB),逐片上传,天然获得三个能力——每片请求内存可控、失败只重传该片(断点续传)、并发上传提升总速度。
服务端接收每个分片时,请求体流直接接到文件流,全程不整包进内存:
js
// server.mjs(Express 片段):分片接收,请求体流直连文件流
import { createWriteStream, createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import path from "node:path";
const TMP_DIR = "/data/upload-tmp";
app.put("/upload/:fileId/:chunkIndex", (req, res) => {
const { fileId, chunkIndex } = req.params;
const target = createWriteStream(path.join(TMP_DIR, `${fileId}.${chunkIndex}`));
// 关键:req 本身就是 Readable 流,直接管道到文件,内存里只有流动中的 chunk
pipeline(req, target)
.then(() => res.json({ ok: true, chunkIndex: Number(chunkIndex) }))
.catch((err) => {
console.error(`[上传] 分片 ${fileId}.${chunkIndex} 写入失败:`, err.message);
res.status(500).json({ ok: false });
});
});
// 全部分片到齐后按序合并:逐片以管道方式追加,写出流保持打开
async function mergeChunks(fileId, total) {
const out = createWriteStream(path.join("/data/uploads", fileId));
for (let i = 0; i < total; i++) {
await pipeline(
createReadStream(path.join(TMP_DIR, `${fileId}.${i}`)),
out,
{ end: false } // 不到最后一片不关闭写出流
);
}
out.end(); // 全部追加完毕后关闭
}断点续传只需再补一个查询接口:GET /upload/:fileId/status 返回已收到的分片索引列表,前端启动上传前先查一遍,跳过已完成的分片。「秒传」则再加一步:上传前计算文件整体哈希,服务端已有同哈希文件时直接返回成功。
前端配合的逻辑也很短:Blob.slice() 切片,逐片 PUT,失败重试当前片。整个协议没有超出 HTTP 的能力范围,不需要 WebSocket。
分片大小是分片方案里最值得花两分钟想清楚的参数。切片太大,单片重传成本高、单请求的传输与内存压力回升,弱网下失败率也随之变高;切太小,请求数暴涨,每个分片的握手、鉴权、磁盘寻址开销都会被放大,几千个小请求能把磁盘 IO 打成碎片。4~10MB 是社区实践里常见的舒适区:视频、数据集这类连续大文件取偏大值,弱网移动端取偏小值。另一个容易被忽略的细节是分片的完整性校验——分片命名带 fileId 与 index,服务端收到后校验长度与哈希再落盘,防止传输损坏的分片混进合并流程;否则合并出的文件损坏,排查方向会被一路带偏到业务代码,这种坑踩一次就是半天。
背压:被忽视的生死线
流的背压(backpressure)机制回答一个关键问题:生产快、消费慢怎么办?pipe 与 pipeline 内置了背压处理——下游写不动时自动暂停上游读取,内存不会积压。但当你绕过管道手动 readable.pipe(writable) 之外的写法(比如手动 write())时,背压要自己负责:write() 返回 false 表示下游缓冲已满,必须停止写入并等待 drain 事件,否则内存照样被冲爆。一句话总结:能 pipeline 就 pipeline,手写数据搬运几乎必然漏掉某个边界条件。
验证内存是否受控的方法很直接:启动服务时限制堆与观察进程内存:
js
// 定时打印内存占用,验证流式改造后 RSS 恒定不随文件大小增长
setInterval(() => {
const m = process.memoryUsage();
console.log(`[内存观测] RSS=${(m.rss / 1024 / 1024).toFixed(1)}MB, 堆=${(m.heapUsed / 1024 / 1024).toFixed(1)}MB`);
}, 5000);改造后实测(示例环境、4GB 容器限额):上传 10GB 文件全程 RSS 稳定在几百 MB 以内,不再随文件体积增长;OOMKilled 告警自此清零。
避坑总结
- 别用 express.json()/body-parser 接文件。它们把 body 解析进内存且默认限制体积,大文件必然 OOM 或被拦;文件类接口走原始流或 multer 的 diskStorage(落盘模式,同样不占内存)。
- pipeline 的 Promise 必须 await 且 catch。漏掉 catch 时错误变成 unhandled rejection,Node 新版本下进程直接退出——白天没事、流量高峰集体崩,多半就是这类漏网的异步错误。
- 分片哈希用流式计算。
createHash支持分段 update:读一块算一块,不要readFile读完整片再算;合并后的整文件校验同理,用流逐段喂数据。 - 临时分片必须清理。上传中断、合并失败都会留下孤儿分片,磁盘慢慢被打满;定时任务扫描超时目录清理,或合并成功后立即删除对应分片。
- 网关与 Node 的限额要一起改。Nginx 的
client_max_body_size、proxy_read_timeout,CDN/负载均衡层的超时,任何一处没同步,本地压测通过、生产经网关必挂;分片方案下单片体积小,通常反而能绕开这些全局限制——这也是分片优于整包的另一理由。
写在最后
这次改造最深的体会是:Node.js 的流不是「高级特性」,而是处理 I/O 的默认姿势。凡是数据体积可能超过内存的路径——文件上传下载、日志压缩转换、大响应转发——都该先问一句「这里是不是在攒全量」。用 pipeline 串起读写、用分片拆解大文件、用背压守恒内存,三件事做对,4GB 内存稳住 10GB 上传就不再是玄学。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。