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
SLO 选型提示: 错误预算消耗通常基于 Counter 的速率计算;延迟类 SLO 必须使用 Histogram 才能获取 p99/p95 分位数;而资源饱和度类 SLO(如队列深度 > 阈值)则依赖 Gauge 的瞬时快照。

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...))
    })
}
桶边界设计要点: 桶边界应覆盖 SLO 阈值附近区域。例如 SLO 要求 p99 < 200ms,则应在 0.1s、0.2s、0.5s 处设置桶,确保阈值落在桶边界上或紧邻边界,避免分位数估算偏差。

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 评估的完整链路:

自定义指标 → SLO 评估链路 应用服务 OTel Metrics SDK Counter / Histogram / Gauge + Attributes (method, path) + 桶边界 / 单位 OTLP Exporter Prometheus / OTEL Collector SLO 评估引擎 告警 / 错误预算面板 关键设计点 • Counter 用于累计事件 • Histogram 用于延迟分布 • Gauge 用于瞬时状态 • Attributes 用于维度切分

4.6 三种 Instrument 的选择决策树

在实际编码前,可通过以下决策树快速确定 Instrument 类型:

  1. 数据是否只增不减? → 是 → Counter(请求数、错误数、事件数)
  2. 数据是否关注分布与分位数? → 是 → Histogram(延迟、包大小、耗时)
  3. 数据是否可上下波动且关注瞬时值? → 是 → Gauge(队列深度、连接数、温度)
  4. 以上都不符合? → 检查是否可以用 Counter + Rate 或 Histogram 的桶计数替代
实战建议: 在同一个 Meter 下,可以为同一业务事件同时创建 Counter 和 Histogram。例如 HTTP 请求既用 Counter 统计总数,又用 Histogram 记录延迟分布,两者通过相同的 Attributes 关联,便于 SLO 引擎同时计算错误率和分位数。

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
}