Node.js 如何处理文件上传和大文件传输?
简化版
Node.js 处理大文件要优先使用流式处理,避免把完整文件读入内存。上传场景要限制大小、类型、并发和路径,落盘或上传对象存储时要处理背压、临时文件清理、校验和安全扫描。
详细版
大文件上传最怕一次性读入内存。 一个 500 MB 文件如果完整放进 Buffer,几个并发就可能把进程打爆。 应使用 multipart 解析库或 stream 管道,把数据分块写入磁盘或对象存储。
import { pipeline } from 'node:stream/promises'
import fs from 'node:fs'
await pipeline(req, fs.createWriteStream('/tmp/upload.bin'))
真实项目还要做文件大小限制、扩展名和 MIME 校验、随机文件名、目录穿越防护、失败清理、断点续传或分片上传。
完整版教学
一、大文件问题的本质是内存和背压
Node.js 很适合 I/O,但不代表可以随便把大文件放进内存。 如果每个上传 200 MB,同时 20 个用户上传,理论数据量就是 4 GB。 这远超多数 Node 进程的安全堆内存。
错误思路:
读取完整文件 -> Buffer -> 再写入
正确思路:
请求流 -> 分块处理 -> 写入磁盘/对象存储
流式处理让内存占用接近分块大小,而不是文件总大小。 这也是 Node 处理大文件的核心姿势。
二、pipeline 能正确处理错误和背压
手动监听 data 事件容易忽略背压。
pipeline 可以把 readable、transform、writable 串起来,并在错误时自动销毁管道。
它比简单 req.pipe(writeStream) 更适合工程代码。
import { pipeline } from 'node:stream/promises'
import { createWriteStream } from 'node:fs'
await pipeline(
req,
createWriteStream(filePath)
)
| 写法 | 优点 | 风险 |
|---|---|---|
data 手动拼接 | 简单直观 | 易爆内存 |
pipe | 流式处理 | 错误处理容易漏 |
pipeline | 处理背压和错误 | 推荐工程使用 |
背压的意思是写入端处理不过来时,读取端应该慢下来。 否则内存会堆积越来越多待写数据。
三、multipart 上传需要专门解析
浏览器表单上传常用 multipart/form-data。
请求体里不只有文件字节,还有边界、字段名、文件名等元数据。
不要自己从零解析,通常使用 busboy、multer、formidable 等成熟库。
multipart body
├─ field: userId
├─ file: avatar.png
└─ field: type
解析库的配置要设置文件大小、字段数量、文件数量限制。 如果没有限制,攻击者可以上传超大文件或构造大量字段消耗资源。
四、文件名和路径必须做安全处理
用户上传的原始文件名不能直接作为服务端路径。
可能包含 ../、特殊字符、重复名或控制字符。
服务端应生成随机文件名,原始文件名只作为展示元数据保存。
import crypto from 'node:crypto'
const safeName = `${crypto.randomUUID()}.bin`
| 风险 | 示例 | 防护 |
|---|---|---|
| 路径穿越 | ../../app.js | 不信任原始文件名 |
| 覆盖文件 | avatar.png 重名 | 使用随机名 |
| 类型伪造 | .jpg 实为脚本 | 内容嗅探和白名单 |
| 超大文件 | 10 GB 上传 | 大小限制 |
文件上传是安全入口,不是单纯 I/O 功能。 面试里补这些点会很加分。
五、失败和取消时要清理临时文件
上传过程中可能网络断开、校验失败、磁盘写满、对象存储失败。 如果临时文件不清理,磁盘会慢慢被打满。 因此上传流程要有 finally 清理和后台巡检。
let completed = false
try {
await pipeline(req, createWriteStream(tmpPath))
completed = true
} finally {
if (!completed) {
await fs.promises.rm(tmpPath, { force: true })
}
}
假设每天失败上传 1000 次,每次残留 20 MB,一天就是 20 GB。 临时文件清理不是小事。
六、大文件下载也要流式输出
下载同样不能一次性 readFile 大文件。
应使用 createReadStream 或对象存储签名 URL。
如果文件很大,优先让客户端直传直下对象存储,Node 只负责签名和鉴权。
记忆钩子:大文件别进内存,Node 只当传送带;上传要限,失败要扫尾。
服务端如果必须中转,就用流和 pipeline。 如果能走对象存储直传,架构上更省资源。
七、常见误区与追问
- 误区:Node.js 处理文件就是把 Buffer 拼起来。 大文件拼 Buffer 会占用大量内存,应流式处理。
- 误区:前端限制文件大小就够了。 服务端必须再次限制大小、数量、类型和并发。
- 误区:用户上传文件名可以直接落盘。 原始文件名不可信,应使用随机名并防路径穿越。
- 追问:pipeline 比 pipe 好在哪里?
pipeline更完整地处理背压、错误传播和资源销毁。 - 追问:对象存储直传有什么好处? 减少 Node 中转流量和带宽压力,Node 只做鉴权和签名。
- 追问:上传失败要注意什么? 清理临时文件、释放流、记录失败原因,并避免磁盘残留。
八、加强记忆
文件上传题按“流式、安全、清理”记。大文件不要进内存,用 stream 和 pipeline;上传要限制大小、类型、数量和路径;失败取消要清理临时文件;大流量场景优先对象存储直传。