bigywater.tech
Gin · 37

文件上传:MaxMultipartMemory 与临时文件

MaxMultipartMemory 是不是“最大上传大小”?不是。这篇用真实的 multipart 上传测它的真正含义:超过它的文件去了哪里、内存占用差多少、请求结束后临时文件还在不在,以及怎样用 MaxBytesReader 真正限制总大小。

32 MiB
MaxMultipartMemory 的默认值(33554432 字节)
128.1 vs 4.1 MiB
40 MiB 文件在 32 MiB 与 1 MiB 限制下 FormFile 期间的累计分配
200 MiB
没有 MaxBytesReader 时,服务器照单全收的上传大小

默认值与 FormFile

实测 · 本机执行输出
    gin.New().MaxMultipartMemory = 33554432(= 32 MiB)
  context.go 的 FormFile(Gin v1.12.0):
     698 | func (c *Context) FormFile(name string) (*multipart.FileHeader, error) {
     699 |     if c.Request.MultipartForm == nil {
     700 |         if err := c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil {
     701 |             return nil, err
     702 |         }
     703 |     }
     704 |     f, fh, err := c.Request.FormFile(name)
     705 |     if err != nil {
     706 |         return nil, err
     707 |     }
     708 |     f.Close()
     709 |     return fh, err
     710 | }

MaxMultipartMemory 就是 ParseMultipartForm 的 maxMemory 参数:multipart 表单里最多多少字节放在内存里,其余放磁盘临时文件。FormFile 第一次调用时才解析整个表单,之后复用解析结果。

内存还是磁盘

实测 · 本机执行输出
    MaxMultipartMemory=1   MiB → 200 FormFile 之后临时目录:multipart(5.0 MiB);fh.Size=5.0 MiB;请求结束后临时目录:无
    MaxMultipartMemory=64  MiB → 200 FormFile 之后临时目录:无;fh.Size=5.0 MiB;请求结束后临时目录:无

文件小于上限就全在内存里,超过就落到临时目录($TMPDIR/multipart-*),fh.Size 与读取方式不变。临时文件在请求结束时由 net/http 删除;所以只要请求是正常结束的,临时目录不会越积越多。

内存开销

实测 · 本机执行输出
    第 1 轮 MaxMultipartMemory=1   MiB:分配 4.1 MiB,耗时 90 ms,err=<nil>
    第 1 轮 MaxMultipartMemory=32  MiB:分配 128.1 MiB,耗时 73 ms,err=<nil>
    第 1 轮 MaxMultipartMemory=64  MiB:分配 128.0 MiB,耗时 38 ms,err=<nil>
    第 2 轮 MaxMultipartMemory=1   MiB:分配 4.0 MiB,耗时 95 ms,err=<nil>
    第 2 轮 MaxMultipartMemory=32  MiB:分配 128.1 MiB,耗时 66 ms,err=<nil>
    第 2 轮 MaxMultipartMemory=64  MiB:分配 128.0 MiB,耗时 35 ms,err=<nil>

放内存的文件,解析期间的累计分配约是文件大小的 3 倍(缓冲区增长时反复复制,TotalAlloc 不是峰值,但说明 GC 压力);限制设成 32 MiB、文件 40 MiB 时,分配量和全放内存一样大(我推测是先在内存里读到上限再落盘,没有逐行验证)。想让大文件尽快落盘,把上限设小(如 1 MiB),代价是每次都有磁盘 I/O。并发上传时每个请求都占一份。

真正的总大小限制

实测 · 本机执行输出
    /open   上传 200 MiB → 200 收下 200 MiB,临时目录:multipart(200.0 MiB)
    /capped(上限 8 MiB)上传 5 MiB → 200 收下 5 MiB
    /capped(上限 8 MiB)上传 20 MiB → 413 超过上限 8388608 字节:http: request body too large

MaxMultipartMemory 不限制总大小:200 MiB 的文件会被完整写到临时目录。要限制总大小用 http.MaxBytesReader 包住 c.Request.Body,超限时 FormFile 返回 http: request body too large,可以用 errors.As 取 *http.MaxBytesError 并返回 413。

文本字段的额度

实测 · 本机执行输出
    MaxMultipartMemory=1 MiB,文本字段  2 MiB → 200 字段数 1
    MaxMultipartMemory=1 MiB,文本字段 10 MiB → 200 字段数 1
    MaxMultipartMemory=1 MiB,文本字段 12 MiB → 400 multipart: message too large

非文件字段也受限制:单个字段超过 maxMemory + 10 MiB 就报 multipart: message too large。1 MiB 的上限下,10 MiB 的文本字段能过,12 MiB 的不行。

分步演示

上传的内存限制
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。用真实的 httptest.NewServer 与流式 multipart 客户端;TMPDIR 指向每个实验自己的临时目录(实验后删除);累计分配用 runtime.ReadMemStats 的 TotalAlloc 差;耗时是 FormFile 调用耗时,不含网络;内存的数据每个限制各跑两轮(第三节),其余各一次
    • 读过的 Go 源码:mime/multipart/formdata.go(maxMemory + 10 MiB 的额度);没有验证:并发上传时的峰值堆大小(只测了累计分配);临时目录写满时的错误;HTTP/2 下的上传;其他 Go 版本的 10 MiB 额度(只在 Go 1.27.1 上看到);Request.MultipartForm 的 RemoveAll 在 handler panic 时是否仍然执行
    京ICP备2026067502号-1京公网安备11010502063487号