应自定义 http.roundtripper 包装 defaulttransport,在 roundtrip 入口记录起始时间,全程打点 dns 解析、连接建立、ttfb、响应读取等阶段,并在 resp 或 err 返回时统一补全 end 时间;需 wrap body 测读取耗时,兼容 nil resp 场景,用 context 透传 traceid 而非全局 map。

用 http.RoundTripper 拦截请求并记录耗时
Go 的 HTTP 客户端默认不暴露单次请求的完整生命周期时间点,想精确到 DNS 解析、TLS 握手、连接建立、首字节(TTFB)、响应体读取等阶段,必须自定义 http.RoundTripper。标准库的 http.DefaultTransport 是一个可复用的结构体,直接修改其字段不安全,正确做法是包装它。
关键点在于:在 RoundTrip 方法开始前记录起始时间,在返回响应后(甚至发生错误时)补全各阶段时间戳。注意要确保 resp.Body 不被提前关闭,否则后续读取会失败。
- 必须用
time.Now()在RoundTrip入口处打点,不能依赖req.Context().Deadline()或其他异步时间源 - DNS 解析耗时需通过覆盖
Resolver实现,但更简单的方式是用net.Resolver配合ctx.WithTimeout单独测;实际追踪中通常把「解析 + 连接」合并为connect_start→connect_done - TLS 握手时间藏在
tls.Conn.Handshake()内部,无法直接观测,只能靠http.Transport.TLSHandshakeTimeout间接约束,或使用crypto/tls自建连接做深度埋点(非常规需求)
用 context.Context 透传追踪 ID 和计时器
如果请求链路涉及中间件、重试、子请求(如调用下游 API),单纯靠函数局部变量记录时间会丢失上下文。应该把耗时数据挂载进 context.Context,并在每个关键节点更新。
例如:在发起请求前,把初始化的 map[string]time.Time 放入 context;在 DNS 开始、连接建立、收到 header 后,分别写入对应 key;最终由 defer 或 recover 捕获完成时间。这样即使中间有 goroutine 切换,也能保证归属清晰。
- 不要用全局 map 存储 traceID → time 数据,高并发下易冲突且无法清理
- 避免在
context.WithValue中传指针或可变结构体,应传不可变副本或只存traceID string,再由中心化 tracker 查表获取详情 - 若用 OpenTelemetry,
otelhttp.Transport已封装好大部分阶段,但默认不输出原始毫秒级时间戳,需额外配置otelhttp.WithSpanOptions(trace.WithAttributes())注入自定义属性
捕获 io.ReadCloser 读取阶段的真实耗时
很多人忽略响应体读取本身也耗时,尤其当 API 返回大 JSON 或流式响应时。http.Response.Body 是个 io.ReadCloser,它的 Read() 调用可能阻塞数秒,而标准 RoundTrip 认为“拿到 resp 就算完成”。必须显式 wrap body 并重写 Read 方法。
Colly 是一个用于 Go 语言的快速开源爬取和爬虫框架。它适用于从简单的页面提取到异步爬虫处理大量页面集合,支持请求回调和结构化解析。
示例做法:创建一个 timingReader,在首次 Read 前记录 read_start,每次 Read 返回后累加耗时,Close 时写入 read_done。注意要同步更新原 body 的状态,不能影响 json.Decoder 等下游行为。
- 不要在
RoundTrip返回后才开始读 body —— 那样测的是“调度延迟”,不是真实网络+解析耗时 - 如果用了
resp.Body = ioutil.NopCloser(bytes.NewReader(buf))缓存响应,需在缓存写入完成后才记read_done,否则会漏掉内存拷贝时间 -
http.MaxBytesReader包装后的 body 会拦截Read,你的 timing wrapper 必须放在它外层,否则计量失效
处理重定向、超时和取消导致的非 2xx 响应
耗时追踪最易出错的地方,是假设 RoundTrip 一定返回非 nil *http.Response。实际上,超时、取消、TLS 错误、重定向循环都会让 err != nil 且 resp == nil,但此时仍需记录“从发起到失败”的总耗时,以及失败发生在哪个阶段。
建议统一用一个结构体承载所有时间点:type Timing struct { Start, Connect, Ttfb, ReadStart, ReadEnd, End time.Time },并在 RoundTrip 结束时不管 err 还是 resp,都调用 t.End = time.Now() 并填充已知字段。缺失字段留空或设为零值,便于后续聚合分析时过滤。
- 重定向由
http.Client.CheckRedirect控制,默认最多 10 次;每次重定向都会触发新RoundTrip,需为每次独立计时,再由上层聚合(如 sum / max) -
context.DeadlineExceeded错误发生时,End - Start就是真实超时耗时,但要注意 Go 1.20+ 中http.TimeoutHandler可能提前关闭连接,导致WriteHeader失败,此时 timing 应以 write 开始时间为准 - 不要在
defer中访问resp.Header或resp.StatusCode,因为 resp 可能为 nil;应先判空再取值
实际部署时,各阶段时间差值往往比绝对时间更有价值,比如 Ttfb.Sub(Connect) 反映服务端处理延迟,ReadEnd.Sub(Ttfb) 反映网络带宽与客户端解析效率。这些计算必须在所有时间点采集完毕后再进行,而不是边走边算——否则一旦某阶段未打点,整个链条就断了。
大量免费API接口:立即使用
涵盖生活服务API、金融科技API、企业工商API、等相关的API接口服务。免费API接口可安全、合规地连接上下游,为数据API应用能力赋能!










