Go HTTP 请求慢在哪里?

一个 HTTP 请求花了 500ms,DNS?TCP?TLS?服务端?没有 httptrace,你只能猜。Go 标准库的 httptrace 能把请求拆成 5 个独立阶段精确测量耗时——不用第三方库,不用改代码架构。

封面

一个 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 请求从发出到收到响应,在底层要经历这些步骤:

  1. DNS 解析:把域名解析成 IP 地址
  2. TCP 连接:三次握手建立连接
  3. TLS 握手:协商加密参数(仅 HTTPS)
  4. 等待响应(TTFB):请求发出去后等服务端返回第一个字节
  5. 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 也跟着自动传播——零侵入。

context vs 接口设计对比

ClientTrace 本身是一个结构体,里面是一组可选的函数字段。你关心 DNS,就填 DNSStartDNSDone;关心首字节,就填 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 的连接复用信息在实际诊断中价值很高,后面会单独展开。

HTTP 请求 5 阶段时序图


二、写一个工具,看看时间花在哪

理论说完了,写一个真正的排查工具。

CLI 工具运行场景

定义一个结构体来记录每个阶段的时间点:

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 记录的不是完整的三次握手时间。后面 §四 会更详细讨论连接复用的影响。

还有一个细节:DNSStartDNSDone 只有在真正触发了 DNS 查询时才有值。如果你传的是 IP 地址,或者自定义了 DialContext 直接连接 IP,这两个 hook 就不会触发。但要注意,即使系统 DNS 缓存命中了,hook 仍然会触发,只是返回速度非常快。同样,TLSHandshakeStartTLSHandshakeDone 只在 HTTPS 请求中出现,HTTP 请求不会有 TLS 阶段。

实际使用时,需要对这些情况做判空处理。比如打印 DNS 耗时之前先检查 dnsBegindnsEnd 是否非零:

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 闭包捕获 reqpt。每次 RoundTrip 会创建新的 pt 实例,因此被多 goroutine 复用时不会产生竞争。代码实现见 evidence/code/httptrace-cli/main.go

TracingTransport 调用链架构图


四、连接复用:最容易被忽略的性能杀手

很多人遇到过这种情况:同一个 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 或新的 TransportTransport 负责连接池,频繁新建会让复用很难发生。正确的做法是复用同一个 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()。调整 MaxIdleConnsPerHostIdleConnTimeout 是最后的优化手段,先确认前两项没问题再动配置。

慢的阶段 常见原因 首选排查手段
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.gogo run httptrace.go https://example.com 就能看到每个阶段的耗时。对你日常访问的几个 URL 做一次分析,结果可能会让你意外——也许你一直以为慢在服务端的问题,其实是 TLS 握手拖了后腿。

本文所有实测代码和完整源码可在 github.com/wujiachen0727/zhiyulab-evidence 查看(近期将整理上传)。

诊断链总结概念图

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →