可观测性:日志、指标与链路追踪
可观测性:日志、指标与链路追踪
2016 年,Charity Majors 在一篇博文里写道:"你不是在调试代码,你是在调试一个系统。"这句话精准地描述了现代分布式系统的运维困境:你的代码可能是完全正确的,但由于某个上游服务延迟升高、某个数据库连接池耗尽,或者某个 Kubernetes 节点的内存压力,你的服务就开始表现异常。在这种环境下,传统的"看日志找错误"已经远远不够了。
可观测性(Observability)这个词来自控制论:一个系统的可观测性,是指你能够从它的外部输出推断出它的内部状态的能力。一个可观测性高的系统,允许你在生产环境遇到任何你从未见过的问题时,都能通过数据来理解它——而不需要提前预测这个问题的存在。这一章讲 Go 服务的三大可观测性支柱:日志、指标和链路追踪。
Level 1 · 为什么需要可观测性
三大支柱各有局限
日志(Logs) 是最古老、最直觉的可观测性工具。当 404 或 500 出现,你打开日志,找到对应的行,看看发生了什么。日志的优势是信息密度高——一条日志可以包含任意上下文。但日志有几个根本性的局限:
第一,日志是事件性的,它记录"发生了什么",但不告诉你"频率如何"。你能从日志里看到有一次数据库查询花了 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 的核心差异:
- 分配开销:
zap在其SugaredLogger上使用interface{}参数,每次调用都可能触发堆分配。zap的Logger(非 sugared)和slog的设计都尽量避免分配,使用[]Field或[]Attr传递键值对 - 与 context 的集成:
slog原生支持把context.Context传入每次日志调用,Handler 可以从 context 中提取 trace ID、用户 ID 等字段自动附加到每条日志 - 标准化:
slog作为标准库的一部分,不需要引入外部依赖;三方库可以直接用slog记录日志而不强迫调用方选择特定的日志库
日志级别与采样:高并发系统中,Debug 日志的量可能比 Info 多几个数量级。在生产环境启用 Debug 日志通常是灾难性的。slog 的 Enabled(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 的地址,Prometheus 知道服务的地址
- 如果服务挂了,Prometheus 立刻发现(抓取失败),而推送模型需要等待心跳超时
- 容易配合服务发现(Kubernetes、Consul)动态更新被监控的端点列表
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
告警疲劳管理
告警过多是比没有告警更危险的状态:工程师开始忽略告警,真正的问题在噪音中被掩埋。
告警设计原则:
-
每条告警必须是可操作的:告警触发时,工程师应该知道该做什么。如果没有对应的 runbook,这条告警就不应该存在。
-
告警应该基于 SLO 而非指标阈值:与其告警"P99 延迟超过 500ms",不如告警"错误预算消耗速率过快(burn rate > 14.4x)"。
-
使用多窗口多 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 分钟内找到问题根因——这种能力的价值,远超其成本。