Go HTTP 服务:从超时、重试、限流到优雅关闭
做一个稳定的 HTTP 服务,很多人一开始会盯着某一个点,比如“重试要不要做”。但真正落地时你会发现,这些点是按请求链路串起来的:先定超时预算,再谈重试;有限流兜底,才能扛住重试带来的放大;最后用优雅关闭保证上线/下线过程不伤用户。
这篇文章按“请求一进一出”的顺序来走,让每一步都能自然衔接到下一步。
1. 先定总预算:端到端超时
如果没有一个明确的“总耗时上限”,下游一旦卡住,你的服务就会被拖死。最常见的做法是:客户端、服务端和业务内部都设置超时,并用 context 把预算传下去。
服务端最小配置:
1 2 3 4 5 6 7 8 9 10 11
| srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, }
|
客户端最小配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| transport := &http.Transport{ DialContext: (&net.Dialer{ Timeout: 2 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 2 * time.Second, ResponseHeaderTimeout: 3 * time.Second, IdleConnTimeout: 60 * time.Second, MaxIdleConns: 200, MaxIdleConnsPerHost: 20, }
client := &http.Client{ Timeout: 5 * time.Second, Transport: transport, }
|
业务内部要把预算传给下游:
1 2 3 4 5 6 7 8 9 10 11 12 13
| func handler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel()
resp, err := callDownstream(ctx) if err != nil { http.Error(w, err.Error(), http.StatusGatewayTimeout) return } _, _ = w.Write(resp) }
|
这一步的核心是:先把时间预算定死。没有预算,后面的重试和连接池配置都没有意义。
2. 在预算内重试:只对可重试错误出手
重试是为了提升成功率,但它也会“吃掉”你的预算。做法是:
- 只重试网络错误、超时、5xx;
- POST 要配幂等 key,否则是重复扣款/重复写入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| func retry(ctx context.Context, max int, fn func() error) error { backoff := 100 * time.Millisecond for i := 0; i < max; i++ { if err := fn(); err == nil { return nil } select { case <-time.After(backoff): backoff *= 2 case <-ctx.Done(): return ctx.Err() } } return fmt.Errorf("retry failed after %d attempts", max) }
|
示例:幂等 key 的重试请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
req.Header.Set("Idempotency-Key", uuid.NewString())
err := retry(ctx, 3, func() error { resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 500 { return fmt.Errorf("server error: %d", resp.StatusCode) } return nil })
|
这里和上一步的关系:重试必须受 context 控制,否则一重试就可能直接超时。
3. 入口限流:给“重试放大”留个安全阀
重试会放大流量。如果没有限流,峰值+重试很容易把服务打挂。入口限流是最简单有效的兜底。
1 2 3 4 5 6 7 8 9 10 11 12
| var limiter = rate.NewLimiter(100, 200)
func limitMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !limiter.Allow() { http.Error(w, "too many requests", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) }
|
这里和上一步的关系:你允许重试,就必须能承受它放大的请求量;限流就是这道保险。
4. 连接池与 KeepAlive:把延迟波动压下来
连接池配置会影响“每次请求消耗多少时间”。如果连接池太小,频繁建连会占用你的超时预算;如果太大,资源消耗会被放大。
1 2 3 4 5 6 7
| transport := &http.Transport{ MaxIdleConns: 200, MaxIdleConnsPerHost: 50, IdleConnTimeout: 90 * time.Second, DisableCompression: false, }
|
这一步和前面的关系是:连接效率决定了你是否能把预算留给真正的业务处理。
5. 优雅关闭:给在途请求一个“善终”
服务上线/下线是常态,如果直接 Close(),会让正在处理的请求直接失败。优雅关闭的思路是:停止接新请求,给在途请求时间完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| srv := &http.Server{Addr: ":8080", Handler: mux}
go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen error: %v", err) } }()
quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(ctx)
|
这里和前面的关系是:只有在超时、重试、限流都合理时,优雅关闭才不会导致大量失败。
6. 一个完整请求的示例(把流程串起来)
假设你的订单服务 CreateOrder 调用库存服务 ReserveStock:
- 总超时 800ms;
- 下游调用 300ms 超时;
- 最多重试 1 次,退避 100ms;
- 入口限流 200 rps,桶 400。
请求路径就是:
- 先限流,确保服务不会被瞬时流量冲垮;
- 进入 handler,开启 800ms 的总预算;
- 调库存时用 300ms 子预算;
- 如果失败且预算够,再重试一次;
- 预算不够就快速失败;
- 服务下线时,优雅关闭让在途请求收尾。
这就是“超时→重试→限流→优雅关闭”的真实关系:每一步都为下一步留出空间。
结语
把这些机制按顺序串起来,你的服务就不会“只在某一个点上强、整体却不稳定”。下一步你可以在此基础上增加可观测性(日志/指标/trace)和熔断策略,让整条链路更易于排查和自愈。