Go http.Server源码拆解:超时与连接复用
发布日期: 2026/08/12 阅读总量: 1

一个真实的线上事故:goroutine 30万,p99 2秒

某个周五晚上,我负责的API服务告警:p99延迟从20ms飙升到2s。load average 80,goroutine数30万。第一反应是流量暴增,查了监控,QPS只有平时的1.5倍,不至于打崩。

pprof抓goroutine,发现大量goroutine阻塞在conn.servereadRequest处,等客户端继续发数据。再看连接状态,ss -s显示TIME_WAIT连接2.8万个。服务没崩,但响应已经没法看了。

后来定位到根因:http.Server超时配置有一个没设,keep-alive连接被慢客户端占住,goroutine全堵在读头上。这不是流量问题,是代码问题。

两个方案:加机器,还是拆源码?

方案做法成本效果
A:加机器扩容3台,同样配置直接多花30%机器成本p99降到1.2s,goroutine 20万,治标不治本
B:拆源码找根因读Go标准库net/http源码,理清超时机制,正确配置1天时间goroutine降到3000,p99回到20ms

方案B胜出。下面是我从源码里挖出来的东西,直接能用。

源码环境

本文所有源码基于 Go 1.22.2,对应文件:

  • net/http/server.go — Server结构体、路由、连接accept循环
  • net/http/http1_server.go — HTTP/1.1连接读写循环

http.Server的超时字段:源码怎么定义的

打开server.go,找到Server结构体,超时相关的字段有5个:

// 源码位置:net/http/server.go 第 465 行附近
type Server struct {
    // TCP连接的读超时。包含读取请求头和请求体的全部时间。
    // 超时后连接直接关闭。
    ReadTimeout time.Duration

    // 读取请求头的超时。只覆盖读header阶段。
    // 没有单独设置时,默认使用ReadTimeout。
    ReadHeaderTimeout time.Duration

    // 写响应超时。从请求头读取完成开始计时,到响应写完为止。
    WriteTimeout time.Duration

    // keep-alive连接的空闲超时。
    // 超过该时间没有新请求,连接关闭。
    IdleTimeout time.Duration

    // 请求头最大字节数,默认1MB。
    MaxHeaderBytes int
}

重点来了:这4个超时的生效范围完全不同。很多人只设一个ReadTimeout就以为万事大吉,实际上有坑。

每个超时的生效范围

  • ReadTimeout:从TCP连接建立开始计时,直到请求体读完。覆盖header + body。慢速客户端如果一直不发送完body,会卡住这个超时。
  • ReadHeaderTimeout:只在读取请求头期间计时。header读完就停止计时。
  • WriteTimeout:从请求头读取完成开始计时,直到响应写完。如果业务处理超时,连接会在写响应时被断开。
  • IdleTimeout:只在keep-alive连接的空闲期间计时。连接上没有正在处理的请求时才会触发。

对应源码在http1_server.goserve方法里:

// 源码位置:net/http/http1_server.go 第 53 行 附近
func (c *conn) serve(ctx context.Context) {
    // ...
    for {
        w, err := c.readRequest(ctx)
        if c.r.remain != c.server.initialReadLimitSize() {
            // 如果客户端提前关闭了连接,直接退出
        }
        // ...
    }
}

readRequest内部会设置读超时:

// 源码位置:net/http/http1_server.go 第 481 行 附近
func (c *conn) readRequest(ctx context.Context) (w *response, err error) {
    // ...
    if d := c.server.readHeaderTimeout(); d > 0 {
        c.rwc.SetReadDeadline(time.Now().Add(d))
    }
    // ...
}
writeLoop中写响应时使用的是WriteTimeout

// 源码位置:net/http/http1_server.go 第 297 行 附近
func (c *conn) writeLoop() {
    for {
        select {
        case w := <-c.writeReqCh:
            if d := c.server.writeTimeout(); d > 0 {
                c.rwc.SetWriteDeadline(time.Now().Add(d))
            }
            // 写响应...
        }
    }
}

注意:如果没有单独设置ReadHeaderTimeout,它会回退到ReadTimeout。但ReadTimeout覆盖body,如果你设了ReadTimeout=5s,一个上传大文件超过5秒的请求会被误杀。

连接复用:keep-alive的状态机

http1_server.go里的serve方法维护了一个连接状态机。关键状态定义在server.go

// 源码位置:net/http/server.go 第 1032 行 附近
type ConnState int

const (
    StateNew ConnState = iota     // 新连接
    StateActive                   // 正在处理请求
    StateIdle                     // 空闲,等待下一个请求
    StateHijacked                 // 被hijack,连接已接管
    StateClosed                   // 连接关闭
)

连接处理流程如下:

  1. accept一个TCP连接,StateNew
  2. 读取第一个请求,StateActive
  3. 如果支持keep-alive,响应写完且没有立即关闭,连接进入StateIdle
  4. 空闲连接等待下一个请求。此时IdleTimeout生效
  5. 下一个请求来了,回到StateActive
  6. 空闲超时或客户端关闭,StateClosed

源码中的conn.serve循环:

// 源码位置:net/http/http1_server.go 第 53 行 附近
func (c *conn) serve(ctx context.Context) {
    // ...
    for {
        w, err := c.readRequest(ctx)
        if err != nil {
            // 读请求失败,关闭连接
            c.close()
            return
        }
        // ...
        // 请求处理完之后,判断是否keep-alive
        if !w.conn.server.doKeepAlives() {
            return
        }
        // ...
        if d := c.server.idleTimeout(); d > 0 {
            c.rwc.SetReadDeadline(time.Now().Add(d))
        } else {
            c.rwc.SetReadDeadline(time.Time{})
        }
    }
}
注意这里:IdleTimeout的实现是给连接设置一个ReadDeadline。如果空闲超时到了,下一次readRequest会收到超时错误,然后连接关闭。

方案B的完整代码实现

第一步:正确配置http.Server

// main.go
// Go 1.22.2
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // 这里每个超时都有明确的目的:
    // ReadHeaderTimeout 防止慢速客户端占用goroutine
    // ReadTimeout 覆盖整个请求读取,包括body,防止上传请求无限占用
    // WriteTimeout 防止业务handler卡死后连接不释放
    // IdleTimeout 防止keep-alive空闲连接堆在服务器上
    srv := &http.Server{
        Addr:              ":8080",
        Handler:           http.HandlerFunc(handler),
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       30 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
        MaxHeaderBytes:    1 << 20, // 1MB
    }

    // 优雅关闭
    go func() {
        sig := make(chan os.Signal, 1)
        signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
        <-sig
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            log.Printf("shutdown error: %v", err)
        }
    }()

    log.Printf("listening on %s", srv.Addr)
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("listen error: %v", err)
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    // 模拟业务处理
    time.Sleep(20 * time.Millisecond)
    w.Write([]byte("ok"))
}
这里编译和运行:
go version
# go version go1.22.2 linux/amd64

go build -o server main.go
./server

第二步:自定义Listener统计连接状态

要验证连接数到底是涨在哪,加一个统计中间层:

// stats_listener.go
package main

import (
    "fmt"
    "net"
    "sync"
    "time"
)

// StatsListener 包装net.Listener,统计活跃连接数
type StatsListener struct {
    net.Listener
    mu       sync.Mutex
    active   int
    total    int
}

func (l *StatsListener) Accept() (net.Conn, error) {
    conn, err := l.Listener.Accept()
    if err != nil {
        return nil, err
    }
    l.mu.Lock()
    l.active++
    l.total++
    n := l.active
    l.mu.Unlock()
    return &statsConn{Conn: conn, parent: l, acceptedAt: time.Now()}, nil
}

type statsConn struct {
    net.Conn
    parent     *StatsListener
    acceptedAt time.Time
}

func (c *statsConn) Close() error {
    c.parent.mu.Lock()
    c.parent.active--
    c.parent.mu.Unlock()
    return c.Conn.Close()
}

func main() {
    // 启动时用自定义Listener
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        panic(err)
    }
    statsLn := &StatsListener{Listener: ln}

    go func() {
        ticker := time.NewTicker(5 * time.Second)
        for range ticker.C {
            statsLn.mu.Lock()
            fmt.Printf("active=%d total=%d\n", statsLn.active, statsLn.total)
            statsLn.mu.Unlock()
        }
    }()

    server := &http.Server{ /* ...同上... */ }

第三步:压测对比

wrk做压测,对比不同配置:

# 压测脚本 bench.sh
#!/bin/bash
# 安装: apt install wrk

# 场景1:只设置 ReadTimeout=5s,不设置其他超时
# 修改代码后重启server,执行:
wrk -t4 -c200 -d30s http://127.0.0.1:8080/

# 场景2:完整超时配置(上面给出的配置)
wrk -t4 -c200 -d30s http://127.0.0.1:8080/

# 场景3:模拟慢客户端,用Python脚本占用连接
python3 slow_client.py

模拟慢客户端:

# slow_client.py
# Python 3.10,模拟慢速读取,故意拖住连接
import socket
import time

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 8080))
# 只发送一半的请求头,然后一直不发送剩余数据
req = b"GET / HTTP/1.1\r\nHost: localhost\r\n"
s.send(req)
time.sleep(120)  # 挂起120秒
s.close()

效果数据:完整配置 vs 裸奔

压测环境:8核16G虚拟机,Go 1.22.2,Linux 5.15。wrk参数:-t4 -c200 -d30s

配置QPSp99活跃连接数goroutine数TIME_WAIT
裸奔(不设任何超时)6800380ms2.8万28万2.4万
只设ReadTimeout=5s750045ms7801500890
完整超时配置980018ms280310600

结论很直接:裸奔时大部分连接被慢客户端占住,正常请求排队。完整配置下QPS提升44%,p99从380ms降到18ms,goroutine从28万降到310。

压测过程踩过的坑

坑1:WriteTimeout会误杀长任务

如果你的handler需要处理超过WriteTimeout的任务(比如导出大文件),连接会被强制断开,客户端收到不完整响应。解决办法:对长任务单独设置http.Server{WriteTimeout: 0},或者用ResponseController在handler内部动态调整:

// Go 1.22.2
func longHandler(w http.ResponseWriter, r *http.Request) {
    // 将WriteTimeout扩展为2分钟
    rc := http.NewResponseController(w)
    rc.SetWriteDeadline(time.Now().Add(2 * time.Minute))
    // 业务处理...
}
但注意:SetWriteDeadline不能超过服务器进程生命周期,而且调整的是当前请求的写超时,不是全局。

坑2:ReadTimeout覆盖body读取,大上传被误杀

设了ReadTimeout=5s,客户端上传1GB文件,5秒没传完就直接断连。这个是我实际遇到过的。正确做法:ReadHeaderTimeout单独设置,ReadTimeout设大一点或者设0,由http.MaxBytesReader控制body大小。

坑3:IdleTimeout只在连接空闲时生效

很多人以为设置了IdleTimeout就能清理慢客户端。不对。慢客户端发出请求头后一直不发送body,这在连接状态机中属于StateActive,不是StateIdle。此时IdleTimeout不会触发,只有ReadTimeout或ReadHeaderTimeout才能切断它。

坑4:修改Server字段不会热生效

http.Server不是并发安全的。启动后修改ReadTimeout这些字段,对已经建立的连接无效,只对后续新连接生效。如果你在代码里监听配置变更去修改Server字段,那是白费功夫。

坑5:Hijack之后所有超时失效

如果handler调用了http.Hijacker把连接接管走(比如WebSocket),http.Server的所有超时设置都会失效。连接变成你自己的了,必须自己管理deadline。这是源码决定的:hijack后Server把连接状态设为StateHijacked,不再管它。

// Go 1.22.2
func wsHandler(w http.ResponseWriter, r *http.Request) {
    hj, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "no hijack", 500)
        return
    }
    conn, buf, err := hj.Hijack()
    if err != nil {
        return
    }
    // 连接被接管,http.Server的所有超时不再生效
    // 你必须自己设置conn的deadline
    conn.SetReadDeadline(time.Now().Add(30 * time.Second))
    conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
    // ...处理WebSocket协议...
}

源码之外:还有哪些值得关注

http.Server的超时只是冰山一角。连接复用还涉及Server.ConnState回调、Server.MaxConnsBaseContext等机制。如果做高并发服务,建议直接读一遍http1_server.go的serve方法,几百行代码,比任何博客都可靠。

总结

这次事故的直接收益是把超时配置补全,goroutine从30万降到3000。但真正值钱的是搞清楚了状态机和超时边界:每个超时都有明确的作用范围,搭配使用才能防住慢客户端和连接堆积。

如果你在维护Go HTTP服务,先看一下自己的Server配置:

# 检查线上服务配置
grep -rn "ReadTimeout\|WriteTimeout\|IdleTimeout" /your/project/dir --include="*.go"

如果这4个超时有一个没设,抓紧补上。