
一个 HTTP 请求耗时 500ms。DNS?TCP?TLS?服务端?没有 httptrace,你猜不出来。
因为 http.Client.Do 只给你一个最终结果,不告诉你中间发生了什么。
Go 标准库里的 net/http/httptrace 就是来解决这个问题的。它能把一次 HTTP 请求拆成 5 个独立阶段,每个阶段都能精确测量耗时。更好的是,它不需要第三方库,不需要额外的代理或中间件,一行 import 就能用。
这不是一个新工具了——httptrace 从 Go 1.7 开始就有了,至今快 10 年。但在我的观察里,很多 Go 开发者听过但没用过。原因不是它不好用,而是很少有人告诉你"什么时候该用它"。
这篇文章会带你从头搭建一个 httptrace 诊断工具,用实测数据看看"慢"到底在哪,最后给你一个完整的定位框架——遇到慢请求时,你知道从哪入手。
本文基于 Go 1.26.4 实测,但
httptrace的核心行为从 Go 1.7 以来基本一致。不同版本间ClientTrace的字段数和 Transport 默认值可能有细微差异。
一、HTTP 请求的 5 个阶段
一次 HTTPS 请求从发出到收到响应,在底层要经历这些步骤:
- DNS 解析:把域名解析成 IP 地址
- TCP 连接:三次握手建立连接
- TLS 握手:协商加密参数(仅 HTTPS)
- 等待响应(TTFB):请求发出去后等服务端返回第一个字节
- Body 传输:读取响应体的内容
每个阶段都可能成为瓶颈,但常规手段看不到它们各自花了多少时间。httptrace 做的事情很简单:它在这 5 个阶段各插了一个钩子,你往钩子上挂一个记录时间戳的函数,就能拿到每个阶段的起止时间。
使用方式很特别。它没有在 http.Client 上加一个 Tracer 字段,而是把 trace 塞进了 context.Context:
trace := &httptrace.ClientTrace{
DNSStart: func(info httptrace.DNSStartInfo) {
fmt.Printf("DNS start: %s\n", info.Host)
},
DNSDone: func(info httptrace.DNSDoneInfo) {
fmt.Printf("DNS done: %v\n", info.Addrs)
},
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
http.DefaultClient.Do(req)
为什么用 context 而不是接口?这个设计选择绕开了两个常见问题。
假设在 http.Client 上加一个 Tracer 接口字段会怎样:
type Client struct {
Tracer Tracer // 所有请求共享同一个 Tracer
}
这样会有一个问题:你没法给单次请求单独配置 trace——要么所有请求都有,要么都没有。
httptrace 选择把 trace 绑在 context 上,trace 的作用域天然绑定在单次请求。同一个 http.Client 可以被多个 goroutine 复用,每个请求都可以带自己的 trace,不需要在 client 上改共享状态。如果你的中间件会转发 context,trace 也跟着自动传播——零侵入。

ClientTrace 本身是一个结构体,里面是一组可选的函数字段。你关心 DNS,就填 DNSStart 和 DNSDone;关心首字节,就填 GotFirstResponseByte。没有设置的字段是 nil,Transport 会跳过。
官方文档说 hook 可能从不同 goroutine 调用,有些 hook 甚至可能在请求完成或失败之后才触发。所以在 hook 里写共享变量时,要么只服务于单次请求的局部记录,要么自己处理并发访问。
httptrace 提供的 hooks 远不止上面提到的几个。完整的 ClientTrace 在 Go 1.26 中有 16 个可选字段——除了 DNS 和连接的 hooks,还有 GetConn(获取连接前)、WroteHeaders(请求头发送完)、WroteRequest(请求体写完)、PutIdleConn(连接放回空闲池)等。用得最多的是 DNS/TCP/TLS/首字节这 4 类,但 GotConn 的连接复用信息在实际诊断中价值很高,后面会单独展开。

二、写一个工具,看看时间花在哪
理论说完了,写一个真正的排查工具。

定义一个结构体来记录每个阶段的时间点:
type phaseTimes struct {
begin time.Time
dnsBegin time.Time
dnsEnd time.Time
dialBegin time.Time
dialEnd time.Time
tlsBegin time.Time
tlsEnd time.Time
connReady time.Time
wroteReq time.Time
firstByte time.Time
finished time.Time
}
然后在 ClientTrace 里填上 hook——每个 hook 做的事就是记录当前时间:
func buildClientTrace(pt *phaseTimes) *httptrace.ClientTrace {
return &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) {
pt.dnsBegin = time.Now()
},
DNSDone: func(_ httptrace.DNSDoneInfo) {
pt.dnsEnd = time.Now()
},
ConnectStart: func(_, _ string) {
pt.dialBegin = time.Now()
},
ConnectDone: func(_, _ string, _ error) {
pt.dialEnd = time.Now()
},
TLSHandshakeStart: func() {
pt.tlsBegin = time.Now()
},
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) {
pt.tlsEnd = time.Now()
},
GotConn: func(_ httptrace.GotConnInfo) {
pt.connReady = time.Now()
},
WroteRequest: func(_ httptrace.WroteRequestInfo) {
pt.wroteReq = time.Now()
},
GotFirstResponseByte: func() {
pt.firstByte = time.Now()
},
}
}
组合起来就是一个完整的命令行工具(完整代码见 evidence/code/httptrace-cli/main.go)。它对几个常用站点跑一组实测,就能告诉你"慢在哪"。我对 github.com、baidu.com 和 pkg.go.dev 各跑了一次,结果如下。
实测数据 [实测 Go 1.26.4 darwin/arm64,家庭宽带]:
| 站点 | DNS 解析 | TCP 连接 | TLS 握手 | TTFB | Body 传输 | 总耗时 |
|---|---|---|---|---|---|---|
| github.com | 7.26ms | 0.45ms | 399.47ms | 76.73ms | 98.81ms | 583.98ms |
| baidu.com | 53.23ms | 0.63ms | 102.58ms | 29.35ms | 2.00ms | 370.58ms |
| pkg.go.dev | 13.60ms | 0.28ms | 298.51ms | 254.62ms | 153.96ms | 721.32ms |
数据里有几个值得注意的发现:
- TLS 握手是本次测试中最耗时的阶段。github.com 的 TLS 花了将近 400ms,占整个请求的 68%。baidu.com 好一些,但也用了 102ms。但这跟证书链长度、OCSP 验证、物理距离有关,不代表所有 HTTPS 请求都如此。
- TCP 连接本身非常快。三次握手只花了不到 1ms——你平时觉得"连接慢",其实慢的是 TLS,不是 TCP。
- DNS 解析差异大。github.com 的 DNS 只要 7ms,baidu.com 却要 53ms。baidu.com 用了智能 DNS 调度,不同地域的解析结果可能指向不同的 CDN 节点——这会导致 DNS 耗时差异大。
- TTFB 差异反映服务端处理速度。baidu.com 只用了 29ms 就返回了第一个字节,pkg.go.dev 却用了 254ms——这反映的是服务端响应速度,不是网络问题。
大多数 HTTP 慢请求,不是慢在传输,而是慢在握手。这次测试里,三个站点的 TLS 分别占了总耗时的 28% 到 68%。如果你遇到一个 HTTPS 请求比预期慢很多,第一个要问的问题不是"网络是不是有问题",而是"TLS 握手花了多久"。
关于 TCP 耗时:< 1ms 的 TCP 连接在互联网环境下并不常见——三次握手通常需要 20-100ms(取决于 RTT)。这里的异常低值是因为系统可能有连接缓存或 Keep-Alive 介入,
ConnectStart/ConnectDone记录的不是完整的三次握手时间。后面 §四 会更详细讨论连接复用的影响。
还有一个细节:DNSStart 和 DNSDone 只有在真正触发了 DNS 查询时才有值。如果你传的是 IP 地址,或者自定义了 DialContext 直接连接 IP,这两个 hook 就不会触发。但要注意,即使系统 DNS 缓存命中了,hook 仍然会触发,只是返回速度非常快。同样,TLSHandshakeStart 和 TLSHandshakeDone 只在 HTTPS 请求中出现,HTTP 请求不会有 TLS 阶段。
实际使用时,需要对这些情况做判空处理。比如打印 DNS 耗时之前先检查 dnsBegin 和 dnsEnd 是否非零:
if !pt.dnsBegin.IsZero() && !pt.dnsEnd.IsZero() {
fmt.Printf("DNS 解析: %v\n", pt.dnsEnd.Sub(pt.dnsBegin))
}
否则在连接复用的场景下,你的日志会输出一堆零值,容易误导判断。

三、把 trace 嵌入项目——RoundTripper 封装
上面那个 CLI 工具对排查单个请求有用。但在实际项目中,你希望所有通过某个 http.Client 发出的请求都自动记录耗时,不需要手动为每个请求创建 trace。
不是所有项目都需要用 RoundTripper 包装。我建议的场景:
- 你在开发一个 API 客户端,需要了解每次请求的性能特征
- 线上出现间歇性慢请求,你想在不改业务代码的前提下增加可观测性
- 你想做长周期的性能基线采集,看看请求耗时是否在逐渐劣化
如果只是临时排查一个请求慢,直接用上面那个 CLI 工具就够了,不需要封装到项目里。
需要封装的话,Go 的 http.RoundTripper 接口就是做这个的。它是一个中间件模式——你可以包装默认的 Transport,在请求前后插入自己的逻辑:
type TracingTransport struct {
Base http.RoundTripper
Log func(req *http.Request, pt *phaseTimes)
}
func (tt *TracingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := tt.Base
if base == nil {
base = http.DefaultTransport
}
pt := &phaseTimes{begin: time.Now()}
trace := buildClientTrace(pt)
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
res, err := base.RoundTrip(req)
pt.finished = time.Now()
if tt.Log != nil {
tt.Log(req, pt)
}
return res, err
}
使用时:
client := &http.Client{
Transport: &TracingTransport{
Log: func(req *http.Request, pt *phaseTimes) {
log.Printf("%s %s dns=%v tls=%v ttfb=%v total=%v",
req.Method, req.URL,
pt.dnsEnd.Sub(pt.dnsBegin),
pt.tlsEnd.Sub(pt.tlsBegin),
pt.firstByte.Sub(pt.wroteReq),
pt.finished.Sub(pt.begin),
)
},
},
}
这样每次请求都会自动输出一条带各阶段耗时的日志,不需要为每个请求手动写 trace 代码。
这里有两个容易被忽略的细节。
先说一个实现细节,基于当前 Go 实现,httptrace.WithClientTrace 会把新 hook 和旧 hook 合并,新注册的先调用。这意味着调用方已有的 trace 不会被覆盖。比如你的框架已经加了 trace 记录请求耗时,你在业务代码里再加一个 trace 关注特定阶段——两个都会生效。不过这是内部实现细节,未来版本可能变化。
另一个容易被忽略的点:RoundTrip 返回时,响应头已经读到了,但响应 body 通常还没读完。上面代码里的 pt.finished 更接近"拿到响应头的时间",不是包括 body 下载在内的完整耗时。如果需要更精确的完整耗时,可以包装 res.Body,在 Close 时记录最终时间。
进阶:包装
res.Body的思路是通过onClose闭包捕获req和pt。每次RoundTrip会创建新的pt实例,因此被多 goroutine 复用时不会产生竞争。代码实现见evidence/code/httptrace-cli/main.go。

四、连接复用:最容易被忽略的性能杀手
很多人遇到过这种情况:同一个 http.Client 反复请求同一 URL,每次耗时都差不多,没觉得有缓存效果。很可能连接根本没有被复用。
Go 的 http.Transport 默认维护一个连接池。复用连接的好处很明显——省去了 DNS 解析、TCP 连接和 TLS 握手。TLS 握手在 HTTPS 请求中通常是最贵的阶段,一次握手 100ms-1s 不等。如果每次请求都要重新握手,性能损失是巨大的。
连接池默认是这样的:每个 host 最多保持 2 个空闲连接(MaxIdleConnsPerHost 默认值 2),空闲连接默认超时 90 秒(IdleConnTimeout 默认值 90s)。MaxIdleConns(总空闲连接数)默认是 0(无限制),所以主要瓶颈在 per-host 的限制。这个配置对大多数场景够用,不过如果你的服务需要跟同一后端建立大量连接,可能需要调整这些参数。
httptrace.GotConnInfo 能直接告诉你连接是否被复用了。它的 Reused 字段表示这条连接之前是否服务过其他请求,WasIdle 表示它是否从空闲池取出来的,IdleTime 表示空闲了多久。
GotConn: func(info httptrace.GotConnInfo) {
pt.connReady = time.Now()
if info.Reused {
log.Printf("连接复用,空闲了 %v", info.IdleTime)
return
}
log.Printf("新建连接到 %s", info.Conn.RemoteAddr())
}
我做了个简单的测试验证连接复用的效果。连续对同一 URL 发 3 次请求,看看耗时变化:
实测数据 [实测 Go 1.26.4 darwin/arm64,2026-06-14]:
| 请求序号 | 连接复用 | 总耗时 |
|---|---|---|
| 第1次(新建连接) | false | 1598ms |
| 第2次(复用连接) | true | 253ms |
| 第3次(复用连接) | true | 363ms |
第一次请求花了 1.6 秒,其中 TLS 握手占了 1.25 秒。第二次请求只用了 253 毫秒——快了 6 倍。这就是连接复用的威力。
连接复用的第一课:让连接活到被复用。
第2、3次请求中,TLSHandshakeStart/Done 均未触发,确认 TLS 握手被完全跳过。第3次比第2次慢了 110ms——这个波动可能是瞬时网络拥塞或服务端负载变化导致的。单次测试的读数仅供参考,建议连续跑 10 次取 P50/P95 来评估真实性能。
但注意,连接复用不是万能的。请求频率太低时,连接池可能因为超时回收连接(默认 IdleConnTimeout 为 90s,但操作系统或中间网络设备可能主动关闭空闲连接),下次请求还是得重新建。另外需要注意协议差异——HTTP/2 的多路复用跟 HTTP/1.1 的连接池机制不一样:HTTP/2 的一条连接可以同时处理多个请求,不需要排队。测试中使用的就是 HTTP/2(ALPN 协商),HTTP/2 的 SETTINGS 帧交换可能在首次连接时额外增加开销。
如果你连续访问同一个 host,却发现每次 Reused 都是 false,优先检查两件事。
一是代码是否每次请求都创建新的 http.Client 或新的 Transport。Transport 负责连接池,频繁新建会让复用很难发生。正确的做法是复用同一个 http.Client 实例。
二是响应体是否被读完并关闭。net/http 的连接复用依赖调用方正确处理 res.Body。只拿到 response 就返回,或者错误路径里漏掉 Close,后续请求就只能重新建连接。
// 错误写法——不关闭 Body
res, _ := client.Get(url)
// 连接不会被放回池子
// 正确写法
res, _ := client.Get(url)
_, _ = io.Copy(io.Discard, res.Body)
res.Body.Close()
很多人在业务代码里写 defer res.Body.Close(),看起来没问题。但如果请求返回的不是 200,有人可能直接 return 了,defer 不会执行。或者有人只调了 res.Body.Close() 但没有先读取完 Body——这样连接虽然被放回池子,但池子可能会认为这条连接还有未读数据,下次复用时会先读残留数据,导致奇怪的延迟。

五、排查框架:看到结果以后怎么办
httptrace 帮你定位到"慢在哪一步",但定位之后呢?
每个阶段慢的原因不同,排查手段也不同。当你用 httptrace 发现某个阶段异常时,按对应方案排查:
DNS 慢(> 100ms)
httptrace 能告诉你精确到毫秒的 DNS 耗时——这比 dig 更真实,因为 dig 测的是你机器到 DNS 服务器的延迟,httptrace 测的是 Go 实际走的解析路径。用 dig @8.8.8.8 <域名> 或 nslookup 对比。如果差异大,换 114DNS(国内公共 DNS)试试。频繁切换域名会导致 DNS 缓存无法生效——这是 httptrace 的 DNS hook 能直接证实的。
TCP 连接慢
TCP 连接本身很快(< 1ms)。如果 httptrace 显示 TCP 阶段耗时异常,通常不是三次握手本身的问题,而是网络延迟大。用 ping 看 RTT——如果 ping 也慢,是网络问题;如果 ping 快但连接慢,可能是中间有防火墙或代理。阈值参考:同区域访问 < 50ms,跨洲访问 150-300ms。
TLS 握手慢(> 500ms)
这是 HTTPS 请求中最常见的瓶颈。httptrace 的 TLSHandshakeStart/Done 能精确到毫秒——结合连接复用状态判断。如果第一次请求 TLS 花了 500ms,是正常的;如果非首次请求 TLS 仍然很慢,说明连接没有被复用。TLS 1.2 需要 2 次往返(2-RTT),TLS 1.3 只需要 1 次(1-RTT)。Go 从 1.13 开始默认支持 TLS 1.3,但如果你的代理或反向代理强制了 TLS 1.2,握手时间会显著增加。用以下命令查看握手细节:
curl -w "TCP: %{time_connect}s, TLS: %{time_appconnect}s, TTFB: %{time_starttransfer}s\n" -o /dev/null -s https://example.com
TTFB 慢(> 200ms)
TTFB 反映的是服务端处理速度,不是网络问题。httptrace 帮不了你进一步定位了——得用 pprof 或慢查询日志去查服务端慢在哪。如果你发现 TTFB 很慢但其他阶段都正常,问题大概率在服务端。
连接复用异常(每次都是新连接)
在开发环境给你的 TracingTransport 加一行 GotConn 日志,打出来每次请求的连接复用状态。如果发现频繁建新连接,优先检查两件事:一是代码是否每次请求都 new http.Client()——复用同一个实例;二是错误路径是否漏掉了 res.Body.Close()。调整 MaxIdleConnsPerHost 和 IdleConnTimeout 是最后的优化手段,先确认前两项没问题再动配置。
| 慢的阶段 | 常见原因 | 首选排查手段 |
|---|---|---|
| DNS | DNS 服务器慢、缓存未命中 | dig + 换 DNS |
| TCP | 网络延迟大、物理距离远 | ping RTT |
| TLS | 证书链长、OCSP 验证 | openssl s_client |
| TTFB | 服务端处理慢 | pprof + 慢查询 |
| 连接复用 | 未关闭 Body、新建 Client | GotConnInfo.Reused 日志 |

六、总结
回到开头的问题:当 HTTP 请求变慢时,你至少要知道慢在哪一步。
这个问题看起来简单,但没有 httptrace 之前,答案是靠猜的。你只能看到总耗时,然后凭经验判断"可能是网络问题"或"可能是服务端问题"。有了 httptrace,猜测变成了测量。
net/http/httptrace 帮你看清了这个"黑箱"——DNS 解析、TCP 连接、TLS 握手、首字节时间、Body 传输,每个阶段都可以独立测量。它不需要第三方库、不需要改代码架构,只要在请求的 context 里塞一个 ClientTrace。
这就是一个完整的诊断链——httptrace 告诉你哪一环慢,系统知识告诉你为什么慢,排查手段告诉你怎么修。三环缺一不可。
看到数据只是第一步,真正有价值的是怎么判断它:
- TLS 握手花了 400ms?如果是第一次请求,这是正常的。但如果非首次请求 TLS 仍然很慢——说明连接没有被复用。
- TTFB 用了 200ms+?服务端需要优化。
- 每次都是新建连接?看看 Body 有没有关闭。
把 httptrace 作为你排查链的第一环。它不解决所有问题,但帮你精确定位问题在哪一环——然后你才知道该往哪个方向使劲。
如果你之前没用过 httptrace,现在就可以试一下:把上面的 CLI 工具保存为 httptrace.go,go run httptrace.go https://example.com 就能看到每个阶段的耗时。对你日常访问的几个 URL 做一次分析,结果可能会让你意外——也许你一直以为慢在服务端的问题,其实是 TLS 握手拖了后腿。
本文所有实测代码和完整源码可在 github.com/wujiachen0727/zhiyulab-evidence 查看(近期将整理上传)。

原文发布于 止语Lab