原创

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。

最后再定每个阶段的合同:头部要多久到齐、正文允许多少字节和多长时间、业务能用多少预算、结果写出多久结束。超时字段只是合同的实现入口。只有阶段划分明确,慢连接和慢业务才不会一直在同一份配置上互相背锅。

正文到此结束
评论插件初始化中...
Loading...