慢速上传导致浏览器重试
触发场景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(1020)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: bytes3358720 cost19.9993135s speed164.01 KB/s errread 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 秒左右超时如何确认不是前端重复调用为了排除前端重复触发可以给每次上传生成一个请求 IDasyncfunctionuploadAudio(file){constdatanewFormData();data.set(file,file);constuploadIDcrypto.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.ServerReadTimeout覆盖的是连接被 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 也会超时服务端实际统计到的速度是bytes3358720 cost20s speed164 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.Bodyhttp.MaxBytesReader(w,r.Body,1020)iferr:r.ParseMultipartForm(1020);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 秒后结束不等待浏览器底层重试可以用AbortControllerasyncfunctionuploadAudio(file){constdatanewFormData();data.set(file,file);constcontrollernewAbortController();consttimersetTimeout(()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去限制整个上传请求体否则在限速、弱网或大文件场景下很容易出现这种“客户端一个请求服务端两次超时”的现象。