先看标准库做了什么
文件上传是 Web 服务里很常见的功能,但在 Go 里它并不是一个“处理请求”那么简单的动作。标准库 net/http 把 multipart/form-data 的解析封装在 Request.ParseMultipartForm 里,大部分框架也直接暴露这个封装。问题是,封装越方便,越容易掩盖底层的大文件上传成本。

很多团队会遇到一个问题:上传几十 MB 的文件还好,但到了几百 MB 甚至 GB 级,服务要么内存突然飙高,要么磁盘被临时文件占满。要理解原因,需要先看懂 ParseMultipartForm(maxMemory) 里的那个参数。它并不是请求体大小上限,而是“在内存中缓存多少文件数据”的阈值。超过阈值的数据会被写到系统的临时目录,由 Go 自动创建临时文件。换句话说,它主要决定“数据放内存还是磁盘”,并不帮你解决“数据最终去哪里”。
| 解析方式 | 内存占用 | 临时文件 | 流式程度 | 适合场景 |
|---|---|---|---|---|
| ParseMultipartForm | 受 maxMemory 控制 | 超过阈值后自动创建 | 一次性解析,非流式 | 中小文件、普通表单 |
| MultipartReader | 可控,由业务决定 | 需要自己管理 | 按 Part 逐步读取 | 大文件、需要直传目标存储 |
| 框架便利 API(如 gin.FormFile) | 与 ParseMultipartForm 相同 | 与 ParseMultipartForm 相同 | 本质非流式 | 快速原型、常规上传 |
从表中能看出,MultipartReader 是唯一能在大文件场景下保持控制权的路径。它能让你在请求体还在传输时,边读边写,而不是等全部落盘后再处理。
用 MultipartReader 做真正的流式上传
真正的大文件流式上传,应该使用 r.MultipartReader()。这个方法返回一个 *multipart.Reader,之后通过 NextPart() 逐段读取表单字段和文件。整个过程不会把文件先完整缓冲到内存,也不会生成你没要求的临时文件。
func handleUpload(w http.ResponseWriter, r *http.Request) {
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "invalid multipart", http.StatusBadRequest)
return
}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, "read part", http.StatusBadRequest)
return
}
if part.FileName() == "" {
continue
}
dst, err := os.OpenFile("uploads/"+part.FileName(),
os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
http.Error(w, "open dst", http.StatusInternalServerError)
return
}
n, err := io.Copy(dst, part)
dst.Close()
if err != nil {
http.Error(w, "copy data", http.StatusInternalServerError)
return
}
_ = n
}
}
这段示例把每个文件直接写到指定目录,没有经过临时文件。注意几个点:NextPart() 返回的 part.FileName() 只在文件类型字段上返回非空,可以当作判断条件;流式读取时,表单字段和文件字段要保持合理的顺序,如果业务依赖某个字段去决定文件存放路径,最好把字段放在文件前面,否则可能读不到后置字段。
另外,MultipartReader 不会替你限制请求体大小。要防止恶意请求,需要先把 r.Body 包一层 http.MaxBytesReader,例如 r.Body = http.MaxBytesReader(w, r.Body, 1<<30)。限制大小后,读超出部分会返回错误,handler 里要能处理这种半截上传。
临时文件的生命周期:一个容易忽略的问题
即便你采用流式上传,有些场景依然绕不开临时文件。比如上传后需要转码、杀毒、生成缩略图,这些操作一般要拿到完整文件才能开始。此时要明确:谁创建临时文件,谁负责删除。
一个常见误解是 Go 的临时文件会在请求结束后自动清理。实际上,ParseMultipartForm 创建的临时文件保存在 r.MultipartForm 中,标准库服务器并不会主动调用 RemoveAll()。如果你直接用了框架的上传方法,又忽略了清理,长时间运行后磁盘被临时文件占满是迟早的事。
经验提醒:任何上传接口都应该把临时文件的清理纳入请求的生命周期,而不是依赖系统 /tmp 的定时清理。
如果自己创建临时文件,建议用 os.CreateTemp,并立刻在 defer 里安排删除:
tmpFile, err := os.CreateTemp("", "upload-*.tmp")
if err != nil {
http.Error(w, "create temp", http.StatusInternalServerError)
return
}
defer func() {
tmpFile.Close()
os.Remove(tmpFile.Name())
}()
if _, err := io.Copy(tmpFile, part); err != nil {
http.Error(w, "write temp", http.StatusInternalServerError)
return
}
CreateTemp 默认使用系统临时目录,文件名带有随机部分,权限是 0600,比直接写固定文件名安全。defer 不只覆盖正常流程,也能在 panic 和客户端断开时尽量执行清理。
几个典型误区
- 把 maxMemory 当成请求体上限。它只控制内存和临时文件的分界,真正的磁盘占用可能远超过你预设的值,大文件上传依然能撑爆临时目录。
- 以为框架的上传 API 是流式。很多框架封装了 ParseMultipartForm,调用 FormFile 时文件已经被完整解析到内存或临时文件里,和你自己写流式代码是两回事。
- 先写临时文件,再异步读取。如果临时文件在请求结束后被删除,异步任务拿到的只是一个无效路径。要么同步处理,要么先搬到持久目录或对象存储。
- 只限制请求体大小,不限制临时文件总量。即使限制了单次请求,如果并发高、清理不及时,累积的临时文件也会拖垮磁盘。
不同规模场景怎么选
如果是内部系统、文件不大,直接 ParseMultipartForm 最简单,记得在处理完成后调用 r.MultipartForm.RemoveAll()。如果文件普遍超过 100 MB,或者需要把文件传到 OSS、S3 这类对象存储,最好放弃框架封装,改用 MultipartReader 边读边传,避免在服务器上产生一份完整临时副本。
还有一种情况是文件极大且网络不稳定,比如直播录制文件、备份包。这时表单上传本身就不合适。建议走分片上传或使用对象存储的预签名 URL 直传,让大流量绕过业务服务器。这个方案虽然有额外的分片合并和进度管理成本,但能彻底避免 Web 进程被文件 IO 拖住。
方案选择的核心判断依据是:这个文件在服务端需要停留多久、停留多少份副本。如果只是中转,就不要让它落盘;如果必须落盘,就要明确它是“临时工作区”还是“持久化存储”,并分别设计清理策略。
落地建议
- 先用
http.MaxBytesReader或网关层限制请求体大小,并给超时参数留足余量。 - 如果使用 MultipartReader,注意字段解析顺序,尽量让必要字段出现在文件字段之前。
- 写文件时用
io.MultiWriter同时计算 SHA-256 或做大小统计,避免二次读取。 - 临时文件目录打上当前任务 ID,处理完成后通过 defer 删除整个任务目录。
- 监控临时目录的磁盘占用和文件数,设置 TTL 清理任务,兜底处理进程异常退出留下的文件。
文件上传不是写一个接口就结束的功能,它牵涉内存、磁盘、网络超时和异常恢复。Go 的标准库在很多情况下足够细致,只要你愿意往下走一层,绕过那些“看起来能用”的封装,就能把大文件上传控制在一个可预期的资源范围里。能流式就流式,能清理就清理,想清楚每个文件从进入到销毁经过的每一步,这个功能就不会成为生产事故的起点。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/954/