Go HTTP 服务:从超时、重试、限流到优雅关闭

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{
// 建连超时 + TCP KeepAlive
DialContext: (&net.Dialer{
Timeout: 2 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
// TLS 握手与响应头超时
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()

// 下游调用必须继承 ctx
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)
// 幂等 key:避免重复写入
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()
// 仅对 5xx 做重试
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) // 每秒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。

请求路径就是:

  1. 先限流,确保服务不会被瞬时流量冲垮;
  2. 进入 handler,开启 800ms 的总预算;
  3. 调库存时用 300ms 子预算;
  4. 如果失败且预算够,再重试一次;
  5. 预算不够就快速失败;
  6. 服务下线时,优雅关闭让在途请求收尾。

这就是“超时→重试→限流→优雅关闭”的真实关系:每一步都为下一步留出空间。

结语

把这些机制按顺序串起来,你的服务就不会“只在某一个点上强、整体却不稳定”。下一步你可以在此基础上增加可观测性(日志/指标/trace)和熔断策略,让整条链路更易于排查和自愈。