Files
cs-note/hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md
T

218 lines
10 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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-元数据与鉴权]]