Go 微服务可观测性三支柱实战:Metrics、Logs 与 Traces 完整建设指南

系统讲解 Go 微服务的可观测性三支柱建设,涵盖 Prometheus 指标设计、结构化日志实践、OpenTelemetry 链路追踪、健康检查、Grafana 看板配置与告警规则设计,提供渐进式建设路径。

小服务也需要知道自己发生了什么

可观测性听起来像大系统话题,容易让初学者联想到复杂的指标平台、分布式追踪和日志集群。其实对一个 Go 小服务来说,最基础的可观测性很朴素:服务是否还活着?请求进来了多少?哪些请求失败?外部调用耗时多久?启动时用了什么配置?

如果这些信息都没有,服务出问题时只能靠猜。Google SRE 提出了可观测性的三大支柱:Metrics(指标)、Logs(日志)和 Traces(链路追踪)。三者从不同维度提供服务运行状态的全景视图。

Go 标准库已经足够做出基础版本:健康检查接口、请求日志、简单计数器、错误日志和启动摘要。等项目变大,再接 Prometheus 、OpenTelemetry 或日志平台也不迟。

这篇文章从标准库基础开始,逐步深入到 Prometheus 指标、结构化日志和 OpenTelemetry 链路追踪,提供一条渐进式的可观测性建设路径。

健康检查与存活探测

健康检查是最基础的可观测性能力。Kubernetes 等容器编排平台通过健康检查决定流量是否路由到某个实例。

基础存活检查(liveness):

package main

import (
	"encoding/json"
	"net/http"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{
		"status": "ok",
	})
}

func readinessHandler(w http.ResponseWriter, r *http.Request) {
	// 检查数据库连接等依赖
	if !checkDatabase() {
		w.WriteHeader(http.StatusServiceUnavailable)
		json.NewEncoder(w).Encode(map[string]string{
			"status": "not ready",
			"reason": "database unavailable",
		})
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{
		"status": "ready",
	})
}

func checkDatabase() bool {
	// 实际检查数据库连接状态
	return true
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/readyz", readinessHandler)
	http.ListenAndServe(":8080", mux)
}

健康检查应该轻量,不要每次都做昂贵操作。基础存活检查返回 ok 即可。liveness 失败意味着进程应该重启,readiness 失败意味着流量不应该路由到这个实例。两者职责不同,应该分开。

结构化请求日志

日志是从一行行文本开始的,但随着规模增长,结构化日志变得至关重要。

标准库的请求日志中间件:

package main

import (
	"log/slog"
	"net/http"
	"time"
)

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (r *statusRecorder) WriteHeader(status int) {
	r.status = status
	r.ResponseWriter.WriteHeader(status)
}

func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(rec, r)

			logger.Info("request completed",
				slog.String("method", r.Method),
				slog.String("path", r.URL.Path),
				slog.Int("status", rec.status),
				slog.Duration("duration", time.Since(start)),
				slog.String("remote_addr", r.RemoteAddr),
				slog.String("user_agent", r.UserAgent()),
			)
		})
	}
}

日志字段要稳定。即使用普通 log.Printf,也可以写成 key=value 格式,方便搜索。结构化日志的优势在于:可以精确搜索某个字段、可以做日志聚合分析、可以与监控系统联动。

Prometheus Metrics 指标设计

Prometheus 是云原生时代最常用的指标采集系统。Go 社区有成熟的 Prometheus 客户端库。

package main

import (
	"net/http"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	requestCounter = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "Total number of HTTP requests.",
		},
		[]string{"method", "path", "status"},
	)

	requestDuration = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "HTTP request duration in seconds.",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"method", "path"},
	)

	activeConnections = prometheus.NewGauge(
		prometheus.GaugeOpts{
			Name: "http_active_connections",
			Help: "Number of active HTTP connections.",
		},
	)
)

func init() {
	prometheus.MustRegister(requestCounter, requestDuration, activeConnections)
}

func MetricsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		activeConnections.Inc()
		defer activeConnections.Dec()

		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)
		duration := time.Since(start).Seconds()

		requestCounter.WithLabelValues(r.Method, r.URL.Path, http.StatusText(rec.status)).Inc()
		requestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("/metrics", promhttp.Handler())
	http.ListenAndServe(":8080", mux)
}

常用指标类型及其适用场景:

类型用途示例
Counter只增不减的计数器请求总数、错误总数
Gauge可增可减的瞬时值活跃连接数、队列长度
Histogram分布统计请求耗时分布
Summary分位数统计P99 延迟

指标设计原则:

  1. 基数控制:标签值组合不要太多,避免高基数问题
  2. 命名规范:namespace_subsystem_metric_name
  3. 单位明确:_total、_seconds、_bytes 后缀
  4. Help 字段:清晰说明指标含义

使用 zap 实现结构化日志

zap 是 Uber 开发的高性能结构化日志库,在 Go 社区广泛使用。

package main

import (
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

func NewLogger() (*zap.Logger, error) {
	config := zap.NewProductionConfig()
	config.EncoderConfig.TimeKey = "timestamp"
	config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
	config.EncoderConfig.StacktraceKey = "stacktrace"

	logger, err := config.Build(
		zap.AddCaller(),
		zap.AddStacktrace(zapcore.ErrorLevel),
	)
	if err != nil {
		return nil, err
	}

	return logger, nil
}

func main() {
	logger, _ := NewLogger()
	defer logger.Sync()

	// 基础日志
	logger.Info("service started",
		zap.String("version", "1.0.0"),
		zap.String("env", "production"),
		zap.Int("port", 8080),
	)

	// 错误日志带上下文
	logger.Error("database connection failed",
		zap.String("host", "db.example.com"),
		zap.Int("port", 5432),
		zap.String("database", "myapp"),
		zap.Error(err),
	)
}

zap 的核心优势是高性能(零分配 JSON 编码)和强类型字段。与标准库 slog 相比,zap 在性能上更优,slog 则随 Go 标准库附带,无需额外依赖。

OpenTelemetry Tracing 接入

分布式链路追踪帮助理解请求在多个服务间的流转过程。OpenTelemetry 是当前行业标准。

package main

import (
	"context"
	"fmt"
	"net/http"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
	"go.opentelemetry.io/otel/trace"
)

var tracer = otel.Tracer("example.com/myapp")

func initTracer() (*sdktrace.TracerProvider, error) {
	exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
	if err != nil {
		return nil, err
	}

	provider := sdktrace.NewTracerProvider(
		sdktrace.WithResource(resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceName("my-service"),
			semconv.ServiceVersion("1.0.0"),
		)),
		sdktrace.WithBatcher(exporter),
	)

	otel.SetTracerProvider(provider)
	return provider, nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, span := tracer.Start(r.Context(), "handle-request",
		trace.WithAttributes(attribute.String("path", r.URL.Path)))
	defer span.End()

	// 模拟数据库查询
	if err := queryDatabase(ctx); err != nil {
		span.RecordError(err)
		http.Error(w, "database error", http.StatusInternalServerError)
		return
	}

	fmt.Fprintln(w, "ok")
}

func queryDatabase(ctx context.Context) error {
	_, span := tracer.Start(ctx, "query-database",
		trace.WithAttributes(attribute.String("db.table", "users")))
	defer span.End()

	// 实际查询逻辑
	return nil
}

OpenTelemetry 的优势在于统一了日志、指标和追踪的 API,支持多种后端(Jaeger、Zipkin、Tempo 等)。

Grafana 看板配置基础

采集了指标和日志后,需要可视化展示。Grafana 是最常用的开源监控看板工具。一个基础的 HTTP 服务看板通常包含以下面板:

  1. 请求速率(每秒请求数)
  2. 错误率(错误请求占比)
  3. 延迟分布(P50、P95、P99)
  4. 活跃连接数
  5. CPU / 内存使用率

PromQL 查询示例:

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

# 错误率
rate(http_requests_total{status=~"5.."}[5m])

# P95 延迟
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

# 各路径请求量
topk(10, sum by (path) (rate(http_requests_total[5m])))

告警规则设计

采集了指标之后,需要配置合理的告警规则。告警规则应该遵循少而精的原则,避免告警疲劳。

推荐的告警:

名称规则优先级
服务不可用健康检查失败连续 2 分钟P0
高错误率5 分钟错误率超过 5%P1
高延迟P99 延迟超过 2sP1
请求量异常请求量较昨日同时段下降 50%P2
资源使用高CPU > 80% 或 内存 > 85%P2

Alertmanager 配置示例:

groups:
  - name: http_alerts
    rules:
      - alert: HighErrorRate
        expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "High error rate detected"
          description: "Error rate is above 5% for 2 minutes"

分布式链路追踪实战

在微服务架构中,单个请求可能经过多个服务。链路追踪可以帮助理解请求的全生命周期。

请求传播上下文的核心是 Trace ID 和 Span ID:

  • Trace ID:标识一个完整的请求链路
  • Span ID:标识链路中的一个操作片段
  • Parent Span ID:标识当前片段的父片段

在 HTTP 请求头中传递追踪上下文:

// 发送请求时注入追踪上下文
func callService(ctx context.Context, url string) error {
	req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

	// 注入追踪头到 HTTP 请求
	otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

// 接收请求时提取追踪上下文
func receiveRequest(w http.ResponseWriter, r *http.Request) {
	ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
	ctx, span := tracer.Start(ctx, "receive-request")
	defer span.End()

	// 处理请求...
}

渐近式可观测性建设路径

可观测性建设不需要一步到位,可以根据项目阶段逐步完善:

阶段一:基础监控(上线初期)

  • 健康检查接口(/healthz、/readyz)
  • 基础请求日志(方法、路径、状态码、耗时)
  • 简单的计数器指标(请求量、错误数)
  • 进程级别的 CPU / 内存监控

阶段二:结构化增强(稳定运行期)

  • 替换为结构化日志(zap / slog)
  • 引入 Prometheus 指标
  • 设置基础 Grafana 看板
  • 配置关键告警规则

阶段三:深度可观测(规模化阶段)

  • 接入 OpenTelemetry Tracing
  • 分布式链路追踪
  • 日志聚合(ELK / Loki)
  • 自定义业务指标
  • SLO / SLI 定义与监控

小结

Go 小服务的可观测性可以从四件事开始:健康检查、请求日志、简单指标、启动摘要。它们不复杂,但能显著降低排查成本。等项目复杂后,再引入结构化日志、Prometheus 指标和分布式追踪。

可观测性三支柱的适用范围:

支柱解决的问题Go 方案
Metrics系统整体趋势和异常Prometheus + Grafana
Logs请求级事件的详细信息zap / slog / ELK
Traces请求在分布式系统中的流转路径OpenTelemetry

可观测性不是上线后补救才做的事情。你在写第一个 HTTP 服务时,就可以把这些基础点放进去,让服务从一开始就更容易理解和维护。记住一个原则:如果生产环境出了问题,你应该能在 5 分钟内定位到故障原因,这就是可观测性的价值所在。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练