没有超时的服务很容易被慢请求拖住
很多 Go 入门 HTTP 服务都是这样启动的:
log.Fatal(http.ListenAndServe(":8080", mux))
这段代码能跑,但它没有显式配置超时。开发环境里看不出问题,到了真实网络环境就可能遇到慢客户端、半开连接、上传请求迟迟不结束、响应写不出去等情况。一个服务如果没有基本超时保护,就可能被少量异常连接占住资源。
Go 标准库的 http.Server 提供了几个常用超时字段:ReadTimeout、ReadHeaderTimeout、WriteTimeout、IdleTimeout。它们不是越短越好,也不是每个服务都一样,但入门阶段至少应该知道这些字段控制什么,以及为什么直接用 http.ListenAndServe 不够清楚。
这篇文章用一个简单 API 服务讲超时配置、请求 context 和 handler 内部超时。
使用 http.Server 显式配置
推荐从显式 server 开始:
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
mux.HandleFunc("/users", usersHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("listening on :8080")
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
ReadHeaderTimeout 限制读取请求头的时间,能防止客户端很慢地发送 header。ReadTimeout 限制读取整个请求的时间,包括 body。WriteTimeout 限制写响应的时间。IdleTimeout 控制 keep-alive 连接空闲多久后关闭。
对普通 JSON API 来说,请求体通常不大,读写超时可以设置得比较保守。对文件上传、流式响应、长轮询接口,就要更谨慎,不能简单套一个很短的 WriteTimeout。
Handler 里也要尊重 context
HTTP server 的超时和请求 context 是两层保护。Handler 内部做数据库查询或调用外部 API 时,应该把 r.Context() 传下去:
func usersHandler(w http.ResponseWriter, r *http.Request) {
users, err := listUsers(r.Context())
if err != nil {
http.Error(w, "load users failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
下游函数:
func listUsers(ctx context.Context) ([]User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/users", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var users []User
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
return nil, err
}
return users, nil
}
如果客户端断开连接,或者 server 认为请求超时,r.Context() 会取消。下游 HTTP 请求能感知取消,而不是继续占用资源。
给内部操作设置更短超时
有时服务整体写超时是 10 秒,但某个外部 API 最多只应该等 2 秒。可以在请求 context 上派生一个更短的 context:
func callProfileAPI(ctx context.Context, userID int64) (Profile, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
url := fmt.Sprintf("https://api.example.com/profiles/%d", userID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Profile{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Profile{}, fmt.Errorf("call profile api: %w", err)
}
defer resp.Body.Close()
var profile Profile
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
return Profile{}, fmt.Errorf("decode profile: %w", err)
}
return profile, nil
}
这样外部依赖慢时,不会把整个请求一直拖住。defer cancel() 也很重要,它会释放 context 相关资源。
不同接口要区别对待
健康检查:
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
它应该非常快,不应该依赖慢外部服务。文件上传接口则可能需要更长的读超时,流式下载可能需要更长的写超时。你可以把不同类型服务拆成不同 server,或者在网关层做更细的限制。
不要把所有接口都塞进一个固定超时模型里。超时策略是产品和工程共同决定的:用户愿意等多久,服务能承受多少并发,外部依赖的正常延迟是多少,都要考虑。
给超时留出可观察性
超时配置不是写完就结束。真正上线后,你还需要知道哪些请求经常超时、超时发生在读请求体、业务处理还是写响应阶段。最简单的做法是在中间件里记录耗时和状态码:
func logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("method=%s path=%s cost=%s", r.Method, r.URL.Path, time.Since(start))
})
}
如果只看到客户端报错,而服务端没有日志,就很难判断是客户端太早断开、网关超时,还是后端没有处理完。实践里建议把服务端超时、网关超时、客户端超时排成一个清楚的顺序。通常客户端允许等待的时间最长,网关略短,后端内部调用更短,这样错误更容易在靠近问题的位置暴露。
小结
Go HTTP 服务应该用 http.Server 显式配置超时,而不是长期停留在 http.ListenAndServe 的最短写法。ReadHeaderTimeout、ReadTimeout、WriteTimeout、IdleTimeout 分别保护不同阶段。
Handler 内部要传递 r.Context(),外部调用要设置更短超时,慢请求、客户端断开和服务关闭才能正确传播。超时不是随便写几个数字,而是服务可靠性的基本边界。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。