HTTP 服务被拖垮,未必都是高并发导致的。更常见也更隐蔽的问题,是客户端把一个连接拖得很慢:请求头迟迟发不完、上传 body 长时间读不完、下游卡住后响应也写不出去。单个连接流量不大,却会持续占住文件描述符、goroutine 和缓冲区。
Go 的 net/http 很适合快速起服务,但默认快捷写法并没有把这些边界替你补齐。下面按连接生命周期拆开来看:哪些超时该放在服务端,哪些要交给业务 context,以及不同接口类型为什么不能套用同一组参数。
慢连接为什么会拖垮服务
HTTP 连接从建立到关闭,通常会经历几个阶段:读取请求头、读取请求体、执行业务逻辑、写出响应,以及 keep-alive 连接的空闲等待。只要其中某个阶段没有边界,异常客户端就能把连接长时间挂住。

这类问题并不需要大流量。比如客户端每隔几秒只发一个字节,让服务端一直等完整请求头;或者用极低速率上传 body,让连接长期停在读取阶段。当慢连接数量上来后,正常请求也会因为文件描述符、goroutine 或内存资源被占住而失败。
所以超时配置的重点,不是简单把数字调小,而是给连接生命周期的每一段都设清楚边界:请求头要严,普通 body 读取要有限制,响应写入不能无限等待,空闲 keep-alive 连接也要按时回收。
先别直接用 http.ListenAndServe
如果是生产环境服务,应该显式创建 http.Server,而不是只用 http.ListenAndServe 的快捷写法。原因很直接:快捷方式没法把关键超时参数和头部限制补齐。
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 3 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
这一组字段各管一段生命周期:
ReadHeaderTimeout:先把慢请求头挡住
ReadHeaderTimeout 只限制请求头的读取时间,适合优先防御慢速 header。公网服务通常都应该显式设置这个值,因为它能直接限制客户端在最前面的拖延空间。
WriteTimeout:限制响应写出阶段
WriteTimeout 约束的是响应写入时间,避免客户端接收过慢时,服务端长期卡在 socket 写入上。它解决的是“写不出去”的问题,不负责约束业务内部处理时长。
IdleTimeout:及时回收空闲 keep-alive
IdleTimeout 控制 keep-alive 连接在等待下一次请求时能空闲多久。没有这个边界,连接复用阶段也可能长期占住资源。
MaxHeaderBytes:限制头部体积
MaxHeaderBytes 用来限制请求行和 header 总大小,避免异常请求头消耗过多内存。示例里的 1 << 20,也就是大约 1 MiB,是比较常见的起步值。

ReadHeaderTimeout 和 ReadTimeout 该怎么分工
ReadHeaderTimeout 和 ReadTimeout 很容易混用,但两者覆盖范围不同。
ReadHeaderTimeout 只管请求头。头部读完之后,handler 仍然可以自行决定怎么处理 body。
ReadTimeout 覆盖整个请求读取过程,包括 header 和 body。它对普通 JSON API 很有效,但对大文件上传并不总是合适。
普通 JSON API:可以用较短的 ReadTimeout
如果请求体很小,比如常见 JSON API,设置较短的 ReadTimeout 通常是合理的。原文给出的起步范围是 5 到 10 秒。这样一来,客户端如果长时间发不完 body,服务端就能更快释放连接。
上传接口:更适合头部限时 + 大小限制
上传场景通常不适合简单套一个偏短的全局 ReadTimeout。更稳妥的做法,是保留较短的 ReadHeaderTimeout,然后在 handler 里单独限制请求体大小。
func upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
defer r.Body.Close()
n, err := io.Copy(io.Discard, r.Body)
fmt.Fprintf(w, "received=%d err=%v", n, err)
}
http.MaxBytesReader 解决的是“最多能读多少”,并不是“必须在多久内读完”。它能防止超大 body 撑爆资源,但单靠它,仍然挡不住低速、长时间的传输。
因此上传接口往往还需要网关层一起配合,比如限制请求体大小、请求总时长,或者最小传输速率。对更重的上传业务,拆到对象存储直传,通常也比让应用服务长期承接上传连接更稳妥。
WriteTimeout 解决不了业务超时
WriteTimeout 的作用范围只在响应写出阶段。它能避免客户端读得太慢,把服务端阻塞在写 socket 上;但如果真正慢的是数据库、缓存或第三方接口调用,靠它并不能及时中断业务链路。
这种时候,应该把超时放进业务 context 里。
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
data, err := query(ctx)
if err != nil {
http.Error(w, "timeout", http.StatusGatewayTimeout)
} else {
json.NewEncoder(w).Encode(data)
}
}
这里的关键点有两个。第一,context.WithTimeout 能把超时边界传给下游调用;第二,r.Context() 本身会在客户端断开时取消,这样后台任务不会在请求已经失效后继续占资源。
换句话说,WriteTimeout 管的是网络写出,context 管的是业务链路,它们不是同一个层面的替代关系。
别只盯着服务端,客户端也要设超时
慢服务不只会拖住调用方,也会把上游应用的 goroutine 和连接池耗住。调用外部 HTTP 接口时,不建议长期使用没有超时的默认客户端。
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("https://api.example.com")
if err == nil {
defer resp.Body.Close()
}
Client.Timeout 覆盖从发起请求到读取响应 body 的整体过程,适合大多数短请求场景。它是一个整体超时,配置简单,优先级也很高。
如果需要更细的拆分控制,还可以继续配置 Transport。例如,ResponseHeaderTimeout 只限制等待响应头的时间,不包含后续读取响应 body 的过程。对依赖外部接口较多的服务来说,这类细粒度控制可以帮助你区分到底是连不上、握手慢,还是服务端迟迟不回 header。
不同接口的起步配置思路
如果是普通 JSON API,可以从下面这组参数开始评估:请求头 2 到 5 秒,整个请求读取 5 到 10 秒,响应写入 5 到 15 秒,空闲连接 30 到 120 秒,header 大小限制在 1 MiB 左右。
如果是上传接口,不要只依赖全局 ReadTimeout。更常见的组合是:较短的 ReadHeaderTimeout、明确的 MaxBytesReader、网关层限制、对象存储直传,以及业务层 context 超时。
如果是流式响应、SSE 或长轮询接口,则要谨慎使用固定且偏短的 WriteTimeout。这类接口本身就依赖较长的连接生命周期,写超时设置过短,很容易和长连接语义冲突。必要时,可以把这类接口拆成独立的 server,或者独立端口处理。
归纳起来,net/http 这些字段分别对应不同阶段:ReadHeaderTimeout 防慢请求头,ReadTimeout 约束请求读取,WriteTimeout 控制响应写入,IdleTimeout 回收空闲 keep-alive,业务超时则交给 context。
超时配置的目标,不是单纯让请求“更快”,而是让失败方式可预期、可回收。只要连接生命周期的边界清楚,慢连接就很难在不显眼的情况下把整个服务拖垮。









