跳至内容
写对一个 Go HTTP 服务:超时、Context、连接复用与优雅停机

写对一个 Go HTTP 服务:超时、Context、连接复用与优雅停机

2026年6月19日·
yanlong

一个能返回 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.Clienthttp.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?后台组件是否也能收敛?

进一步阅读:net/http.Servernet/http.Transport