慢速上传导致浏览器重试
触发场景:Chrome 开启网络限速后,Go 上传接口 20 秒超时,但浏览器端一个 upload 请求 pending 约 40 秒。
该博客由 AI 根据调试过程整理。
触发场景
项目中有一个音频上传接口:
mux.Handle("POST /v1/audio/upload",chain(http.HandlerFunc(audioHandler.AudioUpload),middleware.AuthMiddleware(cfg.SessionStore),middleware.LoggingMiddleware,))服务端配置了 20 秒读超时:
server:=&http.Server{Addr:":80",Handler:mux,ReadTimeout:20*time.Second,WriteTimeout:20*time.Second,IdleTimeout:60*time.Second,}上传接口中通过ParseMultipartForm读取文件:
err:=r.ParseMultipartForm(10<<20)iferr!=nil{log.Println("fail to parse",err)response.WriteJSON(w,http.StatusBadRequest,response.Fail(response.CodeInternalError))return}在 Chrome DevTools 中开启Fast 4G限速后,上传一个约 3.6 MB 的 MP3 文件,服务端出现如下日志:
upload id: 7dc45d25-242b-4541-a4c4-75fa1b97dd0f remote: [::1]:52130 upload body read result: bytes=3358720 cost=19.9993135s speed=164.01 KB/s err=read tcp [::1]:80->[::1]:52130: i/o timeout POST /v1/audio/upload 19.9998646s upload id: 7dc45d25-242b-4541-a4c4-75fa1b97dd0f remote: [::1]:18057浏览器 Network 面板中却只看到一个upload请求,并且 pending 约 40 秒。
表面上看很奇怪:
前端只调用了一次 fetch Chrome 只显示一个 upload 服务端却看到两次 TCP 连接 每次都在 20 秒左右超时如何确认不是前端重复调用
为了排除前端重复触发,可以给每次上传生成一个请求 ID:
asyncfunctionuploadAudio(file){constdata=newFormData();data.set("file",file);constuploadID=crypto.randomUUID();console.log("upload id",uploadID);returnrequestJSON("/v1/audio/upload",{method:"POST",body:data,headers:{"X-Upload-Id":uploadID,},});}后端打印请求 ID 和客户端地址:
log.Println("upload id:",r.Header.Get("X-Upload-Id"),"remote:",r.RemoteAddr)结果两次服务端日志中的X-Upload-Id完全相同,但remote端口不同:
upload id: 7dc45d25-242b-4541-a4c4-75fa1b97dd0f remote: [::1]:52130 upload id: 7dc45d25-242b-4541-a4c4-75fa1b97dd0f remote: [::1]:18057这说明:
前端只发起了一次高层 fetch 请求。 浏览器底层为同一个请求建立了两次 TCP 连接。原理分析
Go 的ReadTimeout不只是限制读取请求头。
对于net/http.Server,ReadTimeout覆盖的是:
连接被 accept -> 读取请求头 -> 读取请求体文件上传时,请求体就是 multipart body。r.ParseMultipartForm(...)会持续从r.Body读取上传内容。
当 Chrome 开启网络限速后,服务端 20 秒内没有读完整个请求体,于是触发:
read tcp ... i/o timeout这不是业务层正常返回失败,而是服务端在读取请求体时遇到连接读超时。
此时即使代码继续执行:
response.WriteJSON(w,http.StatusBadRequest,...)浏览器也不一定能收到一个完整、干净的 HTTP 响应。对浏览器来说,这更像是底层连接异常中断。
Chrome 可能会在底层重新建立连接,并重试同一个请求。DevTools 仍然把它合并显示为一个upload条目,所以客户端看到的是:
一个请求 pending 约 40 秒而服务端看到的是:
第一个 TCP 连接 20 秒超时 第二个 TCP 连接 20 秒超时这就是“客户端一个请求,服务端两次连接”的来源。
为什么 3.6 MB 也会超时
服务端实际统计到的速度是:
bytes=3358720 cost=20s speed=164 KB/s164 KB/s约等于1.31 Mbps。
3.6 MB 文件约等于 3.65 MiB,以这个速度上传需要:
3.65 * 1024 / 164 ≈ 22.8 秒所以 20 秒刚好不够。
问题不是文件很大,而是服务端把“读取完整请求体”的时间限制得太短。
错误的解决方向
不要把这个问题简单理解成:
前端重复绑定了 click 事件 gopls 启了两个 Go handler 自动执行了两次这些都不是根因。
真正的问题是:
慢速上传时,服务端 ReadTimeout 提前关闭了正在读取 body 的连接。 浏览器没有收到稳定响应,底层可能重试同一个请求。解决方案
方案一:上传接口不要依赖全局 ReadTimeout 限制 body
更推荐的服务端配置是:
server:=&http.Server{Addr:":80",Handler:mux,ReadHeaderTimeout:5*time.Second,ReadTimeout:0,WriteTimeout:60*time.Second,IdleTimeout:60*time.Second,}含义是:
ReadHeaderTimeout:限制请求头读取时间,防止慢请求头攻击。 ReadTimeout: 0:不使用全局读超时限制整个 body 上传。 WriteTimeout:限制服务端写响应时间。 IdleTimeout:限制 keep-alive 空闲连接。上传接口再单独限制请求体大小:
r.Body=http.MaxBytesReader(w,r.Body,10<<20)iferr:=r.ParseMultipartForm(10<<20);err!=nil{response.WriteJSON(w,http.StatusBadRequest,response.Fail(response.CodeInternalError,"fail to parse multipartform"))return}这样可以把两个概念分开:
超大文件:用 MaxBytesReader 限制大小。 慢速上传:不被全局 20 秒 ReadTimeout 误杀。方案二:如果只是学习项目,可以调大 ReadTimeout
如果暂时不想调整超时模型,可以把ReadTimeout改大:
ReadTimeout:60*time.Second,这能解决当前 3.6 MB 文件在限速下上传失败的问题。
但它不是最理想的设计,因为不同用户网络差异很大,文件越大越容易再次碰到类似问题。
方案三:客户端主动控制上传超时
如果希望前端严格 20 秒后结束,不等待浏览器底层重试,可以用AbortController:
asyncfunctionuploadAudio(file){constdata=newFormData();data.set("file",file);constcontroller=newAbortController();consttimer=setTimeout(()=>controller.abort(),20_000);try{returnawaitrequestJSON("/v1/audio/upload",{method:"POST",body:data,signal:controller.signal,});}finally{clearTimeout(timer);}}注意:客户端超时是用户体验控制,不能代替服务端大小限制。
生产环境的常见做法
生产环境中,大文件通常不直接经过业务服务器,而是采用对象存储直传:
前端 -> 后端:申请上传凭证 后端 -> 前端:返回预签名 URL 前端 -> 对象存储:上传文件 前端 -> 后端:提交文件 metadata这样业务服务器不负责承载大文件上传流量,也不会因为业务接口的读超时影响文件传输链路。
最终结论
这个问题的核心不是“前端调用了两次接口”,而是:
一个 fetch 上传请求,在慢速网络下没有在 Go ReadTimeout 内传完 body。 服务端读 body 超时并关闭连接。 浏览器底层可能重试同一个请求。 DevTools 合并显示为一个 upload,服务端却看到两个 TCP 连接。上传接口的超时设计应该区分:
请求头超时:用 ReadHeaderTimeout。 文件大小限制:用 MaxBytesReader。 用户体验超时:用前端 AbortController。 大文件传输:优先考虑对象存储直传。不要用一个很短的全局ReadTimeout去限制整个上传请求体,否则在限速、弱网或大文件场景下,很容易出现这种“客户端一个请求,服务端两次超时”的现象。
