--- tags: [gRPC, Logging, Tracing, OpenTelemetry, Observability] create time: 2026-05-11 17:00 --- # 日志与链路追踪 ## 概述 微服务的 observability 三支柱:Metrics、Logs、Traces。gRPC 生态已经为这三者提供了完善的工具链。不需要手写 logging interceptor——用成熟的库就行。但你需要理解这些库背后是怎么工作的,才能正确配置和调试。 ## 正文 ### 自动埋点(OpenTelemetry) 最简单且最可靠的方式是用官方 OTel SDK,一行代码搞定 span 创建、trace context propagation、RPC metrics 采集: ```go import ( "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" ) server := grpc.NewServer( grpc.StatsHandler(otelgrpc.NewServerHandler()), ) conn, _ := grpc.Dial(addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler())) ``` 这是生产环境的首选方案。你只需引入包即可自动获得完整的分布式追踪能力。 > [!tip] StatsHandler vs Interceptor > OTel 内部使用 StatsHandler API,不是 Interceptor。这意味着它对业务逻辑零侵入、性能开销更低。这也是为什么推荐优先选 StatsHandler 做可观测性。 ### 日志 Interceptor(手动实现) 虽然 OTel 足够好,但有时候你需要更细粒度的控制,比如把某些字段打到自己的结构化日志系统里: ```go func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { start := time.Now() md, _ := metadata.FromIncomingContext(ctx) traceID := getTraceID(md) resp, err := handler(ctx, req) // 调用真正的业务 handler log.Info("rpc_complete", "method", info.FullMethod, "duration_ms", time.Since(start).Milliseconds(), "error", err, "trace_id", traceID, "status_code", status.Code(err), ) return resp, err } // getTraceID 从 metadata 中提取 trace ID func getTraceID(md metadata.MD) string { tpp := md.Get("traceparent") if len(tpp) > 0 { return extractTraceID(tpp[0]) } xid := md.Get("x-trace-id") if len(xid) > 0 { return xid[0] } return ulid.Make().String() // 无上游 trace,生成根 span } // extractTraceID 解析 W3C Trace Context 格式 func extractTraceID(traceParent string) string { parts := strings.Split(traceParent, "-") if len(parts) >= 2 { return parts[1] } return "" } ``` 这段代码展示了完整的 logging interceptor 模式——先提取 trace ID,调用 handler 后记录耗时和错误。`getTraceID` 的辅助逻辑做了三件事:优先解析 W3C `traceparent`;回退到 `x-trace-id`;不存在时生成新 root span。这样既能融入分布式链路,也能独立产生新链路。 > [!question] 为什么在 handler 之后才打日志? > 因为我们需要知道请求的处理结果(耗时、错误)才能记录完整信息。如果在 handler 之前打日志,你只能拿到请求参数而拿不到响应。这也是 interceptor "洋葱模型"的核心优势——你可以包裹住 handler 的整个执行生命周期。这也正是 [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 中强调的 chain 原理。 ### Trace ID 透传机制 gRPC 通过 metadata 传递 W3C Trace Context 标准格式——这是目前业界最通用的分布式追踪协议。Trace Context 由四个部分组成: ``` traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 │ ───────────────────── ────────────────────── ── │ trace-id span-id flags │ (16 bytes) (8 bytes) (1 byte) └── version (00) ``` | 字段 | 长度 | 说明 | |------|------|------| | version | 2 hex chars | 当前固定为 `00` | | trace-id | 32 hex chars | 整个链路的唯一标识(所有 span 共享) | | span-id | 16 hex chars | 当前操作的唯一标识 | | flags | 2 hex chars | `01` 表示已采样,`00` 表示未采样 | #### 服务端读取上游 Trace ID ```go md, _ := metadata.FromIncomingContext(ctx) traceParent := md.Get("traceparent") var traceID string if len(traceParent) > 0 { traceID = extractTraceID(traceParent[0]) // 见上节 helper } // traceID 为空 → 当前服务是链路起点,需要生成新 root span ``` #### 客户端向下游注入 Trace ID 在使用 OpenTelemetry SDK 时,SDK 会自动完成 context propagation(详见本节开头的 `otelgrpc`),但如果你手动构造 metadata,需要这样写: ```go span := otel.Tracer("my-service").SpanContext() ctx = metadata.AppendToOutgoingContext( ctx, "traceparent", fmt.Sprintf("00-%s-%s-01", span.TraceID().String(), span.SpanID().String(), ), ) ``` > [!question] 为什么 OTel 不需要手写 traceparent? > OTel 的 `StatsHandler` 底层使用了 Go 的 `stats.Handler` API,它在 RPC 各个生命周期节点(如 `InboundPayload`, `OutboundPayload`)自动读写 metadata、创建 span。**你只需要引入一个包**,框架会帮你处理所有细节。这也是为什么官方强烈推荐使用自动埋点而非手动实现。 ### RPC Metrics 指标清单(OTel 自动产出) | Metric | Type | Labels | 用途 | |--------|------|--------|------| | `rpc.server.duration` | Histogram | method, service, status | 服务端延迟分布,用于画 P95/P99 | | `rpc.server.requests.count` | Counter | method, service | 服务端请求总量,监控流量趋势 | | `rpc.server.responses.count` | Counter | method, service, status | 按状态码统计响应数,发现错误突增 | | `rpc.client.duration` | Histogram | method, service, status | 客户端视角延迟,含网络 + 服务端总耗时 | | `rpc.client.attempts.count` | Counter | method, service | 含重试的调用次数,>1 说明有重试发生 | 这些指标可以直接对接 Prometheus/Grafana,用于构建实时监控面板和告警规则。常见告警示例: - **SLO violation**:`rpc.server_duration{le="0.5"} / rpc_server_requests_count > 0.01` → P99 < 500ms 的 SLA 被击穿超过 1% - **错误突增**:`increase(rpc_server_responses_count{status="Internal"}[5m]) > 10` → 5 分钟内 Internal 错误超 10 次 > [!question] client duration vs server duration 为什么不同? > server duration 是服务端处理耗时,client duration 包含网络 RTT + 排队 + 服务端处理。如果 client duration >> server duration,说明问题出在网络或客户端侧(比如连接池太小导致排队),而不是服务端逻辑慢。这是排查性能问题的关键区分点。[[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 中的 Interceptor 也可以手动记录 server duration 来做对比验证。 ### 端到端 Observability 架构 ```mermaid flowchart TB subgraph Client["客户端服务"] C1["App"] -->|"grpc.Dial WithStatsHandler"| C2["gRPC ClientConn"] end subgraph Network["网络层 Service Mesh / LB"] S1["Proxy Collect spans + traceparent"] end subgraph Server["服务端"] G1["gRPC Server"] -->|"otelgrpc StatsHandler"| O1["OTel SDK Create span"] O1 -->|"exporter"| E[("Tracing Backend")] O1 -->|"metrics"| P[("Metrics Backend")] D1["Interceptor Chain logging + trace_id"] -->|"structured log"| L[("Log Store")] end C2 -->|"HTTP/2 + traceparent metadata"| S1 S1 -->|"forwarded + preserved traces"| G1 G1 --> H["handler"] H --> D1 style C2 fill:#00B6BC,color:#fff style G1 fill:#FFD43B style O1 fill:#EE5A24,color:#fff style S1 fill:#9B59B6,color:#fff ``` 数据流说明: 1. **客户端**通过 `otelgrpc.NewClientHandler()` 自动创建 client span,并将 `traceparent` 写入 metadata 2. **网络层**(如 Envoy、Istio)可以采集 spans 并透传 trace context 3. **服务端**的 `otelgrpc.NewServerHandler()` 提取 upstream trace ID,继续串联当前服务的 span 4. **自定义 Interceptor** 从 metadata 中提取 traceID,将结构化日志发送到独立日志系统 > [!tip] 为什么 Metrics 和 Traces 分开? > Metrics 回答"系统怎么样"(P99 延迟多少?错误率多少?),Traces 回答"哪里出问题"(哪个具体的请求链路慢了?)。二者互补——Grafana dashboard 用 metrics 发现异常,JAEGER/Tempo 用 traces 定位根因。 ### 性能考量 - StatsHandler 比 Interceptor 性能更好——它通过 Go runtime channel 异步上报数据,不阻塞业务 goroutine - 生产环境优先 StatsHandler + OTel,Interceptor 做附加层(如自定义日志格式) - 不要每次都打印 full request/response(太吵),只在 debug level 或采样率下打印 - OTel SDK 默认使用 `BatchSpanProcessor`,会将 spans 批量导出。调整 `ScheduleDelayMillis` 和 `ExportTimeoutMillis` 可以权衡延迟与吞吐量 > [!warning] 日志采样 > 全量打印每条 RPC 的详细信息会迅速压垮日志系统。建议对 INFO 级别做采样(如每秒 1%),DEBUG 级别仅在开发环境开启。对于 Trace ID,即使是采样的日志也必须带上——否则无法在日志系统中将分散的采样日志聚合到同一条链路。 ### 最佳实践 Checklist - [ ] **StatsHandler 优先** — 可观测性用 OTel StatsHandler,不需要手写 interceptor - [ ] **Trace Context 遵循 W3C 标准** — 统一使用 `traceparent` key,避免各团队自定 header - [ ] **结构化日志带 trace_id** — 即使做了采样,也要保证每条日志可追溯到对应链路 - [ ] **Root span 生成新 trace ID** — 服务作为链路起点时,自动生成新 trace ID 而非留空 - [ ] **Context Key 防冲突** — 见 [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]] 的 context key 规范 - [ ] **Interceptor Chain 分层** — 外层做 auth,内层做 logging(见 [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 的 chain 原理) - [ ] **Metrics 对接 Grafana** — 利用 OTel 产出的标准 metrics,快速搭建 dashboard - [ ] **gRPCurl 注入 traceparent 验证** — 排查 tracing 问题时,手动注入已知 trace ID 验证透传链路 - [ ] **区分 client/server duration** — client 视角包含网络 RTT,server 视角只含处理时间 - [ ] **控制 metadata 大小** — 每个 traceparent 约 70 bytes,大量 metadata 累计可观,注意默认 8KB 限制 ## 关联笔记 - [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] - [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]]