根本原因是默认使用 parsemultipartform 全量加载请求体,应改用 multipart.newreader 流式处理:手动迭代 part、忽略非文件字段、校验 filename、用 io.copy 流式写入,并在最外层用 http.maxbytesreader 限流。

Go 处理文件上传时内存暴涨甚至 OOM,根本原因不是框架本身,而是开发者默认用了 ParseMultipartForm 这类“全量加载”逻辑,把整个请求体(含大文件)塞进内存或临时磁盘缓冲区。只要绕开它,用流式读取 + 显式控制边界,1GB 文件也能只占几 MB RSS。
别调用 ParseMultipartForm,直接用 multipart.NewReader
这是最常踩的坑:ParseMultipartForm 会先将所有 form-data 解析进内存(MaxMemory 参数限制的是内存部分,超出才落盘),但它的默认行为是“尽可能全加载”,哪怕你只想要一个文件字段,它也会为其他文本字段分配缓冲、触发多次内存拷贝。
- 改用
multipart.NewReader(r.Body, boundary),手动迭代每个Part,遇到非文件字段就io.Copy(io.Discard, part)忽略 - 对目标文件字段,用
part.Open()得到一个io.Reader,再通过io.Copy或带缓冲的bufio.Reader流式写入磁盘或转发 - 务必检查
part.Header.Get("Content-Disposition")中的filename,避免误处理纯文本字段
http.MaxBytesReader 必须放在最外层
很多人在 handler 里做校验,但恶意请求可能在解析 multipart 前就耗尽内存——比如发一个超长的 boundary 字符串或伪造的头部,multipart.NewReader 内部仍会尝试解析并分配缓冲。
- 在中间件或路由前就包装
r.Body:r.Body = http.MaxBytesReader(w, r.Body, 1(1GB) - 这个限制必须早于任何 multipart 解析逻辑,否则无效
- 错误时直接返回
http.StatusRequestEntityTooLarge,不要继续执行后续逻辑
上传目标选磁盘还是内存?看用途,但别混用
如果只是中转(如上传到对象存储),绝对不要先保存到本地临时文件再读出来——这属于双倍 IO + 双倍内存暂存(OS page cache + Go runtime buffer)。但如果要校验哈希、抽帧、转码,那必须落盘。
- 纯中转:用
io.Copy(dstWriter, partBody)直接流式转发,dst 是http.Post的 body 或s3.PutObject的io.Reader - 需处理:用
os.CreateTemp("", "upload-*.tmp")创建临时文件,写入后立即file.Close(),后续操作用os.Open打开新句柄 - 切忌用
bytes.Buffer接收整个文件——它会在堆上不断扩容,且 GC 不会立刻回收
并发上传时 goroutine 和 buffer 的协同控制
单个大文件上传本身不需 goroutine,但批量上传时,并发数失控会导致文件句柄、内存、TCP 连接三重耗尽。关键不是“开多少 goroutine”,而是“每条流用多少 buffer”。
- 每个上传流使用固定大小的
bufio.Reader(如 64KB),避免小 buffer 导致系统调用过多,也避免大 buffer 拉高单流内存 - 用带缓冲 channel(如
sem := make(chan struct{}, 5))控制最大并发上传数,不是靠runtime.GOMAXPROCS - 注意
os.File的Write调用本身是阻塞的,如果目标存储慢(如 NFS),buffer 再大也没用,反而积压更多待写数据
真正容易被忽略的是 multipart boundary 解析阶段的隐式内存分配——multipart.NewReader 内部会为每个 part header 分配 slice,而 header 长度不可控。生产环境建议用 mime/multipart 的 fork 版本(如 github.com/andybalholm/multipart)或自行加 header 长度截断逻辑,否则攻击者发一个 1MB 的伪造 header 就能让服务卡死。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











