08 大文件与内存

1. readFile() 的内存模型

const content = await readFile(filePath, "utf8");

这不仅可能存在文件 Buffer,还可能产生字符串、解析对象和转换结果:

原始字节
  + UTF-8 字符串
  + Markdown 解析结构
  + HTML 字符串
  + ZIP 压缩缓冲

一个 20 MiB 文件在完整流程中的峰值内存可能远高于 20 MiB。并发 10 个请求时还会相乘。

2. process.memoryUsage()

function logMemory(label: string): void {
  const memory = process.memoryUsage();
  const toMiB = (bytes: number): string =>
    (bytes / 1024 / 1024).toFixed(1);

  console.log(label, {
    rssMiB: toMiB(memory.rss),
    heapUsedMiB: toMiB(memory.heapUsed),
    externalMiB: toMiB(memory.external),
    arrayBuffersMiB: toMiB(memory.arrayBuffers),
  });
}

Buffer 大量计入 external/arrayBuffers,PM2 的 mem 通常观察 RSS。垃圾回收后 V8 或系统分配器也不保证立刻把全部 RSS 归还操作系统,所以一次请求后内存基线升高不等于泄漏。

3. Stream 的改进和边界

Stream 使用有限缓冲传输文件,避免一次性加载整个文件:

await pipeline(
  createReadStream(sourcePath),
  createWriteStream(targetPath),
);

但 Stream 不是零内存,也不能让一个只接受完整字符串的 Markdown 渲染器自动变成流式。你可以流式复制图片和 ZIP 数据,而每个 Markdown 若需完整解析,就必须设置单文件大小上限后再读取。

4. 四类限制

上传 ZIP 至少需要:

压缩包大小上限
Entry 单文件解压大小上限
Entry 数量上限
所有 Entry 解压后总大小上限

压缩包 20 KiB 仍可能解压为数 GB,不能只相信上传大小或 Entry Header。实际传输字节也要累计并在超限时销毁 Stream。

5. 并发比单文件大小更危险

假设每个任务峰值 150 MiB:

1 个任务  ≈ 150 MiB
8 个任务  ≈ 1.2 GiB
PM2 4 进程且每进程 8 个任务 → 更高总峰值

需要同时限制:HTTP 并发、后台 Worker 并发、每任务文件数和单文件大小。

6. 磁盘也是资源

一次处理可能同时保留:

上传 ZIP
解压内容
HTML 输出
最终 ZIP
临时半成品

估算磁盘时不能只看上传文件。发生 ENOSPC 后应停止任务、清理可确认的半成品并告警,而不是无限重试。

7. 判断泄漏的方法

一次升高不是充分证据。应重复同一请求并观察:

处理 1、10、100 次后是否持续近似线性上涨
句柄数量是否持续上涨
任务完成后 Map、监听器和队列引用是否释放
临时文件是否持续积累
并发结束后 heapUsed 是否能回落

练习题

  1. 对比复制 500 MiB 文件时 readFile()pipeline() 的内存。
  2. 解释为什么 RSS 不立即下降不一定是泄漏。
  3. 为 Markdown ZIP 设计四种大小限制和两个并发限制。
  4. 估算上传、解压、转换和压缩同时存在时的磁盘峰值。