go-zero traceId 链路追踪与跨 RPC 传递

「一次请求打进来,跨了好几个内部 RPC,怎么用同一个 traceId 串起来?」—— 这篇讲清 go-zero 内置的 tracing 体系、traceId 怎么产生、HTTP 侧怎么走、最关键的是多个内部 RPC 之间它怎么传递,以及你要在代码里注意啥。

一句话结论 trace/span 概念 go-zero trace 体系 启动与配置 HTTP 侧传递 RPC 跨服务传递 跨服务链路图 代码里拿 traceId 传递规范与踩坑 方案对比

一句话结论

先把最关键的说在前面。

go-zero 内置了基于 OpenTelemetry 的链路追踪。traceId 的传递不是你手动塞进 RPC 参数,而是靠两件你已经熟悉的东西自动完成:context.Context(span 上下文装在里面,靠第一参数一路传)② 拦截器 / 中间件(自动把 span 上下文注入 HTTP 头 或 gRPC metadata,到对端再抽出来)

所以你能在「go-zero ctx 传递规范」那篇里学到的「ctx 当第一参数传」不是白传的——traceId 就是跟着这个 ctx 在多个 RPC 之间流动的。你只要保证 logic 里调用下游 RPC 时用的是 l.ctx,链路就自动串上了;一旦你写了 context.Background(),链路当场断掉。

先分清 trace 和 span

否则后面说「同一个 traceId」会含糊。

Trace(链路)
一次完整请求从入口到所有下游调用的全集合。整个 Trace 共享同一个 traceId,是你在 Jaeger / Tempo 里搜索、还原调用树的「主键」。
Span(跨度)
链路里每一段调用(一个 HTTP 请求、一次 RPC 调用、一段处理逻辑)就是一个 span。每个 span 有自己的 spanId,并用 parentSpanId 指向上游,从而拼成树。

一个 traceId、一串 spanId 的长相

traceId 是 32 位十六进制(如 4bf92f3577b34da6a3ce929d0e0e4736),spanId 是 16 位。一次「网关→用户服务→订单服务」的调用,3 个 span 的 traceId 完全相同,spanId 各不相同,parent 指向上游——这就是「一条链路」。

go-zero 的 trace 体系长啥样

核心包是 core/trace,底层是 OpenTelemetry SDK。

三件套

用户代码 └─ trace.StartAgent(cfg) # 启动全局 TracerProvider(导出 span 到 collector) ├─ rest.MustNewServer # 自动注册 HTTP tracing 中间件 └─ zrpc.MustNewServer # 自动注册 RPC 服务端/客户端 tracing 拦截器 go-zero 内部 ├─ core/trace.Tracer # 封装 otel.Tracer("go-zero") ├─ StartServerSpan(ctx, header) # 入口起 span,从 header 抽取上游上下文 ├─ StartClientSpan(ctx, path) # 出口起 child span,沿用 ctx 里的父 span └─ Inject / Extract # 把 span 上下文塞进/取出 HTTP 头 / gRPC metadata

关键点:你不必手动 new 任何 SDKrest.MustNewServerzrpc.MustNewServer 检测到配置了 Trace 字段,就会自动 trace.StartAgent 并挂上拦截器。后面「配置」一节会看到。

数据去哪了

go-zero 只负责生成 span 并导出,不负责存储和展示。Span 通过 OTLP(或 Zipkin)协议异步发到 Collector(Jaeger / Tempo / Zipkin / 阿里云 SLS 等),你在那边的 UI 里按 traceId 查整条链路。所以 go-zero 这侧是「无状态的生产者」。

怎么把它打开

两步:config 结构体加字段 + 配置文件填 Trace 块。

① config 结构(goctl 生成的已经带了)

internal/config/config.go 里内嵌的 rest.RestConf / zrpc.RpcConf 已经包含 Trace trace.Config 字段,无需手加。

② etc 配置文件填写

# user-api.yaml / user-rpc.yaml
Name: user-api
Host: 0.0.0.0
Port: 8888

Trace:
  Name: user-api            # 本服务在链路里显示的名字
  Endpoint: http://jaeger:4318/v1/traces   # OTLP HTTP 地址(或 Zipkin :14268/api/traces)
  Sampler: 1.0           # 采样率 0~1,1.0 = 全采;生产可 0.1 降成本
  Batcher: otlp          # otlp 或 zipkin,决定用哪个导出协议
  ServiceName: user-api  # 可选,覆盖 Name
只要 Trace 块非空,MustNewServer 自动 StartAgent。你不需要main.go 里手写 trace 初始化——这也是 go-zero「配置即能力」的一贯风格(呼应之前的 config、init 篇)。

HTTP 入口:traceId 怎么来、怎么走

用户请求打进网关/API 服务的第一刻。

入口(服务端)

go-zero 的 HTTP tracing 中间件在请求进来时:① 尝试从请求头 traceparent(W3C 标准)或兼容头里抽取上游 traceId;② 抽不到就新生成一个 traceId 并起一个 server span;③ 把这个 traceId 写进响应头 x-trace-id,所以前端/客户端能在响应里直接看到它,方便反馈给客服排查。

出口(客户端,调别的服务)

你用 go-zero 自带的 httpc 调外部 HTTP 服务时,它内部也有 tracing 拦截器:自动把当前 ctx 里的 span 上下文注入到请求头 traceparent,对端如果是 go-zero 就能抽出来接上。只要你是 l.svcCtx.PayHttp.Do(l.ctx, ...) 这种带着 ctx 调,就自动透传。

浏览器/客户端 带? traceparent go-zero API (server) 无则生成 T1 · 回 x-trace-id 下游 HTTP 服务 httpc 注入 traceparent

多个内部 RPC 之间,traceId 怎么传

这是你最关心的:网关调 A 服务、A 调 B 服务,traceId 怎么不断。

机制:span 上下文放在 context.Context 里 → zrpc 客户端拦截器从 ctx 取出 → 注入 gRPC 的 metadata.MD(gRPC 专用于传递元数据的「键值袋」)→ 网络发出 → B 服务 zrpc 服务端拦截器从 MD 抽出 → 以它为 parent 起 child span。全程你不用碰 traceId 一个字。

客户端(发起 RPC 的那一侧,如 API 网关)

// 你写的 logic 里,调用内部 RPC —— 注意用的是 l.ctx
func (l *CreateOrderLogic) CreateOrder(req *types.Req) (*types.Resp, error) {
    // l.ctx 已经带着上游(HTTP 入口)的 span 上下文
    user, err := l.svcCtx.UserRpc.GetUser(l.ctx, &user.GetUserReq{Id: req.Uid})
    if err != nil { return nil, err }
    // ↓ zrpc 客户端拦截器在背后做了:
    //   md := metadata.MD{}
    //   trace.Inject(l.ctx, &md)   // 把 span 上下文写进 metadata
    //   带着 md 发起 gRPC 调用
    ...
}

服务端(被调用的内部服务,如 user-rpc)

// user-rpc 的 .proto 生成后,你只需填 logic
func (s *UserServer) GetUser(ctx context.Context, req *GetUserReq) (*GetUserResp, error) {
    // zrpc 服务端拦截器在背后做了:
    //   md, _ := metadata.FromIncomingContext(ctx)
    //   parentCtx := trace.Extract(md)       // 从 metadata 抽出上游 traceId
    //   ctx = trace.StartServerSpan(parentCtx, ...)  // 起 child span
    // 于是这里 ctx 的 traceId 和网关那次请求完全相同
    u, err := s.svcCtx.UserModel.FindOne(ctx, req.Id)  // DB 调用也带同一 ctx
    ...
}
核心一句话:拦截器负责「注入 MD / 抽取 MD」,ctx 负责「在进程内把 span 上下文一路带下去」。你唯一要做的,就是调用下游 RPC 时把 ctx 当第一参数传下去——和「go-zero ctx 传递规范」那篇说的完全一样。
常见断链点:如果你在 logic 里新开 goroutine 去调 RPC,却用了 context.Background()、或忘了把 l.ctx 传进去,那次 RPC 抽不到 parent,就会生成一条全新的 trace,在 Jaeger 里看着像「孤立链路」。务必传 l.ctx(后台任务用 context.WithoutCancel(l.ctx) 脱离取消信号但保留 trace 上下文)。

一次跨 3 个服务的调用长这样

traceId 始终 = T1,spanId 逐级派生。

① 网关 user-api traceId=T1 · span=S1 ② user-rpc traceId=T1 · span=S2 parent=S1 ③ order-rpc traceId=T1 · span=S3 parent=S2 MD: traceparent=T1 MD: traceparent=T1 在 Jaeger 里你会看到: • 一条 trace,id = T1 • S1 网关入口 └ S2 user-rpc └ S3 order-rpc 点 T1 即可看完整瀑布图、 每段的耗时 / 错误 / 日志 三者 spanId 不同,traceId 同

为什么不是「手动把 traceId 当 RPC 参数传」

理论上你也能在每个 .proto 里加个 trace_id 字段手动传,但那是反面模式:① 污染业务协议;② 漏传一个方法就断链;③ span 之间的父子关系(耗时、层级)全得自己维护。go-zero 用 metadata(gRPC 专用、不进业务字段) + 拦截器(自动、全覆盖) 解决了这三点,所以是标准做法。

在业务代码里怎么拿到 traceId

排查时想打印、想塞进返回、想关联日志时用。

① 日志自动带上 trace(最常用)

// 用 logx.WithContext(ctx),日志会自动多一个 trace 字段
logx.WithContext(l.ctx).Infof("收到订单创建请求 uid=%d", req.Uid)
// 输出示例: {"@t":"...","trace":"4bf92f...","msg":"收到订单创建请求 uid=12"}
只要日志走 logx.WithContext(ctx)整个链路所有服务的日志都带同一个 trace 值,在日志系统(如 Loki)里直接 trace=4bf92f... 就能捞出这次请求的全部日志,不用自己拼。

② 直接取 traceId 字符串

// 取当前 ctx 的 traceId,可用于返回给前端 / 写库 / 关联业务
tid := trace.TraceIDFromContext(l.ctx)
resp.TraceId = tid   // 返回给调用方,方便用户反馈时带上

③ 自定义 span / 给 span 打标签

// 需要给某段逻辑单独起 span 并打业务标签时
ctx, span := trace.Start(l.ctx, "business:deduct")
defer span.End()
span.SetAttributes(attribute.String("order_id", orderId))
方式 ③ 是进阶用法;绝大多数场景前两种就够了。别为了「好看」滥用自定义 span,会让链路图变杂乱。

传递规范清单(呼应 ctx 篇)

做到这几点,链路 100% 不断。

该做 / 别做说明
✅ 调下游 RPC 用 l.ctxtraceId 靠它传,这是唯一硬性要求(见 go-zero-ctx 篇)
❌ 别写 context.Background()会丢掉 span 上下文,生成孤立 trace,链路断裂
❌ 别在 goroutine 里丢 ctx后台任务用 context.WithoutCancel(l.ctx) 保留 trace、脱离取消
✅ 日志用 logx.WithContext(ctx)跨服务日志自动带同一 trace 值,便于聚合查
⚠️ 跨非 go-zero 服务要手动对端不是 go-zero 时,需自己 Extract/Inject 标准头(W3C traceparent)对齐 propagator
⚠️ 注意采样率Sampler < 1 时部分请求无 span,排查不到别慌,调高或定向采样
⚠️ propagator 要兼容混用其他语言服务时确认都用 W3C(或都 B3),否则抽不出上下文
最容易踩的坑:在 logic 里自己开 goroutine 异步调 RPC,却 new 了一个空 ctx。这条异步链路在 Jaeger 里会变成「没有 parent 的孤儿」,和主链路接不上。务必把 l.ctx 传进去。

和其他做法比,go-zero 这套啥特点

目标一致(全链路可追踪),机制不同。

方案traceId 传递方式特点
go-zero 内置(本文)ctx + 拦截器 + gRPC metadata / HTTP 头,自动零代码、配置即开;基于 otel,导出到 Jaeger/Tempo
手动 OpenTelemetry SDK自己 TracerProvider + propagator 注入 header/MD更灵活,但要手写初始化与拦截器,go-zero 已帮你做了
纯日志拼 traceId每个请求手动 uuid 塞进 ctx,自己记日志无标准链路 UI、无 span 层级耗时,只能看日志;适合小项目
Java Spring Sleuth / Micrometer同基于 otel,靠 MDC + baggage 头生态成熟,但语言不同;原理和 go-zero 一致
把 traceId 写进 proto 字段每个 RPC 方法加 trace_id 参数反面模式:污染协议、易漏传、无父子层级
go-zero 的标签是「开箱即用 + 基于标准 otel + 用 metadata 而非业务字段 + 拦截器全自动」。你几乎感受不到它的存在,只要别破坏 ctx 传递这一条铁律。