唯一能获取全链路耗时的入口是自定义http.roundtripper包装defaulttransport,在roundtrip入口用time.now()打点,通过httptrace捕获dns、连接、tls、ttfb等阶段,并包装resp.body在close()中记录读取耗时,同时用context透传traceid确保归属清晰。

用 http.RoundTripper 包装 DefaultTransport 是唯一能拿到全链路耗时的入口
标准 http.Client.Do 或业务层打点,只能覆盖「发起请求」到「收到 *http.Response」之间,漏掉 DNS 解析、连接池等待、TLS 握手、响应体流式读取等关键阶段。真正可控、可埋点、可透传 context 的位置,只有 http.RoundTripper.RoundTrip 方法。
别改 http.DefaultTransport 字段——它是全局可变结构体,高并发下不安全;正确做法是嵌套它:
type TimingRoundTripper struct {
base http.RoundTripper
}
func (t *TimingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := t.base.RoundTrip(req)
if err != nil {
recordError(req.URL.Host, time.Since(start).Milliseconds())
return resp, err
}
resp.Body = &timingReadCloser{
ReadCloser: resp.Body,
req: req,
start: start,
}
return resp, nil
}
-
base必须是线程安全的 transport(如http.DefaultTransport),不能是临时 new 出来的 - 必须在
RoundTrip入口调用time.Now(),不能依赖req.Context().Deadline()等异步时间源 - 返回
resp前必须包装resp.Body,否则无法捕获Read()和Close()阶段耗时
包装 resp.Body 才能测准响应体读取耗时
很多人以为拿到 resp 就算请求完成,其实不然。大 JSON、流式接口(如 SSE)、慢网络下,resp.Body.Read() 可能阻塞数秒——这部分耗时必须计入总链路。
直接实现 io.ReadCloser,在 Close() 里补全终点时间:
type timingReadCloser struct {
io.ReadCloser
req *http.Request
start time.Time
}
func (r *timingReadCloser) Close() error {
defer r.ReadCloser.Close()
duration := time.Since(r.start).Milliseconds()
recordFullDuration(r.req.URL.Host, r.req.Method, duration)
return nil
}
- 不要只包装
Read():流式响应可能多次调用Read(),总耗时不等于各次之和 - 必须在
Close()里打终点——这是 Go HTTP 客户端约定的“响应处理真正结束”信号 - 如果业务提前
io.Copy(ioutil.Discard, resp.Body)就丢弃 body,Close()仍会被调用,逻辑依然成立
用 httptrace 获取 DNS、连接、TLS 等细分阶段耗时
httptrace 是 Go 标准库(1.8+)提供的轻量级追踪工具,比自己覆写 net.Resolver 或 crypto/tls.Conn 更安全、更便携。
在 RoundTrip 中注入 httptrace.ClientTrace:
trace := &httptrace.ClientTrace{
DNSStart: func(info httptrace.DNSStartInfo) {
recordStage(r.URL.Host, "dns_start", time.Now())
},
DNSDone: func(info httptrace.DNSDoneInfo) {
recordStage(r.URL.Host, "dns_done", time.Now())
},
ConnectStart: func(network, addr string) {
recordStage(r.URL.Host, "connect_start", time.Now())
},
GotFirstResponseByte: func() {
recordStage(r.URL.Host, "ttfb", time.Now())
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
-
DNSStart/DNSDone能真实反映解析耗时,但注意:若启用了连接复用,后续请求不会触发 DNS 回调 -
GotFirstResponseByte对应 TTFB(Time to First Byte),是服务端处理能力的关键指标 -
TLSHandshakeStart/TLSHandshakeDone只在启用 TLS 时触发;未加密 HTTP 下这两个回调不会被调用 - 所有回调函数运行在 client goroutine 内,无需额外同步
context 透传 traceID,别用全局 map 存耗时数据
如果请求链路含重试、子请求或中间件跳转,仅靠局部变量记录时间会丢失归属。必须把 traceID 挂进 context.Context,再由中心化 tracker 查表补全详情。
客户端示例(发起请求前注入):
ctx := context.WithValue(req.Context(), "trace_id", "abc123") req = req.WithContext(ctx)
服务端中间件中提取并写入日志或 metrics:
if tid, ok := r.Context().Value("trace_id").(string); ok {
logFields = append(logFields, "trace_id", tid)
}
- 绝对不要用
map[string]time.Time全局缓存 traceID → 时间戳映射——高并发下易冲突、无法清理、GC 压力大 - 避免往 context 里传指针或可变结构体;只传
string类型 traceID,再由 tracker 通过 ID 查结构化耗时数据 - 若用 OpenTelemetry,优先走
otelhttp.Transport,但它默认不暴露原始毫秒值,需加otelhttp.WithSpanOptions(trace.WithAttributes())注入
真正难的不是打点,而是确保每个阶段时间戳归属清晰、不被协程调度干扰、且能在 panic 或超时后仍准确归因——这要求从 RoundTrip 入口开始就绑定 context,并把所有耗时采集收敛到一次 Close() 调用里。
大量免费API接口:立即使用
涵盖生活服务API、金融科技API、企业工商API、等相关的API接口服务。免费API接口可安全、合规地连接上下游,为数据API应用能力赋能!











