c.File()不适合大文件下载,因其调用http.ServeFile会全量读入内存;应改用io.Copy+http.ServeContent实现流式响应,手动控制文件打开、响应头、断点续传与缓存校验。

直接用 c.File() 做大文件下载,内存暴涨、并发一高就 OOM,这不是框架问题,是没走流式路径。Gin 本身不阻塞流式传输,关键在你怎么组织 IO 和响应头。
为什么 c.File() 不适合大文件下载
它内部调用 http.ServeFile,会把整个文件读进内存再发,哪怕你只传个路径。实测 100MB 文件在 100 并发下内存峰值达 8GB,不是服务器扛不住,是你没给它“边读边发”的机会。
- 触发
os.Open+ 全量io.Copy,无分块控制 - 无法设置
Content-Range或处理断点续传 - HTTP 状态码固定为 200,没法按需返回 206 Partial Content
- 文件修改时可能被缓存层(如 Nginx)误判,导致响应体和
Content-Length不一致
用 io.Copy + http.ServeContent 实现可控流式响应
这才是 Gin 下真正可控的流式方案:手动打开文件、校验 ETag/Last-Modified、交由标准库做 range 处理,全程零内存缓冲。
- 必须显式设置
Content-Type和Content-Disposition,http.ServeContent不自动设 - 用
http.ServeContent(c.Writer, c.Request, filename, modTime, file),其中file是*os.File,不是路径字符串 -
modTime必须传文件真实修改时间,否则 304 缓存失效;可用file.Stat().ModTime() - 如果文件路径含中文或特殊字符,
filename要用url.PathEscape编码,避免客户端解析失败
func streamDownload(c *gin.Context) {
filePath := "./static/report.pdf"
file, err := os.Open(filePath)
if err != nil {
c.AbortWithStatus(404)
return
}
defer file.Close()
info, _ := file.Stat()
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", "attachment; filename="+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
}
并发高时要防文件句柄耗尽
Gin 默认不限制并发打开的文件数,但操作系统有 ulimit 上限。1000 并发下载 = 1000 个 os.File,很容易 hit “too many open files”。
- 加
context.WithTimeout控制单次传输最大耗时,避免慢连接长期占着句柄 - 用
sync.Pool复用http.ServeContent内部用到的 buffer(虽然它自己也用 pool,但复用能进一步压 GC) - 生产环境建议前置 Nginx,用
X-Accel-Redirect把文件路径转给 Nginx 直接 sendfile,Gin 只做鉴权 - 若必须 Gin 自己传,确保
ulimit -n≥ 预估峰值并发 × 2(每个连接至少占 1 句柄,加上日志、DB 连接等)
流式 JSON 响应也要避免中间对象
下载不是唯一场景。比如导出百万行数据为 JSON Lines(每行一个 JSON 对象),用 c.JSON() 会先 marshal 全量再写,照样爆内存。
- 改用
json.NewEncoder(c.Writer).Encode(item),逐条编码直写响应流 - 务必在循环前设好
Content-Type: application/json,否则浏览器可能乱码 - 不要在循环里调用
c.Status()或重复c.Header(),HTTP 状态和头只能写一次 - 若需加换行分隔,
encoder.Encode()自带换行,无需额外c.Writer.Write([]byte{'\n'})
流式响应真正的难点不在代码怎么写,而在你是否清楚每一字节从磁盘到网卡之间经过了几层拷贝、谁在 hold 句柄、谁在 buffer 数据。Gin 只提供管道,水怎么流,得你定流向和水压。











