Go HTTP 服务超时入门:ReadTimeout、WriteTimeout 和请求上下文

本文讲解 Go HTTP 服务中 ReadTimeout、WriteTimeout、IdleTimeout 和请求 context 的基本用法,帮助初学者避免服务被慢请求拖住。

没有超时的服务很容易被慢请求拖住

很多 Go 入门 HTTP 服务都是这样启动的:

log.Fatal(http.ListenAndServe(":8080", mux))

这段代码能跑,但它没有显式配置超时。开发环境里看不出问题,到了真实网络环境就可能遇到慢客户端、半开连接、上传请求迟迟不结束、响应写不出去等情况。一个服务如果没有基本超时保护,就可能被少量异常连接占住资源。

Go 标准库的 http.Server 提供了几个常用超时字段:ReadTimeoutReadHeaderTimeoutWriteTimeoutIdleTimeout。它们不是越短越好,也不是每个服务都一样,但入门阶段至少应该知道这些字段控制什么,以及为什么直接用 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 的最短写法。ReadHeaderTimeoutReadTimeoutWriteTimeoutIdleTimeout 分别保护不同阶段。

Handler 内部要传递 r.Context(),外部调用要设置更短超时,慢请求、客户端断开和服务关闭才能正确传播。超时不是随便写几个数字,而是服务可靠性的基本边界。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页