拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

​编辑 Go 的 http.Client 到底有几层超时:设了 Timeout 为什么请求还是会卡住​​​​​​

​编辑 Go 的 http.Client 到底有几层超时:设了 Timeout 为什么请求还是会卡住​​​​​​

一个很常见的线上现象:调用第三方接口的 goroutine 卡了十几分钟才返回,而代码里明明给http.Client设了Timeout: 10 * time.Second。

这类问题查到最后,往往是那个请求根本没走你以为的那个 client。但要讲清楚为什么,得先把net/http客户端的超时体系过一遍,它比大多数人以为的要分层得多。这篇把每一层是什么、管哪一段、不设会怎样讲清楚。

一次请求的时间线

一个 HTTPS 请求从发起到读完响应,大致经过这几段:

DNS 解析 → TCP 建连 → TLS 握手 → 写请求 → 等响应头 → 读响应体

net/http对这些阶段分别有控制点,散落在三个地方:http.Client、http.Transport、net.Dialer。另外还有贯穿全程的context。

第一层:Client.Timeout,管全程

client := &http.Client{Timeout: 10 * time.Second}

这是最粗的一刀:从发起请求到读完响应体,整个过程超过 10 秒就取消。包括重定向,包括读 body。

最后一点经常被忽略。client.Do返回的时候,body 还没读,计时器还在走。如果你拿到resp之后慢吞吞地处理,再去io.ReadAll(resp.Body),可能会在读 body 时收到context deadline exceeded (Client.Timeout or context cancellation while reading body)。

它的问题是太粗:下载一个大文件,10 秒不够;调一个本该 50 毫秒返回的接口,10 秒又太长。而且它不区分"连不上"和"对方处理慢",这两种情况的应对通常不一样。

默认值是 0,也就是永不超时。http.Get、http.Post用的http.DefaultClient就是这个状态。开头说的那种"设了超时还是卡住",十有八九是某个工具函数里直接用了http.Get。

第二层:Transport 上的分阶段超时

transport := &http.Transport{ DialContext: (&net.Dialer{ Timeout: 3 * time.Second, // TCP 建连 KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 3 * time.Second, // TLS 握手 ResponseHeaderTimeout: 5 * time.Second, // 写完请求后,等响应头 ExpectContinueTimeout: 1 * time.Second, IdleConnTimeout: 90 * time.Second, // 空闲连接在池里留多久 MaxIdleConnsPerHost: 20, } client := &http.Client{Transport: transport, Timeout: 30 * time.Second}

逐个说:

  • Dialer.Timeout:TCP 三次握手的上限,包括 DNS 解析。对方机器挂了、防火墙丢包(不回 RST),不设这个会等到操作系统的 SYN 重试耗尽,Linux 上默认是一两分钟。
  • TLSHandshakeTimeout:TCP 连上之后 TLS 握手的上限。
  • ResponseHeaderTimeout:请求发完之后,等对方返回响应头的时间。这个最能区分"对方处理慢"。它不管读 body 的时间。
  • IdleConnTimeout:和请求耗时无关,是连接池里空闲连接的存活时间。设得比服务端的 keep-alive 超时短,能减少"复用了一条对方已经关掉的连接"导致的EOF/connection reset报错。

这样配完,"连不上"会在 3 秒左右失败,"对方处理慢"会在 5 秒失败,而一个正常开始返回、只是 body 很大的下载,可以一直读到Client.Timeout的 30 秒。

注意http.DefaultTransport本身已经设了Dialer.Timeout: 30s、TLSHandshakeTimeout: 10s,但没有设ResponseHeaderTimeout。所以只用默认 transport、又不设Client.Timeout的话,对方接了连接但一直不回,请求就会一直挂着。

第三层:context,管单次调用

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := client.Do(req)

Client.Timeout是 client 级别的,所有请求共用一个值。而 context 可以每次调用单独定,并且可以从上游继承:上面用了r.Context(),如果调用你的那个 HTTP 请求被客户端断开了,这个下游请求也会跟着取消,不会白跑。

context 和Client.Timeout同时存在时,谁先到用谁。

一个常见的错:

func fetch(url string) (io.ReadCloser, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := client.Do(req) if err != nil { return nil, err } return resp.Body, nil // 错:返回了 body,但函数一退出 cancel 就执行了 }

defer cancel()在函数返回时执行,context 被取消,调用方再去读 body 会报context canceled。要么在函数内读完 body 再返回[]byte,要么把 cancel 交给调用方(比如包一层ReadCloser,在Close里调 cancel)。

第四层:服务端也有一套,别混了

写到这里容易和http.Server的超时混在一起:

srv := &http.Server{ ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, }

这是你作为服务端时,防慢客户端的。ReadHeaderTimeout尤其重要,不设的话,一个连上来之后一个字节一个字节慢慢发请求头的客户端(slowloris)就能长时间占着连接。它和客户端那套没有关系,只是名字像。

回到开头那种卡住

一个典型的排查顺序:

  1. goroutine dump(/debug/pprof/goroutine?debug=2)里看到卡住的栈停在net/http.(*persistConn).roundTrip,说明是在等响应,不是在建连;
  2. 往上翻调用链,发现走的是一个工具函数里的http.Get,用的是DefaultClient,没有任何超时;
  3. 对方服务接了连接但迟迟不回响应头(发布中、线程池打满都可能),而DefaultTransport没有ResponseHeaderTimeout,于是一直等。

修法很朴素:全局禁止直接用http.Get/http.DefaultClient,统一走一个配好超时的 client,并且调用处一律用带 context 的请求。可以在 CI 里加一条 grep 检查http.Get(/http.Post(/http.DefaultClient,比靠 code review 记得住靠谱。

一张速查表

配置在哪管哪一段默认
TimeoutClient全程,含读 body0(不限)
Dialer.TimeoutTransport.DialContextDNS + TCP 建连DefaultTransport 为 30s
TLSHandshakeTimeoutTransportTLS 握手DefaultTransport 为 10s
ResponseHeaderTimeoutTransport写完请求到收到响应头0(不限)
IdleConnTimeoutTransport空闲连接存活DefaultTransport 为 90s
context deadline每个 Request单次调用全程无

局限

这篇只讲了标准库的行为,用了第三方 HTTP 库(resty 之类)的话,它们的超时参数最终也是落到这几个字段上,但默认值各不相同,要去看具体库的文档。另外 HTTP/2 下一条连接上多路复用多个请求,连接池相关参数(MaxIdleConnsPerHost等)的意义和 HTTP/1.1 不同,这里没展开。

福兮(forxi.cn)上的网页截图、IP 查询这类功能都要调外部服务,写这类代码时这几层超时是最先要想清楚的事。文中的数值只是示例,没有标准答案,要按每个下游的实际响应时间来定,关键是每一层都要有值,别留 0。

返回列表