4. OpenTelemetry Metrics SDK:使用 OTel SDK 定义自定义指标
在 SLO 监控体系中,平台内置的 RED 指标(Rate/Errors/Duration)往往无法覆盖业务层面的可用性与性能度量。通过 OpenTelemetry Metrics SDK 定义自定义指标,可以将业务状态、队列深度、缓存命中率等关键信号直接暴露给 SLO 评估引擎。本章聚焦三种核心 Instrument 类型:Counter、Histogram 与 Gauge,并给出与 SLO 场景绑定的代码示例。
4.1 三种 Instrument 的语义与 SLO 映射
| Instrument | 语义 | 典型 SLO 场景 | 聚合方式 |
|---|---|---|---|
| Counter | 单调递增的累计值,只增不减 | 请求总数、错误总数、业务事件计数 | Sum / Rate |
| Histogram | 观测值的分布统计,支持分位数 | 请求延迟、响应体大小、批处理耗时 | 分位数 / 桶计数 |
| Gauge | 可上下浮动的瞬时值 | 队列长度、连接池使用率、CPU 温度 | LastValue / Min / Max |
4.2 Counter:单调递增的业务计数器
Counter 适用于只增不减的累计量。在 SLO 场景中,requests_total 和 errors_total 是最常见的两个 Counter,用于计算错误率:
// 使用 Go OTel SDK 定义 Counter
import (
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/attribute"
)
var (
requestCounter metric.Int64Counter
errorCounter metric.Int64Counter
)
func initMeter() {
meter := global.Meter("slo-service")
var err error
requestCounter, err = meter.Int64Counter(
"http.requests.total",
metric.WithDescription("Total number of HTTP requests"),
metric.WithUnit("{request}"),
)
if err != nil {
panic(err)
}
errorCounter, err = meter.Int64Counter(
"http.requests.error",
metric.WithDescription("Total number of failed HTTP requests"),
metric.WithUnit("{error}"),
)
if err != nil {
panic(err)
}
}
// 在请求处理中记录
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
attrs := []attribute.KeyValue{
attribute.String("method", r.Method),
attribute.String("path", r.URL.Path),
}
requestCounter.Add(ctx, 1, metric.WithAttributes(attrs...))
if r.URL.Path == "/fail" {
errorCounter.Add(ctx, 1, metric.WithAttributes(attrs...))
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
在 SLO 计算中,错误率 = errorCounter / requestCounter,通过 Rate 聚合后与 SLO 目标(如 99.9%)比较,即可判断错误预算是否正在快速消耗。
4.3 Histogram:延迟分布与分位数 SLO
Histogram 将观测值分配到预定义的桶(Bucket)中,后端(如 Prometheus)可据此估算分位数。对于延迟 SLO(例如 p99 < 200ms),必须使用 Histogram:
// 定义 Histogram 并记录请求延迟
var latencyHistogram metric.Float64Histogram
func initLatencyHistogram() {
meter := global.Meter("slo-service")
var err error
latencyHistogram, err = meter.Float64Histogram(
"http.request.duration",
metric.WithDescription("HTTP request latency in seconds"),
metric.WithUnit("s"),
// 显式指定桶边界,覆盖 SLO 关注的范围
metric.WithExplicitBucketBoundaries(
0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
),
)
if err != nil {
panic(err)
}
}
// 使用耗时记录
func timedHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
duration := time.Since(start).Seconds()
attrs := []attribute.KeyValue{
attribute.String("method", r.Method),
attribute.String("path", r.URL.Path),
attribute.Int("status", 200),
}
latencyHistogram.Record(r.Context(), duration, metric.WithAttributes(attrs...))
})
}
4.4 Gauge:瞬时状态与饱和度 SLO
Gauge 用于可上下波动的瞬时值。在 SLO 场景中,队列深度、连接池使用率、缓存命中率等指标适合用 Gauge 表示。与 Counter/Histogram 不同,Gauge 通常由后台采集协程定期更新:
// 定义 Gauge 并定期更新队列深度
var queueDepth metric.Int64Gauge
func initQueueGauge() {
meter := global.Meter("slo-service")
var err error
queueDepth, err = meter.Int64Gauge(
"queue.depth",
metric.WithDescription("Current depth of the work queue"),
metric.WithUnit("{item}"),
)
if err != nil {
panic(err)
}
}
// 后台协程每 5 秒采集一次队列深度
func startQueueMonitor() {
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C {
depth := getCurrentQueueDepth() // 从中间件或业务代码获取
attrs := []attribute.KeyValue{
attribute.String("queue_name", "main_worker"),
}
queueDepth.Record(context.Background(), depth, metric.WithAttributes(attrs...))
}
}()
}
对于饱和度 SLO(如“队列深度在 99% 时间内 < 1000”),Gauge 的 LastValue 聚合配合阈值检测即可实现。
4.5 三种 Instrument 的 SLO 联动架构
下图展示了自定义指标从 SDK 采集到 SLO 评估的完整链路:
4.6 三种 Instrument 的选择决策树
在实际编码前,可通过以下决策树快速确定 Instrument 类型:
- 数据是否只增不减? → 是 → Counter(请求数、错误数、事件数)
- 数据是否关注分布与分位数? → 是 → Histogram(延迟、包大小、耗时)
- 数据是否可上下波动且关注瞬时值? → 是 → Gauge(队列深度、连接数、温度)
- 以上都不符合? → 检查是否可以用 Counter + Rate 或 Histogram 的桶计数替代
4.7 完整代码示例:SLO 就绪的指标注册
以下代码整合了三种 Instrument,并加入资源属性(service.name、environment)用于多维度 SLO 评估:
package main
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func setupSLOAwareMeter() (*sdkmetric.MeterProvider, metric.Meter) {
ctx := context.Background()
exporter, _ := otlpmetricgrpc.New(ctx)
res := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("slo-demo"),
attribute.String("environment", "production"),
)
provider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter, sdkmetric.WithInterval(10*time.Second))),
)
otel.SetMeterProvider(provider)
meter := provider.Meter("slo-demo-meter")
return provider, meter
}
func registerSLOInstruments(meter metric.Meter) (
metric.Int64Counter,
metric.Float64Histogram,
metric.Int64Gauge,
) {
reqCounter, _ := meter.Int64Counter(
"http.requests.total",
metric.WithDescription("Total HTTP requests"),
metric.WithUnit("{request}"),
)
latencyHist, _ := meter.Float64Histogram(
"http.request.duration",
metric.WithDescription("HTTP request duration"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.01, 0.05, 0.1, 0.2, 0.5, 1.0),
)
queueGauge, _ := meter.Int64Gauge(
"queue.depth",
metric.WithDescription("Work queue depth"),
metric.WithUnit("{item}"),
)
return reqCounter, latencyHist, queueGauge
}