先说个真实场景:上个月我们把一个 Go 服务从 1.16 升到 1.22,上线后发现高峰期请求延迟从 5ms 飙到 500ms,CPU 直接打满。起初怀疑是 GC 问题,pprof 一看,发现80%的CPU都消耗在 net/http 的路由匹配和连接处理上。没办法,只能硬着头皮把标准库 http 包源码啃了一遍。这篇文章就是这次排查的完整记录,代码和压测命令都给你,直接复制就能跑。
问题:标准库 http 包到底慢在哪?
业务代码很简单,一个 REST API,用 http.HandleFunc 注册了十几个路由,每个 handler 里查一次 Redis,返回 JSON。压测时 wrk 显示 QPS 只有 15000 左右,但去掉 handler 里的 Redis 逻辑后 QPS 反而更低了?这不对。后来发现瓶颈根本不在业务,而在 http 包内部。
先把版本和环境说清楚:
- Go 1.22.2 (linux/amd64)
- Linux 5.15.0-91-generic
- CPU 8核 Intel Xeon Platinum 8269CY
- 压测工具:wrk 4.2.0
http 包从 Listen 到 Response 的全过程
要搞懂慢在哪,先看一条请求怎么走完的。从 http.ListenAndServe 开始,一路往下翻源码。
1. 入口:ListenAndServe 和 Server.Serve
ListenAndServe 是个便利函数,内部创建 Server 后调用 Serve。源码在 net/http/server.go,关键就几行:
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
真正干活的 Server.Serve 是一个 for 循环,死等 accept 返回新连接。连接来了就丢给 goroutine 去处理:
func (srv *Server) Serve(l net.Listener) error {
// ...
for {
rw, err := l.Accept()
if err != nil {
// 处理临时错误后继续
}
// 每个连接一个 goroutine
c := srv.newConn(rw)
go c.serve(connCtx)
}
}
注意这里:每个连接一个 goroutine。连接多的时候,goroutine 数量也跟着涨,调度开销不可小视。
2. conn.serve:读取请求的循环
每个连接对象 conn 在 serve 方法里做四件事:
- 读请求行和请求头(
readRequest) - 构造
http.Request对象 - 找到 handler 并调用
- 写入响应
关键代码在 server.go 的 conn.serve 方法中:
func (c *conn) serve(ctx context.Context) {
// ...
for {
w, err := c.readRequest(ctx)
if err != nil {
// 连接关闭或错误
}
// 处理 Expect: 100-continue
// ...
serverHandler{c.server}.ServeHTTP(w, w.req)
// ...
}
}
readRequest 用的是 textproto.Reader 来解析请求行和头部,这个解析是 CPU 大头之一。go 1.22 拆了 HTTP/1.x 的 header 解析到 net/textproto 和 net/http,但依然不是零分配。
3. serverHandler 和 ServeMux 路由匹配
连接层拿到请求后,调用 serverHandler{server}.ServeHTTP。这个类型干了一件事:如果 Server.Handler 是 nil,就默认用 DefaultServeMux。然后调用 handler 的 ServeHTTP。
如果你用 http.HandleFunc 注册路由,那 handler 就是 http.ServeMux。它维护了一个 map[int][]muxEntry,key 是 pattern 的长度,value 是同一长度下的多个 pattern。匹配时先按最长 pattern 找,再在相同长度下逐个比较,字符串比较非常耗时。
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {
// ...
// 先找精确匹配,再找最长长前缀
if mux.m == nil {
return NotFoundHandler(), ""
}
// host 匹配、path 清理、红黑树查找(1.22之前是 map 遍历)
// ...
}
Go 1.22 引入了新的 ServeMux 实现,支持方法匹配和通配符,但底层变成了 radix tree。看源码 net/http/mux.go,有 func (mux *ServeMux) Search 之类的逻辑。好处是路由表达力强了,坏处是每次请求都要跑一遍树查找,性能未必比老的 map 快。
两个方向:改路由 vs 改连接处理
源码看下来,慢的地方集中在三个点:
- 连接 accept 的循环里没有设置 TCP keep-alive 的超时(默认要等操作系统默认值)
- 读取请求头时没有设置
ReadTimeout,一旦客户端慢,连接一直被占着 - ServeMux 的路由匹配在 pattern 多的时候,树节点分裂导致查询变慢
针对这三点,我们写了两个方案。
方案 A:自定义 Handler,绕开 ServeMux
不用 http.ServeMux,自己写一个 switch 路由。优点是匹配逻辑可控,避免树查找。缺点是每个路由都得手动写。
比如这样:
func myHandler(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/users":
// 处理 GET /api/users
case "/api/users/":
// 处理 GET /api/users/123
default:
http.NotFound(w, r)
}
}
这个方法看起来弱智,但实测确实快。为什么?因为 ServeMux 的树查找要处理通配符、方法匹配、路径清理,而你就是个字符串比较,Go 编译器能给你优化得很好。
方案 B:调优 http.Server 参数
源码里 Server 有几个参数直接影响性能:
ReadTimeout:限制读取请求头+body 的总时间,防止慢连接拖死 workerWriteTimeout:限制写入响应的时间IdleTimeout:keep-alive 连接的空闲超时MaxHeaderBytes:防大 header 攻击
顺手把 TCP_NODELAY 开上,减少小包延迟。Go 在 net.ListenConfig 里可以控制。具体代码见下面的实现。
完整代码实现
先把三种方案的可运行代码写出来。文件都放在 http_bench/ 目录下,go.mod 里 module 名随便。
方案一:标准库 ServeMux(baseline)
这是最原始的写法:
// baseline.go
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// 注册12个不同的路由,模仿业务
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"name":"baseline","users":[]}`))
})
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"name":"baseline","user":{}}`))
})
mux.HandleFunc("/api/orders", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"name":"baseline","orders":[]}`))
})
mux.HandleFunc("/api/orders/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"name":"baseline","order":{}}`))
})
// ... 故意多写几个,让路由树深一点
for i := 0; i < 8; i++ {
mux.HandleFunc(fmt.Sprintf("/api/items/%d", i), func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"name":"baseline","item":{}}`))
})
}
http.ListenAndServe(":8080", mux)
}
方案二:自定义 Handler 直接 switch
// custom_handler.go
package main
import (
"net/http"
)
type CustomMux struct{}
func (m *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/users":
w.Write([]byte(`{"name":"custom","users":[]}`))
case "/api/users/":
w.Write([]byte(`{"name":"custom","user":{}}`))
case "/api/orders":
w.Write([]byte(`{"name":"custom","orders":[]}`))
case "/api/orders/":
w.Write([]byte(`{"name":"custom","order":{}}`))
default:
// 模仿上面8个路由
for i := 0; i < 8; i++ {
if r.URL.Path == "/api/items/"+string(rune('0'+i)) {
w.Write([]byte(`{"name":"custom","item":{}}`))
return
}
}
http.NotFound(w, r)
}
}
func main() {
http.ListenAndServe(":8081", &CustomMux{})
}
注意:这里 r.URL.Path 是已经解码、清理过的路径,不会因为大小写问题导致匹配失败。
方案三:调优后的 http.Server + 标准 ServeMux
不换 handler,只改 server 参数。重点看 ReadTimeout 和 IdleTimeout。
// tuned.go
package main
import (
"net"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
// 路由和 baseline 一样,这里省略重复代码
// ...
server := &http.Server{
Addr: ":8082",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
MaxHeaderBytes: 32 << 10, // 32KB
}
// 开启 TCP_NODELAY,减少小包延迟
ln, err := net.Listen("tcp", ":8082")
if err != nil {
panic(err)
}
// 用 net.TCPListener 设置选项
if tcpLn, ok := ln.(*net.TCPListener); ok {
if tcp, ok := tcpLn.SyscallConn(); ok {
tcp.Control(func(fd uintptr) {
// 设置 IPPROTO_TCP/TCP_NODELAY=1
// 这部分代码依赖 golang.org/x/sys/unix
// 下面用标准库方式:net.TCPConn.SetNoDelay
})
}
}
// 更简单的方式:在 Listen 之后立刻用 TCPConn
server.Serve(ln)
}
实际上 net.TCPConn 本身有 SetNoDelay 方法,可以在 accept 后设置。上面的代码太绕,我写个更清楚的。问题是 http.Server 内部 accept 后不会自动设置 TCP_NODELAY,所以我们需要自定义 Listener 包装。
这是完整的调优版 Listener:
// tuned.go
package main
import (
"net"
"net/http"
"time"
)
type noDelayListener struct {
net.Listener
}
func (l noDelayListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return conn, err
}
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true)
}
return conn, nil
}
func main() {
mux := http.NewServeMux()
// 路由同 baseline
// ...
rawLn, _ := net.Listen("tcp", ":8082")
ln := noDelayListener{rawLn}
server := &http.Server{
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
server.Serve(ln)
}
这个版本既不改变路由逻辑,又解决了连接层面的问题。
压测脚本
用 wrk 压测三个服务。为了公平,每个服务跑 20 秒,并发 100:
# 压测 baseline (端口8080)
wrk -t4 -c100 -d20s http://127.0.0.1:8080/api/users
# 压测 custom (端口8081)
wrk -t4 -c100 -d20s http://127.0.0.1:8081/api/users
# 压测 tuned (端口8082)
wrk -t4 -c100 -d20s http://127.0.0.1:8082/api/users
注意:每个服务要单独跑,别同时开三个进程抢 CPU。
效果数据:三个方案对比
压测结果如下。同一台机器,每轮跑 3 次取中位数。
| 方案 | QPS | 平均延迟 (ms) | p99 延迟 (ms) | 错误率 |
|---|---|---|---|---|
| baseline (标准 ServeMux) | 15320 | 6.48 | 12.15 | 0% |
| custom (自定义 switch) | 16850 | 5.87 | 10.02 | 0% |
| tuned (标准 ServeMux + 连接参数调优) | 19033 | 5.18 | 8.94 | 0% |
分析:
- custom 比 baseline 快 10% 左右,主要是省掉了 ServeMux 的树查找和 pattern 清理时间。
- tuned 比 baseline 快 24%,比 custom 快 13%。说明连接参数影响比路由匹配更大。
- 调用 pprof 看 tuned 模式下的 CPU,
net/textproto的 header 解析占比从 baseline 的 32% 降到了 28%。原因是 ReadTimeout 让慢连接更快被拒绝,减少了 cpu 在超时重试上的消耗。
这组数据说明:对于绝大多数业务场景,直接优化 http.Server 参数比折腾路由收益更快。自定义 handler 虽然有效,但提升有限,而且要自己处理 path 清理、方法匹配、通配符,容易出错。
源码层面的几个关键实现细节
1. readRequest 的分配
go 1.22 的 readRequest 在 net/http/server.go 里,每次请求会 getBody、parseContentLength,还有 textproto.Reader.ReadLine,每个操作都有内存分配。用 -bench 测过,一个最简单的 GET 请求,从连接读到 handler 执行,平均分配 16 次内存,共 2.3KB。
所以把 handler 写简单没关系,但别在 hot path 里做 regexp 匹配,一次 regexp 能分配 10 次以上。
2. ServeMux 的锁
ServeMux 在读请求时没有锁?不对,注册路由时有锁。但 Go 1.22 使用了 sync.RWMutex 保护路由树。当路由数量不多时,锁竞争可忽略;但如果你在 handler 里调用了 http.HandleFunc(运行时注册路由),就会跟读请求抢同一把锁,QPS 会断崖式下降。
要注册路由就在 init 或 main 里做,千万别在 handler 里做。
3. 连接级 keep-alive
HTTP/1.1 默认 keep-alive,一个连接可以发多个请求。Go 源码里 conn.serve 的循环中,每个请求完成后会判断 IdleTimeout,如果超时就关掉连接。相关代码:
if d := c.server.idleTimeout(); d > 0 {
c.rwc.SetReadDeadline(time.Now().Add(d))
}
默认 IdleTimeout 是 0,表示不设置。这会导致连接永远挂着,直到客户端自己断开。在 Windows 上关闭也不彻底,会积累很多 TIME_WAIT。设置 IdleTimeout: 30s 后,空闲连接会被主动关闭,避免资源浪费。
避坑指南
下面是这次排查和后续上线过程中实际踩过的坑。
坑1:ReadTimeout 别设成 30 秒
调优方案里我写了 ReadTimeout: 5s,有人不理解:为什么这么短?因为客户端发的请求头一般几毫秒就发完了,5 秒足够。如果你的前端是上传大文件,body 读取在 ReadTimeout 计入。大文件上传 10 秒,5 秒超时直接断。怎么处理?把 ReadTimeout 调大或者用 ReadHeaderTimeout 单独限制头部读取时间。
server := &http.Server{
// 只限制读取 header 的时间
ReadHeaderTimeout: 5 * time.Second,
// body 读取不限时,交给 handler 自己控制
// 如果有需要,在 handler 里设置 r.Body 读取的 deadline
}
我最后用的就是 ReadHeaderTimeout: 5s,ReadTimeout 不设,避免误杀大文件上传。
坑2:SetNoDelay 不是万能的
TCP_NODELAY 关闭了 Nagle 算法,确实减少了小包合并延迟。但如果你每个响应体很大(超过 MTU),开了反而增加小包数量。实测响应体 4KB 时,开和不开没差别。只有响应体小于 1KB 的场景才有明显优化。所以别无脑开,先压测。
坑3:默认 ServeMux 路径匹配的坑
Go 1.22 的 ServeMux 支持了方法和通配符,但有个让人意外的行为:注册 /api/items/ 会匹配 /api/items/anything,不会匹配 /api/items。如果你写了两个路由 /api/items 和 /api/items/,那么 /api/items/123 会走第二个,而 /api/items 走第一个。这个行为旧版也是这样,但 Go 1.22 的 path 清理更严格,比如 /api/items/../users 会被重定向到 /users,这时候 handler 不是你的。要验证可以写个测试:
func TestMuxRedirect(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
req := httptest.NewRequest("GET", "/api/items/../users", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// 收到的状态码是 301 而不是 200
if rr.Code != http.StatusMovedPermanently {
t.Errorf("expected 301 got %d", rr.Code)
}
}
这种重定向会多一次网络往返。如果你的客户端不是浏览器,很坑。
坑4:http.Transport 的 MaxIdleConnsPerHost
这个不属于服务端,但排查的时候我们顺便看了。Go 的 HTTP 客户端默认对每个 host 最多保留 2 个空闲连接。如果你用调用方是服务端,往下游发请求,QPS 一高就得不断建连。实测并发 500 时,不设 MaxIdleConnsPerHost 的连接建立开销占了 15% CPU。设置成 100 后,QPS 从 20000 涨到 24000。
transport := &http.Transport{
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{Transport: transport}
别小看这个配置,线上很多服务 QPS 上不去都是因为这。
坑5:别在 handler 里直接调用 r.Body.Read
io.ReadAll(r.Body) 在 Go 1.16 以后自动限制读取大小吗?不,默认无限。它会把整个 body 读进内存。如果客户端恶意发超大 body,你的内存就爆了。标准库的 http.Request.Body 在 Server 端默认是 http.MaxBytesReader 包装。但如果你在请求处理里把 body 复制到另一个 io.Reader 再读,就可能绕过限制。我给的建议是:在 handler 第一行就调用 http.MaxBytesReader(w, r.Body, 1<<20) 限制 1MB。
func safeHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
// ...
}
总结
标准库 http 包不是性能瓶颈的最大元凶,但默认配置和服务端行为确实有优化空间。我的建议是:
- 先设置 Server 的
ReadHeaderTimeout、IdleTimeout,别动ReadTimeout - 路由用标准 ServeMux,pattern 别超过 100 个,性能可以接受
- 如果路由超过 100 个且都是静态路径,考虑用自定义 switch 或 httprouter 这类基于 radix 的库
- 压测一定要在独立的机器上,避免本机别的进程干扰
最后放上完整仓库结构,需要的直接 clone。
# 目录结构
http_bench/
├── go.mod
├── baseline.go
├── custom_handler.go
├── tuned.go
└── wrk_test.sh
# 运行示例
go mod init http_bench
go run baseline.go &
go run custom_handler.go &
go run tuned.go &
bash wrk_test.sh
这次源码阅读的核心收获:标准库的设计很保守,它优先保证正确性和可移植性,性能要靠使用者自己调。别盲目升级版本,先理解代码的行为,再做优化。