大文件上传卡住或超时的根本原因是go标准库multipart解析器默认将文件读入32mb内存缓冲区,超限后依赖临时磁盘路径;若未配置maxmultipartmemory、tmpdir不可写或磁盘满,会导致io timeout、unexpected eof或句柄泄漏。

为什么大文件上传会卡住或超时
根本原因不是 Gin 本身限制,而是 HTTP 协议和 Go 标准库的默认行为:multipart 解析器会把整个文件先读进内存缓冲区(maxMemory),再交给 handler。默认值是 32MB,超过就写临时磁盘;但若没设好路径或磁盘满,c.FormFile() 直接返回 http.ErrMissingFile 或静默失败,日志里只看到空指针 panic。
常见错误现象:read tcp: i/o timeout(客户端等不到响应)、multipart: NextPart: unexpected EOF(解析中断)、too many open files(临时文件句柄泄漏)。
- 必须在
gin.Engine初始化后立即调用r.MaxMultipartMemory = 1024 * 1024 * 512(例如设为 512MB),否则c.FormFile()用的是默认 32MB - 临时目录必须可写且空间充足,通过
os.Setenv("TMPDIR", "/data/tmp")提前指定,避免落在根分区 - 不要用
c.Request.MultipartForm.File直接遍历——它会强制触发全部解析,大文件下极易 OOM
c.FormFile() 和 c.Request.FormFile() 别混用
c.FormFile() 是 Gin 封装后的安全接口,会自动处理 maxMemory 和临时路径;而 c.Request.FormFile() 绕过 Gin 控制,直接调用标准库 http.Request.FormFile(),不尊重 r.MaxMultipartMemory 设置,且返回的 multipart.File 可能是内存 buffer 也可能是临时文件句柄,类型不稳定。
实操建议:
- 始终用
c.FormFile("field_name")获取单个文件,字段名必须与前端--form "file=@xxx"中的 key 一致 - 若需接收多个同名文件(如
photos[]),用c.Request.MultipartForm前先确保已调用c.Request.ParseMultipartForm(r.MaxMultipartMemory) - 拿到
*multipart.FileHeader后,立刻调用fileHeader.Open()得到io.ReadCloser,别缓存fileHeader本身
保存大文件必须流式写入,不能 SaveUploadedFile
c.SaveUploadedFile() 内部会把整个文件读进内存再 os.WriteFile(),对百 MB 级文件等于双倍内存占用(解析缓冲 + 写入缓冲),且无法控制进度、无法校验中间损坏。
正确做法是用 io.Copy 边读边写:
f, err := fileHeader.Open()
if err != nil {
c.JSON(500, gin.H{"error": "failed to open upload"})
return
}
defer f.Close()
<p>dst, err := os.Create("/data/uploads/" + safeName)
if err != nil {
c.JSON(500, gin.H{"error": "failed to create target file"})
return
}
defer dst.Close()</p><p>_, err = io.Copy(dst, f)
if err != nil {
os.Remove(dst.Name()) // 清理残缺文件
c.JSON(500, gin.H{"error": "write failed: " + err.Error()})
return
}
</p>
关键点:
-
defer f.Close()和defer dst.Close()必须都有,漏一个就会句柄泄漏 -
io.Copy返回实际写入字节数,可用于比对fileHeader.Size做完整性校验 - 目标路径必须用
os.MkdirAll()预创建父目录,os.Create()不会自动建多层
中文文件名和路径遍历风险必须同时防
前端传来的 fileHeader.Filename 是原始字符串,可能含 ../ 或空字符,直接拼路径就是路径遍历漏洞;同时中文名不编码会导致 Safari 下载失败或乱码。
实操步骤不可跳过:
- 先用
strings.ReplaceAll(fileHeader.Filename, "..", "_")简单过滤(更严用securejoin.SecureJoin) - 再用
path.Base()截掉路径部分,只保留文件名 - 最后用
url.PathEscape()编码(不是QueryEscape),用于Content-Disposition头 - 保存到磁盘时用原始清洗名(未编码),但需确保不含控制字符,可用正则
regexp.MustCompile(`[^\w.-]`).ReplaceAllString("", name)
最易被忽略的是:同一份文件名,url.PathEscape() 结果要用于响应头,清洗后结果才用于磁盘路径——二者必须分离,不能复用同一个变量。











