微服务可观测性实战:OpenTelemetry 三件套
线上 12 个微服务组成一个订单系统。某天 P99 从 50ms 飙到 800ms,告警一片。排查三天才发现是一个不相关的服务 OOM 后频繁 GC。如果有完整的分布式追踪,5 分钟就能定位。这篇文章记录 OpenTelemetry 的落地实践。
一、可观测性的三大支柱
| 支柱 | 数据类型 | 解决问题 | 工具 |
|---|---|---|---|
| Tracing | 时间线 + 因果链 | 请求在多个服务间的传播路径 | Jaeger / Tempo |
| Metrics | 数值聚合 | 系统整体性能、告警 | Prometheus / VictoriaMetrics |
| Logs | 文本事件 | 详细错误信息、debug | ELK / Loki |
核心原则:三者必须通过 Trace ID 关联,否则日志和指标对不上具体请求。
二、OpenTelemetry 的统一管线
OpenTelemetry(OTel)是 CNCF 推出的可观测性标准。它的核心价值:
- 统一 API:应用程序只依赖 OTel API,不依赖具体后端
- 统一数据模型:Trace / Metrics / Logs 共用 Resource / Attribute 模型
- 多后端支持:通过 Collector 转发到任意后端
架构:
Application Code
↓
OTel SDK (instrumentation)
↓ (OTLP protocol)
OTel Collector
↓
Jaeger / Prometheus / Loki / Datadog / ...
三、Trace 实战:Go 服务接入
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("order-service"),
semconv.ServiceVersion("1.0.0"),
),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% 采样
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.New(
propagation.WithBaggage(),
propagation.WithTraceContext(),
))
return tp, nil
}
创建 Span
func createOrder(ctx context.Context, req *OrderReq) (*Order, error) {
ctx, span := otel.Tracer("order").Start(ctx, "createOrder")
defer span.End()
span.SetAttributes(
attribute.String("user_id", req.UserID),
attribute.Float64("amount", req.Amount),
)
// 调用子服务,trace context 自动透传
user, err := userService.GetUser(ctx, req.UserID)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
order, err := db.CreateOrder(ctx, req, user)
if err != nil {
span.RecordError(err)
return nil, err
}
return order, nil
}
四、Baggage 跨服务透传
Baggage 是 OTel 的「跨服务上下文传递」机制。比如订单服务要把 user_id 传给支付服务,无需修改接口:
// 订单服务
import "go.opentelemetry.io/otel/baggage"
member, _ := baggage.NewMember("user.id", req.UserID)
bag, _ := baggage.New(member)
ctx = baggage.ContextWithBaggage(ctx, bag)
// 调用支付服务
paymentService.Pay(ctx, ...)
// 支付服务
bag := baggage.FromContext(ctx)
userID := bag.Value("user.id")
踩坑:Baggage 默认没有大小限制,可能塞爆请求头。生产环境限制 baggage 总大小(一般 < 8KB)。
五、Metrics 实战
import (
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/sdk/metric"
)
func initMeter() *metric.MeterProvider {
exporter := prometheus.New()
provider := metric.NewMeterProvider(metric.WithReader(exporter))
otel.SetMeterProvider(provider)
return provider
}
// 业务代码
var (
orderCounter metric.Int64Counter
orderLatency metric.Float64Histogram
)
func initMetrics() {
orderCounter, _ = otel.Meter("order").Int64Counter("orders.total",
metric.WithDescription("Total orders created"),
)
orderLatency, _ = otel.Meter("order").Float64Histogram("orders.latency",
metric.WithUnit("ms"),
)
}
func createOrder(ctx context.Context, req *OrderReq) (*Order, error) {
start := time.Now()
defer func() {
orderLatency.Record(ctx, float64(time.Since(start).Milliseconds()))
}()
// ... business
orderCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", "success"),
))
return order, nil
}
六、Logs 实战
OTel Logs 通过 logr 或 zap 等 logger 集成:
import (
otelzap "go.opentelemetry.io/contrib/zap"
"go.uber.org/zap"
)
func newLogger() *zap.Logger {
logger, _ := zap.NewProduction()
return logger.WithOptions(zap.WrapCore(otelzap.NewCore(
otelzap.WithLoggerProvider(otel.GetLoggerProvider()),
)))
}
func createOrder(ctx context.Context, ...) {
logger.Info("creating order",
zap.String("user_id", req.UserID),
zap.Float64("amount", req.Amount),
)
}
OTel 会自动把日志的 trace_id / span_id 写入日志条目,方便关联查询。
七、OTel Collector 配置
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1000
memory_limiter:
check_interval: 1s
limit_mib: 512
tail_sampling:
policies:
- status_code:
status_codes: [ERROR]
- latency:
threshold_ms: 1000
- probabilistic:
sampling_percentage: 10
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, batch]
exporters: [jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loki]
八、踩坑 1:采样导致关键 trace 丢失
tail_sampling:
policies:
- status_code:
status_codes: [ERROR] # 100% 保留错误 trace
- latency:
threshold_ms: 1000 # 100% 保留慢 trace
- probabilistic:
sampling_percentage: 10 # 10% 普通采样
关键:错误和慢 trace 必须 100% 采样,否则排查问题时找不到。
九、踩坑 2:Cardinality 爆炸
// ❌ 高基数 attribute,指标膨胀
orderCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("order_id", req.OrderID), // 每个 order_id 一个时间序列
))
// ✅ 用 tag 级别 attribute
orderCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", "success"),
attribute.String("channel", "web"),
))
每个独立 tag 组合产生一个时间序列。10 个 tag,每个 5 个值,会产生 5^10 = 10M 个序列,Prometheus 直接 OOM。
十、踩坑 3:HTTP 中间件 trace 漏接
// ❌ 没用 otelmux,手动 instrumentation 漏掉子 span
r := mux.NewRouter()
r.HandleFunc("/orders", createOrder)
// ✅ 用 otelmux 自动注入
r := mux.NewRouter()
r.Use(otelmux.Middleware("order-service"))
r.HandleFunc("/orders", createOrder)
原则:能用官方 instrumentation 包就别自己手写。
十一、踩坑 4:gRPC trace 透传
// 客户端
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
conn, _ := grpc.Dial(addr,
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
)
// 服务端
server := grpc.NewServer(
grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
)
不加这俩 interceptor,gRPC 跨服务调用时 trace context 丢失,整条链路断裂。
十二、踩坑 5:Propagator 配置不一致
// 服务 A 用 W3C TraceContext
otel.SetTextMapPropagator(propagation.TraceContext{})
// 服务 B 用 B3
otel.SetTextMapPropagator(propagation.B3{})
A 发的 trace context B 解析不出来,链路断。
修复:全公司统一 propagator,推荐 W3C TraceContext + Baggage:
otel.SetTextMapPropagator(propagation.New(
propagation.WithBaggage(),
propagation.WithTraceContext(),
))
十三、实战案例:50ms → 800ms 排查
排查过程
- 看 Grafana P99 大盘,发现
order-serviceP99 从 50ms 飙到 800ms - 点开 order-service 的慢 trace,发现子 span
payment-service.query耗时 750ms - 进入 payment-service,发现它又调用
risk-service.check - risk-service 的 trace 显示它花 700ms 等 Redis
- 但 Redis 不慢——risk-service 自己慢
- 最终定位:risk-service OOM 后频繁 GC,所有请求阻塞
修复
- 给 risk-service 加内存 limit
- 加 GC 耗时指标告警
- 优化 risk-service 的对象分配
复盘
如果一开始就有完整 trace,第 2 步就能看到「payment-service → risk-service」的调用关系,10 分钟就能定位 risk-service 是瓶颈。
十四、SLI / SLO 监控
# SLO: 订单创建 P99 < 200ms,每月错误率 < 0.1%
slo:
service: order-service
latency_p99: 200ms
error_rate: 0.1%
window: 30d
# Prometheus SLO 查询
sum(rate(orders_total{status="success"}[5m]))
/
sum(rate(orders_total[5m]))
十五、总结
OTel 落地的 5 条原则:
- Trace / Metrics / Logs 共用 Resource:service.name / service.version 必填
- 错误 trace 100% 采样:tail_sampling 配置不能省
- 避免高基数 attribute:order_id / user_id 不能当 metric label
- Propagator 全公司统一:W3C TraceContext + Baggage
- 用官方 instrumentation 包:otelmux / otelgrpc / otelzap
可观测性不是「上线后再加」的功能,而是从架构第一天就该规划的基石。投入一次,受益终生。