适用场景
本文适用于 Go 服务通过 net/http 调用内部 API、第三方接口或对象存储的场景。典型现象是:下游偶发变慢后,请求大量超时;下游恢复后,调用方吞吐仍然偏低;连接数、TIME_WAIT 或新建 TLS 连接数明显上升;日志只看到 context deadline exceeded,却无法判断超时发生在哪一层。
示例基于 Go 标准库,不依赖第三方包,重点解决三个问题:
- 为一次业务调用建立明确的总预算,并把取消信号传到 HTTP 请求;
- 正确关闭和有限排空响应体,让可复用连接回到连接池;
- 分离连接、TLS、响应头和整体请求的超时,避免一个模糊的超时掩盖根因。
现象描述
一段看似正常的代码可能埋下连接泄漏和取消失效问题:
func fetch(rawURL string) ([]byte, error) {
resp, err := http.Get(rawURL)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下游返回异常状态: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}
这里至少有三处风险:
- 没有
defer resp.Body.Close(),成功和失败路径都可能长期占用资源; - 非 200 分支既不关闭响应体,也不读取响应体,连接通常无法复用;
- 使用包级
http.Get,没有业务context,调用方取消后下游请求仍可能继续运行。
流量较小时问题不明显;当下游返回大量 429、500 或慢响应时,旧连接不能及时回池,客户端会不断拨号和握手,最终把一次下游抖动放大成调用方自身的资源紧张。
先区分四类超时
不要只设置一个很大的 http.Client.Timeout 就结束治理。HTTP 调用至少包含以下阶段:
| 阶段 | 常用控制项 | 主要含义 |
|---|---|---|
| 建连 | net.Dialer.Timeout |
TCP 连接建立的最长等待时间 |
| TLS 握手 | TLSHandshakeTimeout |
HTTPS 握手最长等待时间 |
| 等待响应头 | ResponseHeaderTimeout |
请求已写出后,等待响应头的最长时间 |
| 整体调用 | context.WithTimeout 或 Client.Timeout |
包含读取响应体在内的总预算 |
业务代码更适合使用 context.WithTimeout 表达“本次调用还剩多少时间”,因为它可以从入口一路传播到数据库、缓存和 HTTP 请求。Client.Timeout 可以作为客户端级安全上限,但不要让它短于业务预算,否则日志中只看到客户端超时,很难还原上层取消原因。
建立可复用的客户端
不要为每次请求创建新的 http.Client 或 http.Transport。Transport 内部维护连接池,应在进程生命周期内复用:
package downstream
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
)
const maxResponseBytes = 2 << 20
// Client 封装下游 HTTP 调用,并统一管理超时与响应大小限制。
type Client struct {
baseURL string
httpClient *http.Client
}
// NewClient 创建可长期复用的下游客户端。
func NewClient(baseURL string) *Client {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 2 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
MaxConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
},
}
}
// GetUser 在调用方预算内获取用户数据。
func (c *Client) GetUser(ctx context.Context, userID string) ([]byte, error) {
requestURL := fmt.Sprintf("%s/users/%s", c.baseURL, userID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, fmt.Errorf("创建用户查询请求失败: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("查询用户超过调用预算: %w", err)
}
if errors.Is(err, context.Canceled) {
return nil, fmt.Errorf("查询用户已被上游取消: %w", err)
}
return nil, fmt.Errorf("调用用户服务失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, fmt.Errorf("读取用户服务响应失败: %w", err)
}
if len(body) > maxResponseBytes {
return nil, fmt.Errorf("用户服务响应超过限制: limit_bytes=%d", maxResponseBytes)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("用户服务返回异常状态: status_code=%d", resp.StatusCode)
}
return body, nil
}
关键配置的判断方式:
MaxIdleConnsPerHost不是最大并发数,而是单个目标可保留的空闲连接数。过小会导致流量波动时频繁重建连接;MaxConnsPerHost限制单个目标的连接总数,可防止故障期间无限拨号,但设置过小会让请求在客户端排队;ResponseHeaderTimeout只约束等待响应头,不约束读取完整响应体;io.LimitReader防止异常响应吃光内存。多读取 1 字节是为了可靠判断响应是否超限。
在入口设置总预算
调用预算应由离业务入口最近的一层决定,并预留序列化、日志和返回响应的时间:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel()
body, err := h.userClient.GetUser(ctx, r.PathValue("user_id"))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "下游服务响应超时", http.StatusGatewayTimeout)
return
}
http.Error(w, "获取用户信息失败", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
如果入口请求已经有更短的截止时间,NewRequestWithContext 会继承它;再套一个更长的超时不会延长父级预算。调用链中的每一层都应接受 context.Context 作为第一个参数,不要在内部改用 context.Background(),否则取消链会被切断。
响应体到底要不要排空
基本规则是:只要 Do 成功返回 resp,就立即安排 resp.Body.Close()。是否额外排空,要看是否需要复用该连接以及剩余响应体的大小。
对于已知很小的错误响应,可以有限排空:
const maxDrainBytes = 32 << 10
func closeResponse(resp *http.Response) {
_, _ = io.CopyN(io.Discard, resp.Body, maxDrainBytes)
_ = resp.Body.Close()
}
但不能对未知大小的响应无上限执行 io.Copy(io.Discard, resp.Body)。如果下游持续发送数据,排空动作本身会占住 goroutine 和连接。有限排空后仍未到 EOF,当前连接可能无法复用,这是用确定的资源上限换取故障隔离,通常比无限读取更安全。
对于正常响应,业务本来就会读到 EOF,随后关闭即可。对于大文件下载,应流式处理并设置独立的大小、速率和总时长限制,不能沿用“小 JSON 响应”的读取策略。
用 httptrace 定位连接是否复用
当怀疑连接池抖动时,可以在受控采样下挂载 httptrace:
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
logger.Info("获取下游连接",
"reused", info.Reused,
"was_idle", info.WasIdle,
"idle_time_ms", info.IdleTime.Milliseconds(),
)
},
GotFirstResponseByte: func() {
logger.Info("收到下游首字节")
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
重点观察:
- 正常稳定流量中
reused=false是否持续偏高; - TLS 握手数是否与请求数同步增长;
- 等待连接的时间是否显著增加;
- 首字节延迟高,还是读取响应体阶段耗时高。
跟踪日志不应对所有请求永久开启。高并发下应按请求 ID 采样,并使用稳定的英文 snake_case 字段;不要记录 Authorization、Cookie 或完整响应体。
可重复的测试
使用 httptest.Server 可以验证取消是否真正到达服务端,以及失败响应能否及时回收:
func TestGetUserCancelsSlowRequest(t *testing.T) {
requestCanceled := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
close(requestCanceled)
case <-time.After(time.Second):
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
client := NewClient(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
_, err := client.GetUser(ctx, "42")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("期望截止时间错误,实际为 %v", err)
}
select {
case <-requestCanceled:
case <-time.After(300 * time.Millisecond):
t.Fatal("服务端未观察到请求取消")
}
}
func TestGetUserRejectsOversizedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bytes.Repeat([]byte("a"), maxResponseBytes+1))
}))
defer server.Close()
client := NewClient(server.URL)
_, err := client.GetUser(context.Background(), "42")
if err == nil || !strings.Contains(err.Error(), "响应超过限制") {
t.Fatalf("期望响应超限错误,实际为 %v", err)
}
}
测试不要只断言“返回了错误”。第一项测试还验证服务端收到了取消信号,能发现业务层丢弃 context 的回归;第二项测试验证客户端不会无界读取响应。
线上排查顺序
遇到超时和吞吐下降时,可按以下顺序缩小范围:
- 按下游目标统计成功、超时、取消和各状态码数量,不要把所有错误归为一个
request_failed; - 分别记录总耗时、获得连接耗时、TLS 握手耗时和首字节耗时;
- 检查
resp.Body.Close()是否覆盖所有Do成功后的返回路径; - 检查是否在循环或请求处理函数中重复创建
Transport; - 对比活跃连接、空闲连接、新建连接速率和 TIME_WAIT 数量;
- 确认重试是否仍处于同一个总预算内,并且只针对幂等操作和可恢复错误。
Linux 上可以辅助观察目标连接:
ss -tanp | grep ':443' | awk '{print $1}' | sort | uniq -c
该命令只能展示 TCP 状态分布,不能直接证明 Go 连接池泄漏。应结合应用侧 httptrace、请求速率和下游指标判断,避免看到 TIME_WAIT 就盲目修改内核参数。
修复方案与上线策略
建议分阶段上线:
- 先补齐所有响应体关闭路径和响应大小限制,这是最明确的正确性修复;
- 将散落的
http.Client收敛为按下游复用的客户端,并为不同下游设置独立连接池; - 从入口传递
context,建立略短于上游 SLA 的总预算; - 增加阶段耗时和连接复用率采样,再根据数据调整连接池,而不是直接把上限调大;
- 小流量灰度,观察超时率、拨号速率、TLS 握手数、goroutine 数和下游负载。
连接池参数应从并发量和单次耗时估算。例如单实例峰值 200 QPS、平均耗时 100 ms,平均在途请求约为 20;再考虑峰值、慢请求和重试,可从 MaxConnsPerHost=50 或 100 试起,通过压测和监控校准。这个估算不是固定公式,不能脱离请求分布直接照抄。
预防措施
- 统一封装下游客户端,禁止业务代码随手使用无预算的
http.Get; - 静态检查和代码评审重点关注
Do成功后的每个返回分支; - 对响应大小、错误体排空量、重试次数和总时长设置硬上限;
- 只对幂等请求自动重试,并加入退避和抖动,避免故障时形成重试风暴;
- 在压测中加入慢响应、响应头延迟、半途断流和大错误体,而不只测试 200;
- 服务关闭时停止接收新流量,等待在途请求,并在合适边界调用
CloseIdleConnections()回收空闲连接。
总结
Go HTTP 调用超时后吞吐迟迟不恢复,往往不是单一的“下游慢”,而是取消没有传播、响应体未正确处理和连接池参数不匹配共同放大的结果。可靠的治理方式是:由业务入口定义总预算,用 context 贯穿调用链,长期复用 Transport,在所有路径关闭响应体,并对读取与排空设置明确上限。
完成这些正确性约束后,再用阶段耗时、连接复用率和在途请求量校准连接池。这样既能在下游抖动时尽快止损,也能在恢复后迅速回到稳定吞吐。
Discussion
评论