目录

网络-23 Go HTTP 客户端与服务端实战

前置阅读:网络-10 TCP 实战调优网络-12 HTTP 报文网络-20 Socket 与 IO 模型

Go 的 net/http 把 Socket、连接池、HTTP 编解码和 TLS 都封装好了,但“能发出请求”离“适合生产”还差一段距离。本篇只讲最容易形成线上故障的部分。

1. 一次 HTTP 调用经过什么

客户端调用:

resp, err := client.Do(req)

背后可能经历:

从连接池取空闲连接
        │ 没有可用连接
        ▼
DNS → TCP 建连 → TLS 握手 → 写请求 → 等响应头 → 读响应体

连接池命中时,DNS、TCP 和 TLS 都可以跳过。因此服务端调用下游时,连接复用往往比调整单个内核参数更重要

2. 客户端:复用 Client 和 Transport

http.Client 可以并发使用,应该长期复用。不要每个请求创建一个新的 Transport

// 错误:每次调用都创建独立连接池
func call(url string) error {
    client := &http.Client{
        Transport: &http.Transport{},
    }
    _, err := client.Get(url)
    return err
}

生产配置可以从默认 Transport 克隆,保留 Go 随版本更新的合理默认值:

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = 200
transport.MaxIdleConnsPerHost = 50
transport.MaxConnsPerHost = 100
transport.IdleConnTimeout = 90 * time.Second
transport.ResponseHeaderTimeout = 3 * time.Second

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

几个参数的区别:

参数 控制什么
MaxIdleConns 所有目标合计最多保留多少空闲连接
MaxIdleConnsPerHost 单个目标最多保留多少空闲连接
MaxConnsPerHost 单个目标的总连接上限,包含拨号中、使用中和空闲连接
IdleConnTimeout 空闲连接在池中保留多久
ResponseHeaderTimeout 请求写完后,等待响应头的最长时间

MaxIdleConnsPerHost 太小会导致连接刚用完就被关闭,大量重复 TCP/TLS 握手和 TIME_WAITMaxConnsPerHost 不设上限又可能在下游变慢时创建过多连接。前者决定复用能力,后者提供并发保护。

如果要自定义拨号阶段:

dialer := &net.Dialer{
    Timeout:   2 * time.Second,
    KeepAlive: 30 * time.Second,
}
transport.DialContext = dialer.DialContext
transport.TLSHandshakeTimeout = 3 * time.Second

3. 响应体决定连接能否复用

每次成功得到响应后都必须关闭 Body

resp, err := client.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

const maxBody = 4 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
    return err
}
if len(body) > maxBody {
    return fmt.Errorf("response body too large")
}

对 HTTP/1.1,响应体被读到 EOF 并关闭后,连接才最容易安全回到空闲池。只 Close 而没有读完,Transport 可能无法复用这条连接。

但也不能为了复用而无上限地丢弃响应体。下游若返回几十 GB 或无限流,io.Copy(io.Discard, resp.Body) 会让 goroutine 永久占住连接。正确策略是:

  • 正常小响应:完整读取并关闭;
  • 已知有限但业务不需要:设置合理上限后读取;
  • 超大响应或流式响应:按业务消费;提前终止时接受该连接可能无法复用;
  • 所有响应都设置总体 deadline 或请求 context。

服务端会在 Handler 返回后关闭请求体,但 Handler 仍必须限制可读取的大小,避免客户端用超大或无限 body 消耗内存与连接:

r.Body = http.MaxBytesReader(w, r.Body, 8<<20) // 最大 8 MiB
defer r.Body.Close()

4. 超时必须分层

只配置一个“30 秒超时”无法回答到底慢在哪:

阶段 常用控制方式
DNS + TCP 建连 net.Dialer.Timeout
TLS 握手 TLSHandshakeTimeout
等待 100 Continue ExpectContinueTimeout
等响应头 ResponseHeaderTimeout
整个请求(包括读取 body) http.Client.Timeout 或 context deadline

业务代码更推荐用 context 表达每次调用的预算:

ctx, cancel := context.WithTimeout(parent, 800*time.Millisecond)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
    return err
}
resp, err := client.Do(req)

Client.Timeout 是客户端级的总上限,context 是单次请求的预算;二者同时存在时,更早到期的生效。

预算必须逐层递减。入口请求只剩 500ms 时,不应给数据库 1s、下游 HTTP 2s,再各自重试三次。应从入口 deadline 中为序列化和返回响应预留时间,把剩余预算传给下游。

判断超时不要依赖错误字符串:

if errors.Is(err, context.DeadlineExceeded) {
    // 请求 context 到期
}
if errors.Is(err, os.ErrDeadlineExceeded) {
    // 网络 deadline 到期
}

5. 一个可直接复用的客户端封装

把前面的规则收敛成三句话:

  1. Transport 按网络策略和故障隔离边界长期复用,绝不能每次请求都创建;
  2. 可以按超时语义创建少量 Client,共享同一个 Transport 和连接池;
  3. 每个请求都携带 context,响应 Body 必须关闭,小响应的未读部分可以有上限地排空。

下面的封装适合普通内部调用、第三方调用,以及必须在回调内同步消费完的流式响应:

package httpx

import (
    "context"
    "io"
    "net"
    "net/http"
    "time"
)

func newTransport() *http.Transport {
    // 从默认配置克隆,保留 ProxyFromEnvironment、HTTP/2 等合理默认值。
    t := http.DefaultTransport.(*http.Transport).Clone()
    t.DialContext = (&net.Dialer{
        Timeout:   3 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext
    t.MaxIdleConns = 500
    t.MaxIdleConnsPerHost = 100
    t.IdleConnTimeout = 90 * time.Second
    t.TLSHandshakeTimeout = 5 * time.Second
    t.ResponseHeaderTimeout = 5 * time.Second
    t.ForceAttemptHTTP2 = true
    return t
}

var transport = newTransport()

var (
    Fast   = &http.Client{Transport: transport, Timeout: 5 * time.Second}
    Slow   = &http.Client{Transport: transport, Timeout: 60 * time.Second}
    Stream = &http.Client{Transport: transport} // 总时限完全由 context 控制
)

// 最多尝试排空 64 KiB。若仍未到 EOF,Close 通常会放弃复用该 HTTP/1.x 连接,
// 避免为了复用连接而读取一个巨大或永不结束的响应体。
const maxDrain = 64 << 10

// Do 在 Body 关闭前调用 handle。handle 必须在返回前完成 Body 的读取,
// 不能把 resp 或 resp.Body 保存下来供回调结束后使用。
func Do(
    ctx context.Context,
    c *http.Client,
    req *http.Request,
    handle func(*http.Response) error,
) error {
    resp, err := c.Do(req.WithContext(ctx))
    if err != nil {
        return err
    }
    defer func() {
        _, _ = io.CopyN(io.Discard, resp.Body, maxDrain)
        _ = resp.Body.Close()
    }()

    return handle(resp)
}

这里用 http.DefaultTransport.Clone(),比从空的 http.Transport{} 开始更稳妥。多个 Client 只是提供不同的总超时语义,因为底层指向同一个 Transport,连接池仍然只有一份。注意 ResponseHeaderTimeout 属于共享的 Transport:上例中的 Slow 虽然总超时是 60 秒,等待响应头仍然最多 5 秒。如果某个下游连分阶段超时策略也不同,就应该使用单独的 Transport。

Stream 没有设置 Client.Timeout,调用方必须提供带 deadline 或可取消的 context,而且要在回调返回前完成流的消费。

只关心状态码时:

func sendReq(ctx context.Context, url string) error {
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        return err
    }

    return httpx.Do(ctx, httpx.Fast, req, func(resp *http.Response) error {
        if resp.StatusCode >= 400 {
            return fmt.Errorf("unexpected status %d", resp.StatusCode)
        }
        return nil
    })
}

需要解析 JSON 时,应同时限制响应大小,防止异常下游把内存撑满:

func fetchUser(ctx context.Context, url string) (*User, error) {
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }

    const maxJSON = 1 << 20
    var u User
    err = httpx.Do(ctx, httpx.Fast, req, func(resp *http.Response) error {
        if resp.StatusCode != http.StatusOK {
            return fmt.Errorf("unexpected status %d", resp.StatusCode)
        }

        data, err := io.ReadAll(io.LimitReader(resp.Body, maxJSON+1))
        if err != nil {
            return err
        }
        if len(data) > maxJSON {
            return fmt.Errorf("response body too large")
        }
        return json.Unmarshal(data, &u)
    })
    if err != nil {
        return nil, err
    }
    return &u, nil
}

回调式 API 的价值是把 Body 生命周期收口,让调用方没有机会忘记关闭。代价是它不适合把响应体交给外层异步消费。团队若选择直接返回 *http.Response,纪律就是:检查 err 后立刻 defer resp.Body.Close(),并明确由谁把 body 消费到 EOF。

Review HTTP 调用时可以逐项检查:

项目 说明
Transport 长期复用 不在请求函数里创建新的 http.Transport
空闲池容量经过调优 MaxIdleConnsPerHost 默认是 2,高频调用通常需要调大
总连接数可控 必要时用 MaxConnsPerHost 防止慢下游拖垮本进程
超时语义完整 Client.Timeout 兜底,context 控制单次请求;流式请求只靠 context
context 已传递 使用 NewRequestWithContextreq.WithContext
Body 已关闭 必须在确认 err == nil、获得响应之后关闭
连接复用策略明确 小响应读到 EOF;未读部分只做有上限的排空
检查了状态码 err == nil 只表示成功收到响应,不代表业务成功
没有裸用便捷函数 http.Get 使用无总超时的默认 Client,也不方便传 context

以下情况适合单独创建并长期持有一个 Transport

  • TLS 配置不同,例如 mTLS、自定义 CA;
  • 使用不同代理;
  • 核心链路需要与不稳定下游隔离连接池和并发上限;
  • 需要独立调用 CloseIdleConnections 做热重连。

共享 Transport 时,在任意一个 Client 上调用 CloseIdleConnections() 都会影响共享这个 Transport 的所有 Client。除此之外,通常没有必要拆分连接池。

每次请求新建 Transport 会让连接池失去意义,导致重复握手、TIME_WAIT 增多,并可能让空闲连接及其 goroutine 长时间滞留;响应 Body 从不关闭则会直接占住连接和文件描述符。两者叠加在高频调用下很容易演变成 too many open files

6. 重试必须满足三个条件

网络超时后,客户端往往不知道请求处于哪种状态:

请求没发出去
请求已到达但响应丢了
服务端已完成操作但客户端超时

因此不是所有请求都能重试。至少同时满足:

  1. 操作可重试:GET/HEAD 等幂等操作,或服务端支持 Idempotency-Key
  2. 请求体可重放:内存/文件可以重新读取,流式 body 通常不能;
  3. 仍有时间预算:遵守 context deadline,使用指数退避和 jitter。

一个简单的退避序列可以是 50ms、100ms、200ms,并加入随机抖动,避免大量实例同时重试形成重试风暴

支付、下单等 POST 应由客户端生成稳定的幂等键:

Idempotency-Key: 7b55b02e-...

服务端按“调用方 + 幂等键”保存执行状态和结果。相同键再次到来时返回原结果,而不是重新创建订单。幂等键需要过期策略,也要校验相同键对应的请求参数是否一致。

7. 服务端:不要直接使用默认配置

最小示例里的:

http.ListenAndServe(":8080", mux)

适合演示,但无法设置关键超时。生产服务应显式创建 http.Server

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    IdleTimeout:       90 * time.Second,
    MaxHeaderBytes:    1 << 20,
}

为什么优先配置 ReadHeaderTimeout:攻击者可以建立连接后极慢地发送请求头,占住连接和 goroutine,这就是 Slowloris。限制请求头读取时间可以挡住最典型的攻击。

ReadTimeout 覆盖读取整个请求(包括 body),不适合所有大文件上传;WriteTimeout 对流式下载、SSE、WebSocket 也要谨慎。不存在适合所有接口的一个全局值,通常需要:

  • 全局保护请求头;
  • 普通 API 使用中间件设置请求 deadline;
  • 上传接口限制大小和速率;
  • 流式接口使用独立策略和心跳。

Handler 还应限制并发和资源:

  • 有界 worker/semaphore,过载时尽早返回 429/503;
  • MaxBytesReader 限制 body;
  • 数据库和下游调用继承 r.Context()
  • panic recovery 记录堆栈并返回 500;
  • 不把客户端可控数据直接拼进日志、SQL 或响应头。

8. 客户端断开后要停止工作

请求 context 会在客户端连接断开、HTTP/2 流被取消或 Handler 返回时取消。下游调用应传递它:

func handler(w http.ResponseWriter, r *http.Request) {
    row := db.QueryRowContext(r.Context(), query)

    req, err := http.NewRequestWithContext(
        r.Context(),
        http.MethodGet,
        downstreamURL,
        nil,
    )
    // ...
}

如果收到取消后仍用 context.Background() 跑完整条链路,客户端虽然已经离开,数据库和下游资源仍会继续消耗。只有明确需要脱离请求生命周期的后台任务,才应复制必要数据后交给独立、有界、可观测的任务系统。

9. 优雅关闭

发布时直接杀进程会中断正在处理的请求。Go 服务应:

  1. 收到 SIGTERM;
  2. 先从服务发现/LB 摘除或让 readiness 失败;
  3. 调用 Server.Shutdown 停止接受新连接并等待现有 Handler;
  4. 超过总预算后强制 Close
  5. 等待必要的后台 goroutine 收尾。
srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    IdleTimeout:       90 * time.Second,
}

errCh := make(chan error, 1)
go func() {
    errCh <- srv.ListenAndServe()
}()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

select {
case sig := <-sigCh:
    log.Printf("shutting down: %v", sig)
case err := <-errCh:
    if !errors.Is(err, http.ErrServerClosed) {
        log.Fatalf("serve: %v", err)
    }
}

ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    _ = srv.Close()
}

Shutdown 不会自动等待你自行启动的后台 goroutine,也不会替 WebSocket 等已劫持连接做完整协议关闭,这些资源要由应用单独登记和停止。

10. 反向代理后的真实客户端地址

Go 服务通常只看到代理/LB 的 RemoteAddr。真实客户端地址可能由代理写入:

  • Forwarded
  • X-Forwarded-For
  • X-Real-IP

这些头可以被公网客户端自行伪造。只有请求确实来自受信任代理时才能读取它们,并且要按代理追加规则从右向左去掉可信代理地址,不能简单取 X-Forwarded-For 的第一个值。

这关系到限流、审计和安全策略。若信任边界配置错误,攻击者可以伪造 IP 绕过限流或污染审计日志。

11. 可观测性:慢在哪一段

客户端可用 httptrace 分解 DNS、连接、TLS 和首字节耗时:

trace := &httptrace.ClientTrace{
    DNSDone: func(info httptrace.DNSDoneInfo) {
        log.Printf("dns: %+v", info)
    },
    GotConn: func(info httptrace.GotConnInfo) {
        log.Printf("reused=%v idle=%v", info.Reused, info.WasIdle)
    },
    GotFirstResponseByte: func() {
        log.Printf("first response byte")
    },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))

生产指标至少关注:

  • 按下游和状态码统计的请求数、错误率和延迟分位;
  • 连接池命中/新建连接、DNS/TCP/TLS 耗时;
  • 超时、取消和重试次数;
  • 服务端在途请求、排队时间、响应大小;
  • goroutine、fd、TCP 状态和进程内存。

只记录“调用耗时 2 秒”不够。必须能区分是连接池排队、DNS、握手、服务端首字节,还是读取大 body 慢。

12. 本章面试题

为什么 http.Client 和 Transport 要复用?

Transport 持有连接池。每次请求新建 Transport 等于每次创建一个新池,无法复用 TCP/TLS 连接,会造成重复握手、大量 TIME_WAIT、临时端口和 NAT 端口压力。

http.Clienthttp.Transport 都支持并发复用。通常按不同的下游策略创建少量长期 Client,而不是按请求创建。

为什么关闭 resp.Body 以后连接仍可能没有复用?

对 HTTP/1.1,Transport 还需要确认响应体边界已经完整消费。只 Close 但没有读到 EOF 时,剩余字节可能仍在连接上,无法安全用于下一次响应,所以连接可能被关闭。

小响应应完整读取并关闭;超大或流式响应要按业务消费,不能为了复用无上限 drain。

Client.Timeout、context 和 ResponseHeaderTimeout 有什么区别?
  • Client.Timeout:覆盖连接、重定向、读取响应体在内的整个请求;
  • context deadline:单次业务调用的预算,还能传递给数据库和其他下游;
  • ResponseHeaderTimeout:请求写完后只限制等待响应头的阶段,不限制读取 body。

生产中通常组合使用,并让下游预算小于入口剩余 deadline。

HTTP 请求超时后可以直接重试吗?

不可以。超时不代表服务端没有执行,请求可能已经完成,只是响应丢失。

只有操作幂等或有可靠幂等键、请求体可重放、仍有时间预算时才能重试,并使用指数退避和 jitter。支付/下单等 POST 必须由服务端按 Idempotency-Key 去重。

Go HTTP 服务为什么要配置 ReadHeaderTimeout?

防止客户端极慢地发送请求头、长期占住连接和资源,即 Slowloris。默认的 http.ListenAndServe 无法设置这些关键超时,生产中应显式创建 http.Server

Server.Shutdown 做了什么?还有什么不会做?

它关闭监听器、停止接受新连接,并等待普通活动连接变为空闲后关闭,直到 context 到期。

它不会自动等待应用自己启动的后台 goroutine,也不会自动管理 WebSocket 等 hijacked connection。服务发现摘流、后台任务和长连接都需要应用额外协调。