xixixi.lol
go

Go 的 http.Client 超时到底该怎么设

这个问题我见过至少三种错的写法,包括我自己早几年写的。 根本原因是 http.Client 上有两层超时,名字还都挺像, 很容易只设一层就觉得完事了。

第一层:Client.Timeout

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

这是总时长:从发起请求开始,到响应体读完为止。包括 DNS 解析、TCP 连接、 TLS 握手、发请求、等响应头、读响应体。听起来很全,问题在于它是个总闸—— 你不能区分"连接慢"和"服务端处理慢"。

而且有个坑:它是靠 context 实现的,超时的时候报的错长得像这样:

Get "https://api.example.com/x": context deadline exceeded
    (Client.Timeout exceeded while awaiting headers)

如果是在读 body 的时候超时,错误会出现在 io.Copy 或 json.Decode 那一步, 而不是 client.Do 返回的位置。这个很容易漏判。

第二层:Transport 里的细项

tr := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    TLSHandshakeTimeout:   3 * time.Second,
    ResponseHeaderTimeout: 5 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
    IdleConnTimeout:       90 * time.Second,
}

这几个可以分开调,出问题的时候日志里能看出是哪一段慢。 我一般 Client.Timeout 设成略大于各项之和,作为一个兜底。

最容易忽略的一条:MaxIdleConnsPerHost

默认值是 2。对,2。如果你的服务要并发打同一个后端, 很快就会发现连接一直在新建,TIME_WAIT 涨得飞快,延迟也上去了:

tr := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 20,
}

这个默认值坑过我一次。现象是压测时 QPS 上不去,但 CPU 和带宽都很闲, ss -s 一看 TIME_WAIT 好几万。查了半天才发现是这里。

响应体必须读完

连接复用要求 body 被读完并关闭。只 Close() 不读,这条连接就废了, 下次还得新建:

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
    io.Copy(io.Discard, resp.Body)
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)

注意 io.Copy(io.Discard, ...) 那行。以前这里我直接 return 了, 结果错误路径下的连接全都没被复用。

context 什么时候用

如果超时要跟着请求的生命周期走(比如 HTTP handler 里的下游调用), 用 context 而不是 Client.Timeout:

ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)

这样上游断开的时候下游请求也会一起取消,不会白白占着连接。 Client.Timeout 和 context 可以同时用,取先到的那个。

一句话版本

Client.Timeout 兜底,Transport 里各段分开设, MaxIdleConnsPerHost 记得调,body 一定要读完。

← 返回笔记