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 是否能回落
练习题
- 对比复制 500 MiB 文件时
readFile()和pipeline()的内存。 - 解释为什么 RSS 不立即下降不一定是泄漏。
- 为 Markdown ZIP 设计四种大小限制和两个并发限制。
- 估算上传、解压、转换和压缩同时存在时的磁盘峰值。