
客户端断开时 c.FormFile 不会报错,但后续读取会卡住
很多人以为上传中断会在 c.FormFile() 调用时立刻失败,实际并非如此。c.FormFile("file") 只是触发 multipart 解析的开关,真正读取 body 的动作发生在它内部或后续 io.Copy() 中。如果此时客户端已关闭连接,Go 的 conn.Read() 通常不会立即返回错误,而是阻塞等待,直到超时或下一次写操作才暴露异常。
常见现象:前端进度条停在 95%,后端日志静默,c.FormFile 返回 nil, nil 或正常 file 对象,但紧接着 io.Copy(dst, src) 卡住、不返回也不 panic —— 这是因为连接已断,但 Go 还没感知到。
- 别依赖
file != nil判断上传是否完成;它只表示字段名匹配,不代表数据可读 -
c.Request.Body是一个io.ReadCloser,读取时才真正触碰网络层 - 若未设
http.Server.ReadTimeout,这个“卡住”可能持续数分钟,拖垮连接池
唯一可靠信号是 req.Context().Done(),但它不反映客户端断连
req.Context().Done() 是 Gin handler 内能拿到的最接近“连接状态”的信号,但它只在服务端主动取消(如超时、反向代理中止)时触发,不响应客户端静默断开。浏览器关标签、手机切后台、Nginx proxy_read_timeout 触发,都不会让 ctx.Done() 立即关闭。
所以你不能靠 select { case 来“检测断连”,而应把它当作“允许我安全退出”的许可——所有耗时 IO 操作必须接受 <code>context.Context 并检查 ctx.Err()。
- 数据库查询必须用
db.QueryContext(ctx, ...),不能用db.Query(...) - HTTP 调用必须用
http.DefaultClient.Do(req.WithContext(ctx)) - 文件读写建议用
io.CopyN(dst, src, n)+ 定期检查ctx.Err(),而非一次性io.Copy - 不要在 goroutine 中传
*gin.Context,只传c.Request.Context()
write: broken pipe 是写响应时才暴露的错误,recover 捕不到
当你调用 c.JSON(200, ...) 或 c.String(...) 时,如果连接早已断开,Go 底层会触发 write: broken pipe 错误。这不是 panic,而是 net.Conn.Write() 返回的 error,它发生在 http.Server 的 I/O 层,**完全绕过 Gin 的中间件和 handler 执行栈**。
这意味着:你在任何 defer func() { recover() }() 里都抓不到它;Gin 自带的 Recovery() 中间件也无效;日志里看到的 panic 是 http.Server 自己打的,不是你的代码抛的。
- 唯一能捕获它的位置是
http.Server.ErrorLog,需在启动时显式配置 - 别试图在
c.Writer.Write()后检查err—— Gin 的 writer 封装层不透出底层 error - 不要用
c.Writer.Status()判断是否还能写,状态码已发不代表连接还活着 - 若需强反馈,可在写响应前做一次空写:
w.WriteHeader(200); w.Write([]byte{}),捕获并忽略write: broken pipe
生产环境该怎么做:三层协同 + 主动放弃
没有单点方案能 100% 感知客户端断连。现实解法是降低影响范围 + 明确放弃时机:
- Go 层:设置
http.Server{ReadTimeout: 5 * time.Minute},避免长阻塞;禁用默认Recovery(),手写中间件统一处理 panic - Nginx 层:配
client_max_body_size 1g和proxy_read_timeout 600,让中断发生在网关,而非业务层 - Gin 层:对大文件上传接口,用分片上传替代单次上传;每个分片带
X-Upload-ID和序号,服务端只存已收分片,客户端可重试 - 关键逻辑里定期检查
ctx.Err(),一旦非 nil 就立即return,不等 IO 报错
最易被忽略的一点:http.Server.ReadTimeout 必须显式设置。Go 1.26+ 默认为 0,但实际行为受 TCP keepalive 和中间代理制约,不设就等于放任连接无限期挂起。

















