跳到主内容

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 告警自此清零。

避坑总结 ​

  1. 别用 express.json()/body-parser 接文件。它们把 body 解析进内存且默认限制体积,大文件必然 OOM 或被拦;文件类接口走原始流或 multer 的 diskStorage(落盘模式,同样不占内存)。
  2. pipeline 的 Promise 必须 await 且 catch。漏掉 catch 时错误变成 unhandled rejection,Node 新版本下进程直接退出——白天没事、流量高峰集体崩,多半就是这类漏网的异步错误。
  3. 分片哈希用流式计算。createHash 支持分段 update:读一块算一块,不要 readFile 读完整片再算;合并后的整文件校验同理,用流逐段喂数据。
  4. 临时分片必须清理。上传中断、合并失败都会留下孤儿分片,磁盘慢慢被打满;定时任务扫描超时目录清理,或合并成功后立即删除对应分片。
  5. 网关与 Node 的限额要一起改。Nginx 的 client_max_body_size、proxy_read_timeout,CDN/负载均衡层的超时,任何一处没同步,本地压测通过、生产经网关必挂;分片方案下单片体积小,通常反而能绕开这些全局限制——这也是分片优于整包的另一理由。

写在最后 ​

这次改造最深的体会是:Node.js 的流不是「高级特性」,而是处理 I/O 的默认姿势。凡是数据体积可能超过内存的路径——文件上传下载、日志压缩转换、大响应转发——都该先问一句「这里是不是在攒全量」。用 pipeline 串起读写、用分片拆解大文件、用背压守恒内存,三件事做对,4GB 内存稳住 10GB 上传就不再是玄学。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。