慢速上传导致浏览器重试
触发场景: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)
if err != 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:
async function uploadAudio(file) {
const data = new FormData();
data.set("file", file);
const uploadID = crypto.randomUUID();
console.log("upload id", uploadID);
return requestJSON("/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/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)
if err := 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:
async function uploadAudio(file) {
const data = new FormData();
data.set("file", file);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
try {
return await requestJSON("/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 去限制整个上传请求体,否则在限速、弱网或大文件场景下,很容易出现这种“客户端一个请求,服务端两次超时”的现象。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)