一个真实的线上事故:goroutine 30万,p99 2秒
某个周五晚上,我负责的API服务告警:p99延迟从20ms飙升到2s。load average 80,goroutine数30万。第一反应是流量暴增,查了监控,QPS只有平时的1.5倍,不至于打崩。
pprof抓goroutine,发现大量goroutine阻塞在conn.serve的readRequest处,等客户端继续发数据。再看连接状态,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.go的serve方法里:
// 源码位置: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 // 连接关闭
)
连接处理流程如下:
- accept一个TCP连接,
StateNew - 读取第一个请求,
StateActive - 如果支持keep-alive,响应写完且没有立即关闭,连接进入
StateIdle - 空闲连接等待下一个请求。此时
IdleTimeout生效 - 下一个请求来了,回到
StateActive - 空闲超时或客户端关闭,
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。
| 配置 | QPS | p99 | 活跃连接数 | goroutine数 | TIME_WAIT |
|---|---|---|---|---|---|
| 裸奔(不设任何超时) | 6800 | 380ms | 2.8万 | 28万 | 2.4万 |
| 只设ReadTimeout=5s | 7500 | 45ms | 780 | 1500 | 890 |
| 完整超时配置 | 9800 | 18ms | 280 | 310 | 600 |
结论很直接:裸奔时大部分连接被慢客户端占住,正常请求排队。完整配置下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.MaxConns、BaseContext等机制。如果做高并发服务,建议直接读一遍http1_server.go的serve方法,几百行代码,比任何博客都可靠。
总结
这次事故的直接收益是把超时配置补全,goroutine从30万降到3000。但真正值钱的是搞清楚了状态机和超时边界:每个超时都有明确的作用范围,搭配使用才能防住慢客户端和连接堆积。
如果你在维护Go HTTP服务,先看一下自己的Server配置:
# 检查线上服务配置
grep -rn "ReadTimeout\|WriteTimeout\|IdleTimeout" /your/project/dir --include="*.go"
如果这4个超时有一个没设,抓紧补上。