网络-23 Go HTTP 客户端与服务端实战
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_WAIT;MaxConnsPerHost 不设上限又可能在下游变慢时创建过多连接。前者决定复用能力,后者提供并发保护。
如果要自定义拨号阶段:
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. 一个可直接复用的客户端封装
把前面的规则收敛成三句话:
Transport按网络策略和故障隔离边界长期复用,绝不能每次请求都创建;- 可以按超时语义创建少量
Client,共享同一个Transport和连接池; - 每个请求都携带 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 已传递 | 使用 NewRequestWithContext 或 req.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. 重试必须满足三个条件
网络超时后,客户端往往不知道请求处于哪种状态:
请求没发出去
请求已到达但响应丢了
服务端已完成操作但客户端超时
因此不是所有请求都能重试。至少同时满足:
- 操作可重试:GET/HEAD 等幂等操作,或服务端支持
Idempotency-Key; - 请求体可重放:内存/文件可以重新读取,流式 body 通常不能;
- 仍有时间预算:遵守 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 服务应:
- 收到 SIGTERM;
- 先从服务发现/LB 摘除或让 readiness 失败;
- 调用
Server.Shutdown停止接受新连接并等待现有 Handler; - 超过总预算后强制
Close; - 等待必要的后台 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.Client 和 http.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。服务发现摘流、后台任务和长连接都需要应用额外协调。
xingliuhua