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.TimeoutTCP 三次握手的上限包括 DNS 解析。对方机器挂了、防火墙丢包不回 RST不设这个会等到操作系统的 SYN 重试耗尽Linux 上默认是一两分钟。TLSHandshakeTimeoutTCP 连上之后 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就能长时间占着连接。它和客户端那套没有关系只是名字像。回到开头那种卡住一个典型的排查顺序goroutine dump/debug/pprof/goroutine?debug2里看到卡住的栈停在net/http.(*persistConn).roundTrip说明是在等响应不是在建连往上翻调用链发现走的是一个工具函数里的http.Get用的是DefaultClient没有任何超时对方服务接了连接但迟迟不回响应头发布中、线程池打满都可能而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 为 30sTLSHandshakeTimeoutTransportTLS 握手DefaultTransport 为 10sResponseHeaderTimeoutTransport写完请求到收到响应头0不限IdleConnTimeoutTransport空闲连接存活DefaultTransport 为 90scontext deadline每个 Request单次调用全程无局限这篇只讲了标准库的行为用了第三方 HTTP 库resty 之类的话它们的超时参数最终也是落到这几个字段上但默认值各不相同要去看具体库的文档。另外 HTTP/2 下一条连接上多路复用多个请求连接池相关参数MaxIdleConnsPerHost等的意义和 HTTP/1.1 不同这里没展开。福兮forxi.cn上的网页截图、IP 查询这类功能都要调外部服务写这类代码时这几层超时是最先要想清楚的事。文中的数值只是示例没有标准答案要按每个下游的实际响应时间来定关键是每一层都要有值别留 0。
返回列表