第 45 章

可观测性:日志、指标与链路追踪

可观测性:日志、指标与链路追踪

2016 年,Charity Majors 在一篇博文里写道:"你不是在调试代码,你是在调试一个系统。"这句话精准地描述了现代分布式系统的运维困境:你的代码可能是完全正确的,但由于某个上游服务延迟升高、某个数据库连接池耗尽,或者某个 Kubernetes 节点的内存压力,你的服务就开始表现异常。在这种环境下,传统的"看日志找错误"已经远远不够了。

可观测性(Observability)这个词来自控制论:一个系统的可观测性,是指你能够从它的外部输出推断出它的内部状态的能力。一个可观测性高的系统,允许你在生产环境遇到任何你从未见过的问题时,都能通过数据来理解它——而不需要提前预测这个问题的存在。这一章讲 Go 服务的三大可观测性支柱:日志、指标和链路追踪。

Level 1 · 为什么需要可观测性

三大支柱各有局限

日志(Logs) 是最古老、最直觉的可观测性工具。当 404500 出现,你打开日志,找到对应的行,看看发生了什么。日志的优势是信息密度高——一条日志可以包含任意上下文。但日志有几个根本性的局限:

第一,日志是事件性的,它记录"发生了什么",但不告诉你"频率如何"。你能从日志里看到有一次数据库查询花了 5 秒,但你不知道这是千分之一的偶发事件,还是每十秒就出现一次。第二,日志的会让你淹没。一个中等规模的微服务集群,每秒可能产生数十万行日志。在这个量级下,搜索和关联日志的代价很高。第三,日志本身没有聚合语义:你无法从日志中直接得到"过去 5 分钟的 P99 响应时间",你需要先把日志送入 Elasticsearch 或 ClickHouse,再写查询,延迟可能有几分钟。

指标(Metrics) 弥补了日志在聚合方面的缺陷。指标是对系统状态的数值摘要,天然适合时序数据库(如 Prometheus)存储和查询。你可以实时得到 P99 延迟、每秒请求数、错误率。但指标的信息密度很低——一个计数器只告诉你"发生了多少次",不告诉你每次是什么情况。当 P99 延迟突然升高,指标只告诉你这件事发生了,不告诉你为什么

链路追踪(Traces) 解决的是分布式系统特有的问题:一个请求跨越了多个服务,每个服务都贡献了一部分延迟,那么瓶颈在哪里?链路追踪把一个请求的完整生命周期——从用户端到 API 网关、到业务服务、到数据库——串成一条有时序关系的 Span 树。但链路追踪的代价最高:它需要在所有服务间传播追踪上下文(trace context),存储代价也比日志和指标更高。

真正的可观测性不是三选一,而是三者结合:指标让你发现问题(P99 延迟升高),追踪让你定位问题(是哪个服务的哪个数据库查询慢),日志让你理解细节(具体的 SQL 语句、参数和错误信息)。

可观测性的成本

可观测性不是免费的。存储日志、指标和追踪数据需要基础设施成本;采集这些数据会给应用增加 CPU 和内存开销;维护可观测性系统本身需要工程时间。

Prometheus 的一个实例在中等规模时可以处理每秒数百万个样本,但当你有数千个服务,每个服务有数百个指标,你需要联邦(federation)或者 Thanos/Cortex 这类水平扩展方案。Jaeger 存储追踪数据可能每天需要几十 GB 到几 TB,取决于采样率。日志存储的成本通常是三者中最高的。

这就是为什么**采样(sampling)**在可观测性工程中如此重要。我们不可能也不必要记录每一条请求的完整追踪,但我们必须保证那些有问题的请求(高延迟、错误)不被丢弃。

Level 2 · 原理:三大工具的内部机制

log/slog:结构化日志的 Go 标准

Go 1.21 引入了 log/slog 包,这是 Go 标准库对结构化日志的官方回应。在此之前,生态系统被 zap(Uber 开源)和 zerolog 分裂,大家各自写适配层。

slog 的核心设计是处理器(Handler)接口

type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}

slog.Logger 不直接写日志,它构建 Record 并将其传递给 Handler。标准库提供了两个内置 Handler:TextHandler(人类可读格式)和 JSONHandler(机器可读格式)。生产环境几乎总是使用 JSONHandler,因为 JSON 日志可以被 Elasticsearch、Loki、ClickHouse 等工具直接解析。

slog vs zap 的核心差异:

日志级别与采样:高并发系统中,Debug 日志的量可能比 Info 多几个数量级。在生产环境启用 Debug 日志通常是灾难性的。slogEnabled(ctx, level) 机制允许在日志调用的最外层做早期退出,避免构建不需要的 Record

if logger.Enabled(ctx, slog.LevelDebug) {
    // 只在 Debug 级别启用时才做这些昂贵的字符串格式化
    logger.Debug("详细请求日志", "headers", formatHeaders(r.Header))
}

Prometheus:指标的 Pull 模型

Prometheus 采用拉取(pull)模型:Prometheus Server 定期向被监控的服务的 /metrics HTTP 端点发起请求,抓取当前的指标快照。这与 StatsD 等推送(push)模型相反。

Pull 模型的优势:

Prometheus 的四种基本指标类型:

Counter(计数器):只增不减的累计值,适合记录事件总数(请求总数、错误总数)。不要直接用 Counter 的当前值,而是用 rate() 函数计算单位时间内的增量。

Gauge(仪表盘):可增可减的瞬时值,适合记录当前状态(活跃连接数、内存使用量、队列深度)。

Histogram(直方图):将观测值分布到预定义的桶(bucket)中,用于统计延迟、请求大小等分布。Histogram 会自动提供 _bucket_sum_count 三个指标,可以计算任意分位数(P50、P95、P99)。

Summary(摘要):与 Histogram 类似,但在客户端计算分位数。由于分位数在服务端聚合时不可加(你不能把两个服务的 P99 平均得到总体 P99),Summary 在多副本环境下不如 Histogram 灵活。

Prometheus Go 客户端的内存存储:每个 Metric 在注册时被加入一个全局(或自定义)Registry,抓取时 Registry 遍历所有 Metric,序列化为 Prometheus 文本格式输出。这个序列化过程是 O(n) 的,n 是指标数量,通常在毫秒级完成。

OpenTelemetry:链路追踪的统一标准

OpenTelemetry(OTel)是 OpenCensus 和 OpenTracing 合并后的产物,现在是 CNCF 的孵化项目,定位是可观测性数据的统一采集和导出标准

OTel 的核心概念:

Trace(链路):一次完整请求的全局视图,由一组 Span 组成,有一个全局唯一的 Trace ID(128 位)。

Span(跨度):链路中的一个操作单元,有起止时间、状态(OK/Error)、属性(键值对)和事件(时间点上的日志)。Span 有一个 Span ID(64 位)和一个可选的 Parent Span ID,从而构成树形结构。

Context Propagation(上下文传播):当请求跨服务时,追踪上下文(Trace ID + Span ID + 采样标志)必须随请求一起传输。W3C TraceContext 规范定义了标准的 HTTP Header 格式(traceparent),OTel 实现了这个规范。

Exporter(导出器):OTel SDK 收集 Span 数据后,通过 Exporter 发送到后端存储(Jaeger、Zipkin、Tempo 等),或者通过 OTLP(OpenTelemetry Protocol)发送到 OTel Collector,再由 Collector 转发到多个后端。

OTel 的 Go SDK 架构:tracer provider 是追踪的入口,通过它创建 Tracer,用 Tracer 创建 Span。采样策略在 tracer provider 初始化时配置,对业务代码透明。

Level 3 · 代码实践

log/slog 为 Gin 服务添加结构化日志

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
)

// 定义 context key 类型,避免冲突
type contextKey string

const traceIDKey contextKey = "trace_id"

// 创建携带 trace ID 的上下文感知 logger
func loggerFromCtx(ctx context.Context) *slog.Logger {
    logger := slog.Default()
    if traceID, ok := ctx.Value(traceIDKey).(string); ok {
        logger = logger.With("trace_id", traceID)
    }
    return logger
}

// 中间件:注入 trace ID 并记录请求日志
func SlogMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        traceID := c.GetHeader("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.New().String()
        }

        // 把 trace ID 存入 context 和 response header
        ctx := context.WithValue(c.Request.Context(), traceIDKey, traceID)
        c.Request = c.Request.WithContext(ctx)
        c.Header("X-Trace-ID", traceID)

        c.Next()

        // 请求结束后记录日志
        logger := loggerFromCtx(ctx)
        logger.Info("http request",
            "method", c.Request.Method,
            "path", c.Request.URL.Path,
            "status", c.Writer.Status(),
            "latency_ms", time.Since(start).Milliseconds(),
            "client_ip", c.ClientIP(),
            "bytes", c.Writer.Size(),
        )
    }
}

func main() {
    // 生产环境使用 JSON handler
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })
    slog.SetDefault(slog.New(handler))

    r := gin.New()
    r.Use(SlogMiddleware())

    r.GET("/users/:id", func(c *gin.Context) {
        logger := loggerFromCtx(c.Request.Context())
        logger.Debug("fetching user", "user_id", c.Param("id"))
        // 业务逻辑...
        c.JSON(http.StatusOK, gin.H{"id": c.Param("id")})
    })

    r.Run(":8080")
}

用 Prometheus 采集 Gin 服务指标

package metrics

import (
    "strconv"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    // Histogram:HTTP 请求延迟
    httpRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP 请求处理时间分布",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
        },
        []string{"method", "path", "status"},
    )

    // Counter:HTTP 请求总数(按状态分类)
    httpRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "HTTP 请求总数",
        },
        []string{"method", "path", "status"},
    )

    // Gauge:当前活跃请求数
    httpActiveRequests = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "http_active_requests",
            Help: "当前正在处理的 HTTP 请求数",
        },
    )

    // Counter:业务错误总数
    businessErrorsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "business_errors_total",
            Help: "业务逻辑错误总数",
        },
        []string{"error_type"},
    )
)

// PrometheusMiddleware 采集每个请求的指标
func PrometheusMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        httpActiveRequests.Inc()
        defer httpActiveRequests.Dec()

        c.Next()

        status := strconv.Itoa(c.Writer.Status())
        duration := time.Since(start).Seconds()
        // 使用路由模板(如 /users/:id)而非实际路径,避免高基数问题
        path := c.FullPath()
        if path == "" {
            path = "unknown"
        }

        labels := prometheus.Labels{
            "method": c.Request.Method,
            "path":   path,
            "status": status,
        }
        httpRequestDuration.With(labels).Observe(duration)
        httpRequestsTotal.With(labels).Inc()
    }
}

// RegisterMetricsEndpoint 注册 /metrics 端点
func RegisterMetricsEndpoint(r *gin.Engine) {
    r.GET("/metrics", gin.WrapH(promhttp.Handler()))
}

注意 c.FullPath() 而非 c.Request.URL.Path 的选择:如果使用实际路径(如 /users/12345),每个不同的用户 ID 都会产生一个新的 label 组合,导致**高基数(high cardinality)**问题——Prometheus 的内存使用量会爆炸式增长,因为它为每个唯一的 label 组合维护独立的时序。

用 OpenTelemetry 为 HTTP 调用添加分布式链路追踪

package tracing

import (
    "context"
    "net/http"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "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"
)

// InitTracer 初始化 OpenTelemetry tracer,导出到 OTLP 端点(如 Jaeger)
func InitTracer(ctx context.Context, serviceName, endpoint string) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracehttp.New(ctx,
        otlptracehttp.WithEndpoint(endpoint),
        otlptracehttp.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion("1.0.0"),
        ),
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
        // 生产环境使用采样率,例如 10%
        sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)),
    )

    // 设置全局 tracer provider 和 propagator
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{}, // W3C TraceContext
        propagation.Baggage{},
    ))

    return tp, nil
}

// 业务代码中创建 Span
func fetchUserFromDB(ctx context.Context, userID int64) (*User, error) {
    tracer := otel.Tracer("user-service")
    ctx, span := tracer.Start(ctx, "db.findUser")
    defer span.End()

    span.SetAttributes(
        attribute.Int64("user.id", userID),
        attribute.String("db.system", "postgresql"),
        attribute.String("db.statement", "SELECT * FROM users WHERE id = $1"),
    )

    user, err := db.FindUser(ctx, userID)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return nil, err
    }

    span.SetAttributes(attribute.String("user.name", user.Name))
    return user, nil
}

// 跨服务 HTTP 调用:注入追踪上下文到请求头
func callDownstream(ctx context.Context, url string) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }

    // 将当前 Span 的追踪上下文注入 HTTP header(W3C traceparent)
    otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

    return http.DefaultClient.Do(req)
}

W3C TraceContext traceparent 格式00-{trace-id}-{parent-id}-{flags},例如 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01。下游服务从 Header 中提取这个值,用 otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(req.Header)) 恢复追踪上下文,新创建的 Span 会自动成为上游 Span 的子节点。

把 Trace ID 注入日志

指标、追踪和日志的联动,关键在于每条日志都携带当前请求的 Trace ID。这样,当你在 Grafana 看到延迟异常的时间点,可以直接跳转到对应的 Trace,再从 Trace 里点击 "view logs" 查看这个 Span 期间的日志。

// 从 OTel context 中提取 trace ID 注入到 slog
func slogWithTrace(ctx context.Context) *slog.Logger {
    span := trace.SpanFromContext(ctx)
    if !span.SpanContext().IsValid() {
        return slog.Default()
    }
    return slog.Default().With(
        "trace_id", span.SpanContext().TraceID().String(),
        "span_id", span.SpanContext().SpanID().String(),
    )
}

Grafana 的 Loki 数据源支持从日志中提取 trace_id 字段,自动生成跳转到 Tempo(Grafana 的分布式追踪后端)的链接,实现日志、指标、追踪的无缝联动。

构建 Grafana 仪表板

Grafana 仪表板的核心是 PromQL 查询。几个关键的监控面板:

# 请求速率(每秒)
rate(http_requests_total[5m])

# P99 延迟(需要 Histogram 类型)
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, path)
)

# 错误率(5xx 请求占比)
sum(rate(http_requests_total{status=~"5.."}[5m])) by (path)
/
sum(rate(http_requests_total[5m])) by (path)

# 活跃连接数(Gauge)
http_active_requests

一个好的 Grafana 仪表板应该遵循 USE 方法(Utilization、Saturation、Errors)或 RED 方法(Rate、Errors、Duration),而不是把所有指标堆在一起。

Level 4 · 高级话题与边界情况

日志采样策略:头部采样 vs 尾部采样

头部采样(Head-based sampling):在请求进入系统时,按照预定的概率决定是否追踪这个请求(例如 10%)。优点是实现简单,开销低;缺点是有 90% 的概率丢掉一个有价值的错误请求。

尾部采样(Tail-based sampling):先收集所有 Span,在整个链路完成后,根据结果(是否有错误、延迟是否超过阈值)决定是否保留这次追踪。优点是能保证所有"有趣"的请求都被记录;缺点是需要在某个中间层(通常是 OTel Collector)暂存完整链路的所有 Span,内存开销大,实现复杂。

OTel Collector 支持通过 tailsamplingprocessor 实现尾部采样:

# otel-collector-config.yaml
processors:
  tail_sampling:
    decision_wait: 10s  # 等待 10 秒收集完整链路
    policies:
      - name: errors-policy
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow-traces-policy
        type: latency
        latency: {threshold_ms: 1000}
      - name: sample-10-percent
        type: probabilistic
        probabilistic: {sampling_percentage: 10}

这个配置会保留所有错误请求、所有超过 1 秒的请求,以及 10% 的其他请求。

Exemplars:连接指标与追踪

Exemplar(示例点)是 OpenMetrics 规范中定义的一个概念:在 Prometheus Histogram 的某个桶里,附带一个具体的样本点,包含 Trace ID。当 Grafana 展示 P99 延迟时,如果延迟突然飙升,你可以直接点击 Exemplar 跳转到对应的 Trace。

在 Go 中,使用带 Exemplar 的 Histogram 观测:

import "github.com/prometheus/client_golang/prometheus"

// 使用 ObserveWithExemplar 而非 Observe
httpRequestDuration.With(labels).(prometheus.ExemplarObserver).ObserveWithExemplar(
    duration,
    prometheus.Labels{
        "traceID": span.SpanContext().TraceID().String(),
    },
)

Prometheus 需要启用 --enable-feature=exemplar-storage 才能存储 Exemplar 数据。

持续性能分析:Pyroscope

链路追踪告诉你哪个 Span 最慢,但不告诉你为什么慢。持续性能分析(continuous profiling)填补了这个空白。Pyroscope(现已被 Grafana 收购)是一个开源的持续性能分析工具,它周期性地对运行中的 Go 进程做 CPU 和内存的 pprof 采样,并与时间戳关联存储。

在 Go 服务中集成 Pyroscope:

import "github.com/grafana/pyroscope-go"

func initPyroscope(serviceName string) {
    pyroscope.Start(pyroscope.Config{
        ApplicationName: serviceName,
        ServerAddress:   "http://pyroscope:4040",
        ProfileTypes: []pyroscope.ProfileType{
            pyroscope.ProfileCPU,
            pyroscope.ProfileAllocObjects,
            pyroscope.ProfileAllocSpace,
            pyroscope.ProfileInuseObjects,
            pyroscope.ProfileInuseSpace,
            pyroscope.ProfileGoroutines,
        },
    })
}

Pyroscope 支持与 OTel 集成,将 Trace ID 作为标签注入 Profile,实现"从 Trace 直接打开当时的火焰图"的体验。

SLI/SLO/SLA:把指标变成承诺

**SLI(Service Level Indicator)**是服务质量的量化指标,通常是 Prometheus 查询。例如:"成功请求的比例"、"P99 延迟"。

**SLO(Service Level Objective)**是 SLI 的目标值。例如:"99.9% 的请求应在 200ms 内完成"、"每月可用性不低于 99.95%"。

**SLA(Service Level Agreement)**是 SLO 对外的法律承诺,通常有违约赔偿条款。

SLO 的管理核心是错误预算(error budget):如果 SLO 是 99.9%,每月的错误预算是 0.1% × 30天 × 24小时 × 60分钟 = 43.2 分钟。当错误预算消耗殆尽时,应该停止发布新功能,专注于可靠性改进。

在 Prometheus 中跟踪错误预算消耗速率(burn rate):

# 过去 1 小时的错误率是否超过 SLO 的 14.4 倍(快速消耗预警)
(
  sum(rate(http_requests_total{status=~"5.."}[1h]))
  /
  sum(rate(http_requests_total[1h]))
) > 14.4 * 0.001  # 14.4x burn rate for 99.9% SLO

告警疲劳管理

告警过多是比没有告警更危险的状态:工程师开始忽略告警,真正的问题在噪音中被掩埋。

告警设计原则:

  1. 每条告警必须是可操作的:告警触发时,工程师应该知道该做什么。如果没有对应的 runbook,这条告警就不应该存在。

  2. 告警应该基于 SLO 而非指标阈值:与其告警"P99 延迟超过 500ms",不如告警"错误预算消耗速率过快(burn rate > 14.4x)"。

  3. 使用多窗口多 burn rate 告警(Google SRE 书中的方法):

# 短窗口快速告警(高置信度)
- alert: HighBurnRateFast
  expr: |
    (error_rate_1h > 14.4 * 0.001) and (error_rate_5m > 14.4 * 0.001)
  for: 2m
  labels:
    severity: critical

# 长窗口慢速告警(防止短时间抖动误报)
- alert: HighBurnRateSlow
  expr: |
    (error_rate_6h > 6 * 0.001) and (error_rate_30m > 6 * 0.001)
  for: 15m
  labels:
    severity: warning

高量日志的成本优化

日志成本优化最有效的手段是在源头减少日志量,而不是在存储层压缩。

结构化字段复用:避免在每条日志中重复记录同样的字段(如 service name、环境、版本)。应该在 slog.Logger 初始化时通过 With 方法一次性添加这些字段,它们会自动出现在所有后续日志中。

日志采样:对于高频但低价值的日志(如健康检查请求、心跳),实施采样:

// 每 100 次只记录 1 次
var healthCheckCount atomic.Int64

func healthCheckHandler(c *gin.Context) {
    if healthCheckCount.Add(1) % 100 == 0 {
        slog.Info("health check", "total_checks", healthCheckCount.Load())
    }
    c.Status(http.StatusOK)
}

分级存储:热数据(最近 24 小时)放在 Elasticsearch 的热节点,冷数据(超过 7 天)通过 Loki 或 S3 + Athena 查询,大幅降低存储成本。

可观测性不是一次性的基础设施建设,而是一种持续演进的实践。随着系统复杂度增加,你对可观测性的投入也应该成比例增加。一个可观测的系统,在凌晨三点的生产告警中,能给你足够的数据在 15 分钟内找到问题根因——这种能力的价值,远超其成本。

本章评分
4.7  / 5  (3 评分)

💬 留言讨论