适用场景

本文适用于 Go 服务通过 net/http 调用内部 API、第三方接口或对象存储的场景。典型现象是:下游偶发变慢后,请求大量超时;下游恢复后,调用方吞吐仍然偏低;连接数、TIME_WAIT 或新建 TLS 连接数明显上升;日志只看到 context deadline exceeded,却无法判断超时发生在哪一层。

示例基于 Go 标准库,不依赖第三方包,重点解决三个问题:

  1. 为一次业务调用建立明确的总预算,并把取消信号传到 HTTP 请求;
  2. 正确关闭和有限排空响应体,让可复用连接回到连接池;
  3. 分离连接、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.WithTimeoutClient.Timeout 包含读取响应体在内的总预算

业务代码更适合使用 context.WithTimeout 表达“本次调用还剩多少时间”,因为它可以从入口一路传播到数据库、缓存和 HTTP 请求。Client.Timeout 可以作为客户端级安全上限,但不要让它短于业务预算,否则日志中只看到客户端超时,很难还原上层取消原因。

建立可复用的客户端

不要为每次请求创建新的 http.Clienthttp.TransportTransport 内部维护连接池,应在进程生命周期内复用:

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 的回归;第二项测试验证客户端不会无界读取响应。

线上排查顺序

遇到超时和吞吐下降时,可按以下顺序缩小范围:

  1. 按下游目标统计成功、超时、取消和各状态码数量,不要把所有错误归为一个 request_failed
  2. 分别记录总耗时、获得连接耗时、TLS 握手耗时和首字节耗时;
  3. 检查 resp.Body.Close() 是否覆盖所有 Do 成功后的返回路径;
  4. 检查是否在循环或请求处理函数中重复创建 Transport
  5. 对比活跃连接、空闲连接、新建连接速率和 TIME_WAIT 数量;
  6. 确认重试是否仍处于同一个总预算内,并且只针对幂等操作和可恢复错误。

Linux 上可以辅助观察目标连接:

ss -tanp | grep ':443' | awk '{print $1}' | sort | uniq -c

该命令只能展示 TCP 状态分布,不能直接证明 Go 连接池泄漏。应结合应用侧 httptrace、请求速率和下游指标判断,避免看到 TIME_WAIT 就盲目修改内核参数。

修复方案与上线策略

建议分阶段上线:

  1. 先补齐所有响应体关闭路径和响应大小限制,这是最明确的正确性修复;
  2. 将散落的 http.Client 收敛为按下游复用的客户端,并为不同下游设置独立连接池;
  3. 从入口传递 context,建立略短于上游 SLA 的总预算;
  4. 增加阶段耗时和连接复用率采样,再根据数据调整连接池,而不是直接把上限调大;
  5. 小流量灰度,观察超时率、拨号速率、TLS 握手数、goroutine 数和下游负载。

连接池参数应从并发量和单次耗时估算。例如单实例峰值 200 QPS、平均耗时 100 ms,平均在途请求约为 20;再考虑峰值、慢请求和重试,可从 MaxConnsPerHost=50100 试起,通过压测和监控校准。这个估算不是固定公式,不能脱离请求分布直接照抄。

预防措施

  • 统一封装下游客户端,禁止业务代码随手使用无预算的 http.Get
  • 静态检查和代码评审重点关注 Do 成功后的每个返回分支;
  • 对响应大小、错误体排空量、重试次数和总时长设置硬上限;
  • 只对幂等请求自动重试,并加入退避和抖动,避免故障时形成重试风暴;
  • 在压测中加入慢响应、响应头延迟、半途断流和大错误体,而不只测试 200;
  • 服务关闭时停止接收新流量,等待在途请求,并在合适边界调用 CloseIdleConnections() 回收空闲连接。

总结

Go HTTP 调用超时后吞吐迟迟不恢复,往往不是单一的“下游慢”,而是取消没有传播、响应体未正确处理和连接池参数不匹配共同放大的结果。可靠的治理方式是:由业务入口定义总预算,用 context 贯穿调用链,长期复用 Transport,在所有路径关闭响应体,并对读取与排空设置明确上限。

完成这些正确性约束后,再用阶段耗时、连接复用率和在途请求量校准连接池。这样既能在下游抖动时尽快止损,也能在恢复后迅速回到稳定吞吐。