Go ReadHeaderTimeout 挡不住慢正文:HTTP 超时的阶段边界
服务已经设置 ReadHeaderTimeout,慢客户端却仍能在请求头发送完成以后长时间占着连接,通常是配置只覆盖了头部读取。HTTP 请求头结束与正文结束是两个时点,头部预算不会自动变成正文预算。
Go 的几个超时字段不能靠名字里都带 Timeout 就互相代替。先问连接卡在哪一段,再决定要限制哪个动作。下面固定 Go 1.24.1,通过本地原始连接刻意拆开发送请求头和正文,观察服务端究竟执行到了哪一步。
三个受控请求,分别卡在不同位置
实验创建临时监听端口,每次只处理一个场景。处理器读完整个正文才返回成功:头部没有完成时,处理器不应开始;头部已经完成但正文延后时,只设置头部超时仍可以成功;再加整段读取超时,正文读取就会失败。
保存为 main.go,不需要第三方依赖:
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync/atomic"
"time"
)
func runCase(name string, readTimeout time.Duration, completeHeader bool) error {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
var entered atomic.Bool
server := &http.Server{
ReadHeaderTimeout: 100 * time.Millisecond,
ReadTimeout: readTimeout,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
entered.Store(true)
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body read failed", http.StatusRequestTimeout)
return
}
_, _ = fmt.Fprintf(w, "read %d bytes", len(data))
}),
}
serving := make(chan struct{})
go func() {
defer close(serving)
_ = server.Serve(listener)
}()
defer func() {
_ = server.Close()
<-serving
}()
connection, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
return err
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(3 * time.Second))
if !completeHeader {
_, err = io.WriteString(connection, "POST / HTTP/1.1\r\n")
if err != nil {
return err
}
_, _ = io.ReadAll(connection)
if entered.Load() {
return fmt.Errorf("handler ran before headers completed")
}
fmt.Println(name + ": handler not entered")
return nil
}
head := "POST / HTTP/1.1\r\nHost: local\r\n" +
"Content-Length: 1\r\nConnection: close\r\n\r\n"
if _, err = io.WriteString(connection, head); err != nil {
return err
}
time.Sleep(350 * time.Millisecond)
// 有读取超时的场景,服务端可能已经关闭;仍继续读取其响应。
_, _ = io.WriteString(connection, "x")
response, err := http.ReadResponse(bufio.NewReader(connection),
&http.Request{Method: http.MethodPost})
if err != nil {
return err
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return err
}
want := http.StatusOK
if readTimeout > 0 {
want = http.StatusRequestTimeout
}
if response.StatusCode != want || !entered.Load() {
return fmt.Errorf("status=%d want=%d entered=%v",
response.StatusCode, want, entered.Load())
}
fmt.Printf("%s: status=%d body=%q\n", name,
response.StatusCode, strings.TrimSpace(string(data)))
return nil
}
func main() {
cases := []struct {
name string
read time.Duration
complete bool
}{
{"unfinished header", 0, false},
{"header timeout only", 0, true},
{"whole read timeout", 150 * time.Millisecond, true},
}
for _, item := range cases {
if err := runCase(item.name, item.read, item.complete); err != nil {
panic(err)
}
}
}
运行 go run main.go。第一组不会进入处理器;第二组等待正文后返回 200;第三组处理器已经开始,但正文读取失败,返回例子自己选择的 408。这里的毫秒数只用于拉开实验阶段,不是生产环境建议,也不是性能测试结论。机器负载极高时,应先增大测试预算,再观察阶段关系。
ReadTimeout 也不是“头读完后重新给正文十秒”
ReadHeaderTimeout 限制读取请求头的时间。请求头成功完成以后,处理器可以按具体请求调整后续读写控制。
ReadTimeout 则限制读取整个请求,包括正文。它不是在头部完成后额外开启一份同样长度的全新预算。请求头已经花掉的时间,也会影响剩余读取空间。若上传协议允许正文持续较久,统一给所有路径设置很短的整段读取超时,可能把正常上传一起拒绝。
大文件接口通常需要另外规定总大小、总时长以及允许的读取进展。仅靠一个全局字段,无法同时表达“每个请求总共最多多久”和“每隔多久至少要收到多少数据”。需要更细控制时,可以评估处理器内的 ResponseController.SetReadDeadline,并验证底层协议和包装器是否支持。
更关键的是,网络读取超时不会中断已经在执行的任意 CPU 代码。如果处理器读完正文后进入死循环,这两项读取设置都不能替它完成业务退出。下游数据库和远程请求还要接收请求 context 或自身的超时参数,长循环也必须拥有协作停止点。
WriteTimeout 和 IdleTimeout 负责另外两段
WriteTimeout 限制响应写出相关时间,不能拿来保证上游请求体按时读完。IdleTimeout 关注保持连接时等待下一次请求的空闲阶段,不等于正在处理当前请求时允许空闲多久。
把 IdleTimeout 调短能减少闲置 keep-alive 连接,但处理器已经开始读取当前正文时,不会因为“看起来没有新字节”就按同一个规则立刻退出。排障需要把连接等待新请求、读取当前请求、执行业务和写响应分开记录。
反向代理也可能有独立超时。浏览器看到 504,可能是代理先结束等待;Go 服务内部的任务仍在运行。服务端先读取超时,也可能表现为代理读取上游响应失败。只把各层都配置成同一个整数,不会自动得到清晰的总预算,反而容易让错误归属难以判断。
这些设置还受到 TLS 建连和 HTTP 协议实现细节影响。生产服务若同时支持 HTTP/1 与 HTTP/2,应分别在真实部署入口验证,不要只凭一段本地 HTTP/1 原始连接实验就承诺所有路径完全一样。
体积限制同样独立。一个客户端可以在允许时间内发来非常大的正文,因此读取超时不能替代请求大小预算;反过来,把正文限制在几百字节,也不能阻止客户端一字节一字节拖很久才发完。公开接口应同时在读取路径设大小限制,并为适用的阶段安排时间预算。
写响应的处理器还要检查写出错误。业务函数返回以后,若网络已经不能继续发送数据,不能把“没有继续执行的代码”推断为客户端已经收到结果。写入业务记录和确认响应到达原本就是两个动作,重试政策需要基于业务幂等,而不是靠延长WriteTimeout来消灭所有未知结果。
修改超时配置后,最好保留类似本例的分阶段回归样本。只用一个一次性发完整请求的curl测试,可以证明正常路径仍然通,却覆盖不到头部半截、正文半截以及响应接收缓慢这些真正受配置影响的位置。
从哪条证据开始查
第一条证据是处理器有没有进入。请求头还没完成就失败,业务日志里没有对应记录并不奇怪;问题发生在框架还没把请求交给业务的位置。连接级别的日志和指标在这里更有价值。
第二条是具体哪次读写返回了错误。处理器已经开始,不代表正文已经完整;把所有网络错误都记录成“业务执行超时”,会掩盖输入读取阶段。反过来,正文已经读完,下游调用还没返回,就应继续沿业务链查,而不是继续调整 ReadHeaderTimeout。
最后再定每个阶段的合同:头部要多久到齐、正文允许多少字节和多长时间、业务能用多少预算、结果写出多久结束。超时字段只是合同的实现入口。只有阶段划分明确,慢连接和慢业务才不会一直在同一份配置上互相背锅。