触发场景: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.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)

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐