span.end()不能反映真实网络延迟,因其在服务端writeheader或sendmsg返回时触发,此时数据可能滞留tcp缓冲区,syn-ack、tls握手、nat重传等网络层耗时未被观测。

Span.End() 为什么不能反映真实网络延迟
OpenTelemetry 默认在服务端 WriteHeader 或 gRPC 的 SendMsg 返回时调用 span.End(),但此时数据很可能还卡在 TCP 发送缓冲区,甚至没发出去。SYN-ACK、TLS 握手、NAT 重传这些真实耗时全被吞掉了。
典型表现:Span 耗时在 80–350ms 波动,而 tcpdump 显示仅 SYN-ACK 就占 40ms,TLS 再加 60ms——纯网络开销已超 100ms。
- 根本原因不是 tracer 不准,而是观测点太靠上,漏掉了网络层关键阶段
-
http.Client.Transport不暴露底层连接建立细节,net.Conn的DialContext耗时默认不可见 - 必须补两层观测:①
net.Conn级建连耗时(用Transport.DialContexthook);② 每个请求级write+read往返时间(用自定义RoundTripper)
traceparent 头在跨机房 LB 后丢失的排查要点
机房间统一 LB(如 F5、Nginx Ingress Controller)默认清洗非标准 header,trace_id 这种带下划线的字段极易被静默丢弃。
- 确认 LB 是否开启
underscores_in_headers on,否则propagators.TraceContext{}.Inject无效 - 强制使用 W3C 标准头:
traceparent和tracestate是 LB 的“白名单字段”,几乎不会被删 - Go 初始化时显式指定传播器:
otel.SetTextMapPropagator(propagation.TraceContext{}),别依赖默认行为 - 若 LB 强制小写 header 名(如 AWS ALB),需在服务入口加 middleware 将
traceparent→Traceparent重写,否则 OTel SDK 解析失败
HTTP 客户端连接池必须按机房维度隔离
复用同一个 http.Client 会导致北京和新加坡 endpoint 的空闲连接混管,连接复用率暴跌,频繁重建连接。
-
MaxIdleConnsPerHost是按 Host 隔离的,但跨机房服务往往共用同一域名(如user-service.prod.internal),DNS 解析后 IP 才不同——此时连接池无法区分物理位置 - 正确做法:为每个机房 endpoint 单独初始化
http.Client,并配置独立Transport,设置MaxIdleConns、IdleConnTimeout(建议 30s) - 避免复用
http.DefaultClient,它共享全局连接池,易被其他模块干扰 - 未隔离后果:P99 延迟毛刺明显,实测跨机房场景下连接重建占比可达 30%+
gRPC keepalive 和 context.WithTimeout 必须配齐
跨机房空闲断连很常见,最典型的延迟毛刺不是发生在业务请求时,而是第一次请求或空闲后下一次请求——因为中间设备(如云厂商 SLB、NAT 网关)默认 60 秒静默断开 TCP 连接。
- gRPC 客户端需配置
grpc.WithKeepaliveParams(keepalive.KeepaliveParams{Time: 30 * time.Second, Timeout: 10 * time.Second}):每 30 秒发探针,超时 10 秒即认为失效 - 服务端也需配对应
keepalive.ServerParameters,单向保活无效 -
context.WithTimeout必须设在调用层,不能只靠http.Client.Timeout——后者只控制连接建立和读响应头,不覆盖整个请求生命周期 - 每一层调用都得透传带截止时间的 ctx:
client.GetUser(ctx, req),且务必defer cancel(),否则 timer 泄漏会缓慢耗尽系统资源
真正难的不是加参数,而是让建连耗时、TLS 时间、LB 转发延迟、NAT 重传这些“看不见的耗时”浮出水面,并与 Span 对齐。多数团队卡在 traceparent 透传失败或连接池混用上,而不是 tracer 选型问题。











