可观测性

线上服务出问题时,唯一能依赖的就是它吐出来的信息。可观测性三支柱——日志(发生了什么)、指标(趋势与告警)、剖析(为什么慢)——在 Go 生态中均有标准或事实标准方案。

结构化日志:log/slog

Go 1.21 引入的标准库 log/slog 取代了 log 与各路第三方日志库,核心思想是日志即结构化数据:每条日志由消息 + 键值对属性构成,输出为 JSON,可直接被采集系统索引。

package main

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

func main() {
    // JSON handler:生产环境标准配置
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo, // 生产用 Info,排查期临时降到 Debug
    }))
    slog.SetDefault(logger) // 全局默认,方便各层直接用 slog.Info

    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        logger.InfoContext(r.Context(), "订单查询",
            "order_id", id,
            "method", r.Method,
            "remote", r.RemoteAddr,
        )

        if id == "404" {
            logger.WarnContext(r.Context(), "订单不存在", "order_id", id)
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        _ = json.NewEncoder(w).Encode(map[string]string{"id": id})
    })
    _ = http.ListenAndServe(":8080", mux)
}

示例需 import "encoding/json"。输出的每条日志形如:

{"time":"2026-09-05T10:00:00+08:00","level":"INFO","msg":"订单查询","order_id":"1001","method":"GET","remote":"203.0.113.7:52341"}
Tip

slog 使用要点:

  • 请求路径中的日志用 InfoContext/ErrorContext,携带 ctx 以支持链路追踪字段透传。
  • 分组与复用:logger := slog.Default().With("service", "shortener"),公共字段只写一次。
  • 敏感信息(密码、token、手机号全文)绝不入日志
  • 旧代码迁移:slog.SetDefault 后,log.Printf 也会走 slog handler。 :::

请求日志中间件

把日志埋在中间件里,统一记录方法、路径、状态码与耗时:

package middleware

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

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

        next.ServeHTTP(sw, r)

        level := slog.LevelInfo
        if sw.status >= 500 {
            level = slog.LevelError
        }
        logger.Log(r.Context(), level, "http_request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", sw.status,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

type statusWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusWriter) WriteHeader(code int) {
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}

指标:Prometheus

Prometheus 是指标监控的事实标准。Go 服务只需暴露 /metrics 端点:

go get github.com/prometheus/client_golang/prometheus
package metrics

import (
    "net/http"

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

var (
    RequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{Name: "http_requests_total", Help: "请求总数"},
        []string{"method", "path", "status"}, // 维度标签
    )
    RequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "请求耗时分布",
            Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5},
        },
        []string{"method", "path"},
    )
)

func init() {
    prometheus.MustRegister(RequestsTotal, RequestDuration)
}

func Handler() http.Handler {
    return promhttp.Handler() // GET /metrics
}

在中间件中记录指标(与日志中间件同构):

func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(sw, r)

        metrics.RequestsTotal.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(sw.status)).Inc()
        metrics.RequestDuration.WithLabelValues(r.Method, r.URL.Path).
            Observe(time.Since(start).Seconds())
    })
}

:::warning 标签基数控制:path 标签应使用路由模板(如 /api/users/{id})而非原始路径,否则每个不同的 id 都会生成一个新时间序列,导致 Prometheus 内存暴涨。高频接口的四类黄金指标:延迟、流量、错误率、饱和度。

剖析:pprof

排查 CPU、内存、goroutine 泄漏问题,使用标准库 pprof:

import _ "net/http/pprof" // 注册 /debug/pprof/* 路由

func main() {
    // pprof 端点不要暴露公网:单独监听内网端口
    go func() {
        _ = http.ListenAndServe("127.0.0.1:6060", nil)
    }()
    // ... 业务服务
}
go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/profile?seconds=30  # CPU 火焰图
go tool pprof http://127.0.0.1:6060/debug/pprof/heap                             # 内存
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=1                          # goroutine 现场

链路追踪:OpenTelemetry

跨服务排障需要 trace id 串联。Go 生态标准是 OpenTelemetry(otel),核心用法:

package main

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
)

var tracer = otel.Tracer("shortener")

func Resolve(ctx context.Context, code string) (string, error) {
    ctx, span := tracer.Start(ctx, "Resolve")
    defer span.End()

    span.SetAttributes(attribute.String("code", code))
    // ... 业务逻辑
    return url, nil
}
Tip

otel 的 SDK 初始化(导出器、采样率、与采集器对接)依赖具体部署环境,篇幅较长。入门阶段记住两点即可:所有跨层调用传递 context.Context,日志中间件将 trace id 写入日志字段——有了这两条,接入 otel 只是配置问题。完整接入参考 opentelemetry-go 官方文档

小结

  • log/slog + JSON handler 是 Go 结构化日志的标准答案,请求日志收敛到中间件。
  • Prometheus 四类黄金指标 + /metrics 端点,标签基数必须可控。
  • pprof 内网常开,是 CPU/内存/goroutine 问题的最终手段。
  • ctx 全链路传递是未来接入追踪的前置条件,从第一天就坚持。