4.2 链路追踪:TraceID 贯穿

灰度要对照,排障要串链,都靠同一个东西:一条 TraceID 贯穿全部服务。我们基于 OpenTelemetry 做埋点,TraceID 在网关入口生成、随 gRPC metadata 透传、每个服务落到日志与指标标签,最后在 CLS 一条检索串出全链。这一篇把生成规则、透传细节、断链排障写全。TraceID 的规范与 go-micro-cps-guide 的观测章节对照,本篇只讲 17 个服务上的落地细节。

场景:断链是怎么发生的

断链场景 现象 根因
跨进程丢 下游日志没有 traceId metadata 没透传
异步丢 消息队列消费端无 traceId 生产端没写进消息头
日志断 有 traceId 但日志行没带 打日志用了本地 context

三起都是「某一段没把 ID 传下去」,链路断一段,后面全瞎。1.1 那次 35 分钟排障的根因就是第二起:订单超时发生在异步对账线程里,traceId 没进消息头。

排障慢的另一半原因是日志散在 9 台机器上 grep(1.1 的数字)。TraceID 解决「串起来」,4.4 的 CLS 归集解决「找得到」,两篇合起来才是完整的排障提速。

方案:TraceID 规则定死

项 规则
生成位置 只在网关入口生成,内部服务只透传
格式 32 位 hex(16 字节随机加 4 字节时间戳)
SpanID 每服务内生成 16 位 hex,随父 span 关系存
透传 gRPC metadata 两个 key:trace-id、span-id
落点 日志行、指标标签、消息头三处必带

时间戳进 traceId 的好处是 CLS 检索时能按 traceId 前 8 位粗筛时间窗,减少检索扫描量。16 字节随机保证 17 个服务并发下不撞号,碰撞概率按生日问题算在 10^15 次以内可忽略。

// 网关入口:生成 traceId 塞进 ctx,随 metadata 下发
traceID := newTraceID() // 16B 随机 + 4B 时间戳,32 hex
md := metadata.NewOutgoingContext(ctx, md, metadata.Pairs(
    "trace-id", traceID,
    "span-id", newSpanID(),
))

服务侧的 interceptor 统一做两件事:入站把 metadata 解进 ctx,出站从 ctx 取出来写 metadata。服务代码不碰 traceId,全在 govkit 的观测钩子里,1.4 篇定的观测层职责。

// govkit 观测 interceptor:入站解、出站注,服务代码零感知
func TraceUnary() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
        if id, ok := metadata.FromIncomingContext(ctx).Get("trace-id")[0]; ok && valid32Hex(id) {
            ctx = withTrace(ctx, id, newSpanID()) // 续接上游链路
        }
        log.Bind(ctx, "traceId", TraceOf(ctx)) // 日志行必带
        return h(ctx, req)
    }
}

出站侧对称:从 ctx 取 traceId 与 spanId 写进 outgoing metadata,消息生产端写进消息头,HTTP 外呼写 X-Trace-Id。三段封装同一个 withTrace/TraceOf,保证出口不遗漏。

透传要覆盖三类出口:gRPC 同步调用走 metadata、消息队列走消息头、HTTP 外呼(第三方网关)走 X-Trace-Id 头。三类出口的透传封装都在 govkit 里,漏掉任何一类就断在那类出口上——我们断链事故有三起里有两起源于第三类。

配置与代码:采样与落点

采样策略:入口请求 100% 采样,内部调用 10% 采样,但命中任一「异常条件」(错误、超时、降级、灰度命中)强制 100%。异常链全量,正常链抽样,存储成本压到 1/8 而不丢排障能力。采样判定在网关入口一次算完,随 traceId 一起透传,下游不重复判。

落点 内容 用途
日志行 traceId=xxx spanId=yyy CLS 全文检索
指标标签 延迟直方图带 trace 维度 4.3 看板
消息头 异步链路续接 跨进程不断链

CLS 里一条 traceId 串 5 个服务:traceId: 9f3a... | select ts, svc, span, err order by ts,35 分钟的排障现在 10 分钟内讲得清楚。异常链 90 天保留是为了复盘灰度事故与压测尖峰,正常链 30 天足够日常排障。

踩坑:四个断链教训

第一,异步丢 ID。消息队列消费端没读消息头的 traceId,对账链路断在异步处。修法是生产者把 traceId 写进消息头、消费者重建 ctx,两端的约定写进 govkit 的 MQ 封装,新接入的异步场景强制走封装。

第二,采样率配置漂移。内部 10% 采样被某服务本地覆盖成 100%,存储翻倍一周才发现。采样配置只能走 etcd 统一下发(1.4 的口径),本地覆盖的入口关掉,改采样要过 4.3 评审。

第三,ID 混用。早期有人拿 requestId(业务单号)当 traceId 塞进日志,CLS 检索把业务号与链路号混在一起,排障时两条线打架。规则:traceId 只认 metadata 里的 32 位 hex,业务单号走 err_code 与 msg 字段,CLS 的检索模板按字段分开。

第四,跨服务版本不一致。2.2 说 proto 只加不改,但观测层也适用:某服务升 OT SDK 后把 span 命名改了,下游看板按旧命名聚合直接断图。span 命名进契约,改名字走 4.3 看板的双周冻结期。

断链排障实操:四步定位

断链不用猜,按四步定位,每步都有固定动作:

步 动作 判据
1 CLS 搜 traceId 全链 看缺哪个服务段
2 缺段服务的入口日志 有没有收到该 traceId
3 上一跳出口日志 透传是否发出
4 出口类型对表 metadata/消息头/HTTP 头哪类漏了

第 3、4 步是快慢的分界:多数断链出在「上一跳漏发」,对出口类型表一查即中。17 个服务里 14 个走 gRPC metadata、3 个纯异步走消息头,排障时先判类型再查代码,比逐行 grep 快一个量级。

这套排障动作的脚本化版本(CLS 检索串自动生成、断链服务高亮)见 rust-logpipe-guide 的归集章节;订单域异步对账的链路重建对照 java-order-guide 的链路设计。

验收:链路通不通一条命令看

CI 里加了一个链路冒烟:网关打一条请求,断言返回体带 traceId,同时断言该 traceId 在 CLS 里能串出全部 17 个服务至少 1 段。任何一段缺失即红灯,合并前必过。这条冒烟把「断链」从生产事故降级成提交前缺陷,1.1 的 35 分钟排障不再复现。

参数速查

参数 取值 说明
traceId 长度 32 hex 16B 随机 + 4B 时间戳
入口采样 100% 网关入口全采
内部采样 10% 异常链强制全采
透传 key trace-id、span-id gRPC metadata
CLS 保留 30 天 异常链 90 天

小结

TraceID 四句话:只在网关生成、metadata 透传加消息头续接、异常链全量采样、ID 与业务号分字段。断链的三起全在「某段没传下去」,所以落点三处(日志、指标、消息头)缺一即断。下一篇 4.3 把 QPS、延迟、错误率三条线接上看板,TraceID 是它们的检索入口。

延伸阅读