当前位置: 首页 > news >正文

慢速上传导致浏览器重试

触发场景: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.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 也会超时

服务端实际统计到的速度是:

bytes=3358720 cost=20s speed=164 KB/s

164 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去限制整个上传请求体,否则在限速、弱网或大文件场景下,很容易出现这种“客户端一个请求,服务端两次超时”的现象。

http://www.cnnetsun.cn/news/2535912.html

相关文章:

  • SUMO-RL:基于强化学习的智能交通信号控制终极指南 [特殊字符]
  • 为什么有些论文,答辩老师越听越不敢卡?
  • 解锁 Codex 逆向能力!一键部署 JS 逆向全能 Skill
  • 铜排产线数字化升级实战-生产企业应该如何进行信息化建设
  • Rufus制作Linux启动盘翻车实录:分区方案选错、U盘变砖怎么救?
  • 区块链与计算机视觉融合:构建可信数字世界的技术架构与实践
  • GPU加速LBM流体模拟:Palabos的C++17并行优化实践
  • 【Lovable高阶开发者私藏技巧】:绕过平台限制实现自定义CSS/JS注入与第三方SDK深度对接
  • 别再到处找激活工具了!手把手教你用vlmcsd在Windows上自建KMS服务器(附防火墙配置)
  • 从啤酒尿布到精准推荐:用FP-Growth算法实战电商用户购物篮分析(附完整Python代码)
  • AI 答疑系统痛点破解:从意图模糊到秒级响应,LightRAG实战解密上下文工程
  • Qoder 1.0 深度实操:让Agent团队替你写代码是种什么体验
  • AI编程新纪元已来(Claude 3.5 Sonnet代码能力压测报告:GitHub Copilot vs Cursor vs 原生Claude)
  • 【陕西专升本】2026陕西专升本真题
  • MySQL数据库:创建/删除数据库、数据类型及完整性约束详解
  • 1. NLP课程大纲
  • 海量时序数据困局破壁:DolphinDB 如何重新定义工业物联网的数据底座
  • Rust Trait系统设计模式:实现灵活的多态和代码复用
  • 终极消息保护方案:RevokeMsgPatcher轻松实现微信QQ防撤回
  • 加速科研、提出新假设:谷歌重磅推出Co-Scientist模型
  • 【c++面向对象编程】第48篇:Lambda表达式与std::function:OOP中的函数式编程
  • 山东防爆监控哪个品牌好用
  • 3分钟解决网易云音乐格式限制:免费NCM转换工具完全指南
  • ComfyUI Manager 终极安装指南:3种方法轻松管理AI工作流节点
  • CANN NPU 功耗优化:推理服务的能效比提升实战
  • 2026论文写作工具红黑榜:AI论文网站怎么选?清单来了
  • AI Agent Harness 在智能客服领域的应用
  • 2026年论文党必备:盘点2026年倾心之选的的降AIGC网站
  • 为什么92%的Lindy自动化项目在第90天遭遇断崖式停滞?资深架构师紧急披露3个临界预警信号
  • 10_函数递归_从阶乘到递归调用栈