「一次请求打进来,跨了好几个内部 RPC,怎么用同一个 traceId 串起来?」—— 这篇讲清 go-zero 内置的 tracing 体系、traceId 怎么产生、HTTP 侧怎么走、最关键的是多个内部 RPC 之间它怎么传递,以及你要在代码里注意啥。
先把最关键的说在前面。
go-zero 内置了基于 OpenTelemetry 的链路追踪。traceId 的传递不是你手动塞进 RPC 参数,而是靠两件你已经熟悉的东西自动完成:① context.Context(span 上下文装在里面,靠第一参数一路传);② 拦截器 / 中间件(自动把 span 上下文注入 HTTP 头 或 gRPC metadata,到对端再抽出来)。
l.ctx,链路就自动串上了;一旦你写了 context.Background(),链路当场断掉。否则后面说「同一个 traceId」会含糊。
parentSpanId 指向上游,从而拼成树。traceId 是 32 位十六进制(如 4bf92f3577b34da6a3ce929d0e0e4736),spanId 是 16 位。一次「网关→用户服务→订单服务」的调用,3 个 span 的 traceId 完全相同,spanId 各不相同,parent 指向上游——这就是「一条链路」。
核心包是 core/trace,底层是 OpenTelemetry SDK。
关键点:你不必手动 new 任何 SDK。rest.MustNewServer 和 zrpc.MustNewServer 检测到配置了 Trace 字段,就会自动 trace.StartAgent 并挂上拦截器。后面「配置」一节会看到。
go-zero 只负责生成 span 并导出,不负责存储和展示。Span 通过 OTLP(或 Zipkin)协议异步发到 Collector(Jaeger / Tempo / Zipkin / 阿里云 SLS 等),你在那边的 UI 里按 traceId 查整条链路。所以 go-zero 这侧是「无状态的生产者」。
两步:config 结构体加字段 + 配置文件填 Trace 块。
internal/config/config.go 里内嵌的 rest.RestConf / zrpc.RpcConf 已经包含 Trace trace.Config 字段,无需手加。
# 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 篇)。用户请求打进网关/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 调,就自动透传。
这是你最关心的:网关调 A 服务、A 调 B 服务,traceId 怎么不断。
机制:span 上下文放在 context.Context 里 → zrpc 客户端拦截器从 ctx 取出 → 注入 gRPC 的 metadata.MD(gRPC 专用于传递元数据的「键值袋」)→ 网络发出 → B 服务 zrpc 服务端拦截器从 MD 抽出 → 以它为 parent 起 child span。全程你不用碰 traceId 一个字。
// 你写的 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 的 .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 ... }
context.Background()、或忘了把 l.ctx 传进去,那次 RPC 抽不到 parent,就会生成一条全新的 trace,在 Jaeger 里看着像「孤立链路」。务必传 l.ctx(后台任务用 context.WithoutCancel(l.ctx) 脱离取消信号但保留 trace 上下文)。traceId 始终 = T1,spanId 逐级派生。
理论上你也能在每个 .proto 里加个 trace_id 字段手动传,但那是反面模式:① 污染业务协议;② 漏传一个方法就断链;③ span 之间的父子关系(耗时、层级)全得自己维护。go-zero 用 metadata(gRPC 专用、不进业务字段) + 拦截器(自动、全覆盖) 解决了这三点,所以是标准做法。
排查时想打印、想塞进返回、想关联日志时用。
// 用 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... 就能捞出这次请求的全部日志,不用自己拼。// 取当前 ctx 的 traceId,可用于返回给前端 / 写库 / 关联业务 tid := trace.TraceIDFromContext(l.ctx) resp.TraceId = tid // 返回给调用方,方便用户反馈时带上
// 需要给某段逻辑单独起 span 并打业务标签时 ctx, span := trace.Start(l.ctx, "business:deduct") defer span.End() span.SetAttributes(attribute.String("order_id", orderId))
做到这几点,链路 100% 不断。
| 该做 / 别做 | 说明 |
|---|---|
✅ 调下游 RPC 用 l.ctx | traceId 靠它传,这是唯一硬性要求(见 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),否则抽不出上下文 |
l.ctx 传进去。目标一致(全链路可追踪),机制不同。
| 方案 | 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 参数 | 反面模式:污染协议、易漏传、无父子层级 |