写对一个 Go HTTP 服务:超时、Context、连接复用与优雅停机
一个能返回 200 OK 的 HTTP 服务很容易写;一个面对慢客户端、下游抖动和滚动发布仍然行为可预测的服务,则需要把超时、取消和资源所有权一起设计。
四层边界,而不是一个 Timeout
flowchart LR
C["客户端"] -->|ReadHeaderTimeout| H["读取请求头"]
H -->|ReadTimeout / 限制 Body| B["读取请求体"]
B -->|request Context| A["业务处理"]
A -->|WriteTimeout| W["写响应"]
A -->|下游 Context| D["数据库 / RPC"]
这些时间限制解决的是不同问题:
ReadHeaderTimeout防止客户端极慢地发送请求头,通常应显式设置。ReadTimeout覆盖读取整个请求(包括 Body);上传接口不能照搬普通 API 的数值。WriteTimeout限制写响应的时间,但流式响应、SSE 和大文件下载需要单独设计。IdleTimeout限制 Keep-Alive 连接等待下一次请求的时间。- 请求自身的
Context管理业务截止时间,并向数据库、RPC 和 goroutine 传播取消。
srv := &http.Server{
Addr: ":8080",
Handler: routes(),
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}不要直接用 http.ListenAndServe 代替显式的 http.Server:它让关键超时留在零值。具体数值没有通用答案,应以接口的正常延迟分布、Body 大小和部署层的超时为依据。
Body 既要限量,也要关闭
Content-Length 只是声明,不能当作可信边界。服务端应在解码前限制实际读取量:
func createUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in CreateUserRequest
if err := dec.Decode(&in); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// 使用 r.Context() 调用下游。
}反向代理、网关和应用层都可以有限制,但应用不能假设上游永远配置正确。
Context 只沿请求链传播
客户端断开、HTTP/2 请求取消或 Handler 返回时,请求 Context 会被取消。业务函数应该接收它,并把它原样传给支持 Context 的 API:
func (s *Service) User(ctx context.Context, id string) (User, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
return s.repo.FindUser(ctx, id)
}子超时应小于请求剩余预算。不要用 context.Background() 截断取消链,也不要把 Context 存进结构体。确实需要在响应后执行的任务,应交给有独立生命周期和持久化语义的队列,而不是偷偷启动一个 goroutine。
客户端和 Transport 要复用
http.Client 和 http.Transport 被设计为并发安全并应长期复用。每次请求创建一个 Transport,会丢掉连接池,并带来额外的 DNS、TCP、TLS 成本。
var upstreamClient = &http.Client{
Timeout: 2 * time.Second, // 整个交换的兜底上限
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
}
func fetch(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return nil, err }
return upstreamClient.Do(req)
}调用方负责关闭响应 Body。需要复用 HTTP/1.x 连接时,还应把 Body 读取到 EOF;如果响应可能很大,不要为了复用连接无上限地 io.Copy(io.Discard, resp.Body),宁可放弃该连接或设置读取上限。
优雅停机是一个协议
sequenceDiagram
participant O as 编排系统
participant S as HTTP Server
participant R as 正在处理的请求
O->>S: SIGTERM
S->>S: 停止接受新连接
S->>R: 等待 Handler 返回
alt 在停机预算内完成
R-->>S: 返回
S-->>O: 进程退出
else 超时
S-->>O: Shutdown 返回 deadline exceeded
end
func run() error {
srv := &http.Server{Addr: ":8080", Handler: routes()}
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) { return err }
return nil
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}Shutdown 会关闭监听器和空闲连接,再等待活动连接进入空闲;它不会替你处理被 Hijack 的连接,也不会自动停止消息消费者等后台组件。WebSocket 等长连接应通过 RegisterOnShutdown 或自己的生命周期管理器退出。停机预算还必须小于容器平台的强杀宽限期。
上线前检查
- 服务端四类超时是否显式、是否适合上传或流式接口?
- Body 是否有大小上限,客户端响应 Body 是否总能关闭?
- 客户端与 Transport 是否复用,连接池是否按目标主机容量配置?
- 下游调用是否都使用请求 Context,子预算是否合理?
- readiness 是否先摘流量,再执行
Shutdown?后台组件是否也能收敛?