Go 日志与可观测性:slog、结构化日志与 OpenTelemetry 集成

Go 日志与可观测性工程实践:从标准库 log 到 slog 结构化日志、Handler 自定义、日志级别与上下文关联、OpenTelemetry Trace 集成、Metrics 指标与运行时监控、以及生产日志体系与告警的设计原则。

导语:可观测性是"日志、Trace、Metrics"三位一体

很多团队把可观测性等同于"打日志",于是线上出问题时:日志像豆腐渣、Trace 缺失、指标迟到——三个信号对不上,排障全凭运气。真正的可观测性是 Logging(日志)、Tracing(链路)、Metrics(指标) 三种信号的协同:Metrics 告诉你"出了什么问题",Tracing 告诉你"在哪条链路出问题",Logging 告诉你"问题现场的细节"。

Go 生态在这条路上已经相当成熟:标准库 log/slog 在 Go 1.21 落地了结构化日志;go.opentelemetry.io/otel 提供了标准化的 Trace 与 Metrics 接入。本文把三块拼成一套可落地的工程方案。

一句话总结:Go 可观测性 = slog 打结构化日志 + OpenTelemetry 打链路与指标 + 一套"日志、Trace、Metrics 用同一 traceID 关联"的协同体系。

1. 从 log 到 slog:结构化日志的演进

1.1 传统 log 的痛点

// 传统 log:只有消息,没有结构,机器无法过滤与聚合
log.Printf("user %d login failed from %s", userID, ip)

// 传统 log 是"面向人类阅读",无法被查询引擎结构化检索
// 线上排查:grep 无法精确过滤字段,日志量一大就失控

1.2 slog 的基本用法

import "log/slog"

// 默认 JSON Handler:输出结构化字段
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user login", "userID", 42, "ip", "10.0.0.1")
// 输出:{"time":"...","level":"INFO","msg":"user login","userID":42,"ip":"10.0.0.1"}

// 文本 Handler:开发期更易读
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Warn("cache miss", "key", "hot-item", "cost_ms", 12.3)

log/slog 从 Go 1.21 进入标准库,提供了 Info、Warn、Error、Debug 四个级别,每个调用用 key/value 成对追加结构化字段。机器可解析,人也可读。

1.3 与旧 log 的兼容

// 旧代码仍可用 log 包,但输出被重定向到 slog
slog.SetDefault(logger)
log.SetOutput(slog.Default().Handler().Writer()) // 老 log 也走结构化

// 或直接把标准 logger 替换成 slog
log.SetFlags(0) // 去掉前缀,交给 slog 管理

一句话总结:slog 把"人读的消息"升级为"机器可解析的结构化事件",级别、时间、字段全是结构化数据,是日志体系的地基。

2. slog 核心 API 与 Handler 自定义

2.1 全局默认 logger 与 Logger 注入

// 包级:设置全局默认
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

// 依赖注入:把 *slog.Logger 作为依赖传入,便于测试与替换
type Service struct {
    log *slog.Logger
}

func NewService(log *slog.Logger) *Service {
    return &Service{log: log.With("service", "user-svc")} // 公共字段一次附加
}

2.2 HandlerOptions 与级别过滤

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo, // 只记录 Info 及以上,Debug 被过滤
    // AddSource: true,    // 附加调用位置(file:line)
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        // 自定义:把 level 改成大写,或裁剪敏感字段
        if a.Key == slog.LevelKey {
            a.Value = slog.StringValue(strings.ToUpper(a.Value.String()))
        }
        return a
    },
}))

// 运行时调整级别(配合配置文件或环境变量)
var lvl = new(slog.LevelVar)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
lvl.Set(slog.LevelDebug) // 按需切换到 Debug 排障

2.3 自定义 Handler:接第三方日志系统

// 自定义 Handler 只需实现三个方法,slog 负责上层调度
type myHandler struct {
    next slog.Handler
}

func (h *myHandler) Enabled(ctx context.Context, l slog.Level) bool {
    return h.next.Enabled(ctx, l)
}

func (h *myHandler) Handle(ctx context.Context, r slog.Record) error {
    // 在这里把 slog.Record 转成自己格式:加 traceID、采样、缓冲发送
    // ...
    return h.next.Handle(ctx, r)
}

func (h *myHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &myHandler{next: h.next.WithAttrs(attrs)}
}

func (h *myHandler) WithGroup(name string) slog.Handler {
    return &myHandler{next: h.next.WithGroup(name)}
}

一句话总结:slog.Logger 全局可设、依赖可注,HandlerOptions 控制级别与格式,自定义 Handler 让你把日志接到任何第三方平台。

3. 日志级别、采样与上下文关联

3.1 级别语义与生产规则

□ Debug —— 开发排障细节,生产默认关闭,按需动态打开
□ Info  —— 业务关键事件:登录、下单、状态变更
□ Warn  —— 可恢复的异常:重试、降级、慢查询
□ Error —— 影响功能的错误:必须记录堆栈与上下文
□ 原则:Info 别刷屏(每条 Info 都该有查询价值),Error 必带可排障字段

3.2 上下文关联:把请求元数据带进每条日志

// 方式1:With 派生 logger,在处理器入口建立"请求级 logger"
func handler(log *slog.Logger, w http.ResponseWriter, r *http.Request) {
    reqLog := log.With(
        "traceID", r.Header.Get("X-Trace-ID"),
        "path", r.URL.Path,
        "method", r.Method,
    )
    reqLog.Info("request start")
    // 后续该请求的所有日志都自动带 traceID/path/method
    reqLog.Error("upstream timeout", "service", "pay", "cost_ms", 2500)
}

3.3 日志采样:高流量下的取舍

// 高频日志(如每个请求的访问日志)在高流量下会淹没存储
// 方案1:按 traceID 哈希采样 —— 同一请求的日志要么全留要么全丢
func sampled(traceID string) bool {
    sum := 0
    for _, c := range traceID {
        sum += int(c)
    }
    return sum%100 < 10 // 保留 10%
}

// 方案2:按错误级别强制保留,Info 采样、Error 全留
if logLevel == slog.LevelError || sampled(traceID) {
    // 记录
}

一句话总结:级别语义决定"记什么",请求级 logger 决定"每条日志带什么上下文",采样决定"存多少"——三者协同才能控制日志成本与价值。

4. OpenTelemetry Trace 集成

4.1 初始化 TracerProvider

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    sdkTrace "go.opentelemetry.io/otel/sdk/trace"
)

func setupOTel() (*otlptracehttp.Exporter, error) {
    // 通过 OTLP/HTTP 导出到 Collector
    exporter, err := otlptracehttp.New(context.Background())
    if err != nil {
        return nil, err
    }
    tp := sdkTrace.NewTracerProvider(
        sdkTrace.WithBatcher(exporter),
        sdkTrace.WithSampler(sdkTrace.AlwaysSample()),
    )
    otel.SetTracerProvider(tp)
    return exporter, nil
}

4.2 在业务代码中打 Span

func HandleRequest(ctx context.Context) error {
    tracer := otel.Tracer("user-service")
    ctx, span := tracer.Start(ctx, "HandleRequest") // 创建一个 Span
    defer span.End()

    // 记录 Span 级结构化属性
    span.SetAttributes(attribute.String("endpoint", "/v1/user"))

    // 调用下游:ctx 传递让 Span 成为父子链
    if err := callPayment(ctx); err != nil {
        span.RecordError(err) // 记录错误但不一定结束 Span
        span.SetStatus(codes.Error, err.Error())
        return err
    }
    return nil
}

4.3 TraceID 注入日志:让 Log 与 Trace 对齐

// 关键:把 traceID 从 Span 取出,注入 slog —— 日志与链路就能对上
func logWithTrace(log *slog.Logger, ctx context.Context) *slog.Logger {
    span := trace.SpanFromContext(ctx)
    return log.With(
        "traceID", span.SpanContext().TraceID().String(),
        "spanID", span.SpanContext().SpanID().String(),
    )
}

// 使用:handler 里先取 ctx(可能来自 span),再打日志
ctx, span := tracer.Start(r.Context(), "handler")
defer span.End()
logWithTrace(logger, ctx).Info("user loaded", "userID", 42)
// 日志里的 traceID 与链路系统里的 traceID 完全一致

一句话总结:OpenTelemetry 用 TracerProvider 统一导出、用 Span 记录链路节点,最关键的是把 traceID 注入日志——日志与 Trace 才能互跳排查。

5. Metrics 指标与运行时监控

5.1 三种基本指标类型

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

var meter = otel.Meter("app.metrics")

// Counter:只增不减的累计计数(请求数、错误数)
reqCounter, _ := meter.Int64Counter("http.requests.total")

// Histogram:分布统计(延迟、大小)
latencyHist, _ := meter.Float64Histogram("http.request.duration")

// Gauge:可增可减的瞬时值(当前连接数、队列长度)
connGauge, _ := meter.Int64ObservableGauge("http.conn.current")

// 使用
func trackRequest(method, path string, dur time.Duration, code int) {
    reqCounter.Add(context.Background(), 1, metric.WithAttributes(
        attribute.String("method", method),
        attribute.String("path", path),
        attribute.Int("status", code),
    ))
    latencyHist.Record(context.Background(), dur.Seconds())
}

5.2 Go runtime 指标

// Go runtime 自带可观测指标,接入 OpenTelemetry 后自动上报
// 常见指标:
//   go.goroutines               当前 goroutine 数
//   go.gc.count / go.gc.duration GC 次数与耗时
//   process.memory.rss          进程常驻内存
//   process.cpu.utilization     CPU 使用率
import (
    "go.opentelemetry.io/contrib/instruntime/runtime"
)
runtime.Start(context.Background()) // 一行接入 runtime 指标

5.3 RED / USE 方法论选指标

□ RED(面向请求型服务):
  Rate —— 每秒请求数
  Errors —— 每秒错误数
  Duration —— 请求延迟分布(P50/P95/P99)
□ USE(面向资源型组件):
  Utilization —— 使用率(CPU/内存/磁盘)
  Saturation —— 饱和度(队列长度、goroutine 堆积)
  Errors —— 错误计数(重试、超时次数)
□ 指标要少而精:每个指标都对应一个"能否直接触发告警"的决策

一句话总结:Metrics 用 Counter/Histogram/Gauge 三种原语表达"量",RED/USE 方法论指导"选哪些量",runtime 指标一键接入补全系统视角。

6. 生产日志体系与告警设计

6.1 日志、Trace、Metrics 的关联设计

┌─ 告警触发条件(Metrics)
│      P99 延迟 > 500ms 持续 5 分钟
│           │
│           ▼
│  打开对应服务面板(Metrics + Trace 过滤)
│           │
│           ▼
│  按 traceID 进入单条链路(Tracing)
│           │
│           ▼
│  链路上某 Span 的日志详情(Logging,含 traceID)
└─ 完成排障

三者的纽带就是 traceID:指标异常 → 链路定位 → 日志取证,一条 traceID 串起全部。

6.2 结构化字段命名规范

□ 统一 key:traceID、spanID、service、env、host、version
□ 业务字段用驼峰:userID、orderID、costMs —— 保持可查询性
□ 敏感字段脱敏:token、password 绝不落日志
□ 统一时间格式:RFC3339,日志与 Trace 时间轴对齐
□ 保留堆栈:Error 级必须带 stack,Warn 级视情况

6.3 告警设计原则

□ 告警必须有"可执行的下一步"——没有行动的告警就是噪音
□ 用 Metrics 触发(持续条件),不用单条日志触发(容易抖动)
□ 分级:Warning(人工关注)→ Critical(自动动作/值班)
□ 避免告警风暴:Error 级别采样 + 聚合窗口(如 5 分钟错误率)
□ 每条告警附 traceID 查询链接,值班人员点开即可排障

一句话总结:生产可观测性是"traceID 关联三信号 + 结构化命名规范 + Metrics 驱动告警",让排障从"猜"变成"沿链路取证"。

7. 总结

信号Go 工具一句要义
Logginglog/slog结构化 key/value,机器可解析
日志级别slog 级别 + LevelVarDebug 关、Error 必带堆栈
上下文request loggertraceID/path/method 常驻每条日志
TracingOpenTelemetry SDKSpan 记录链路节点,父子传递
Metricsotel metric APICounter/Histogram/Gauge 三种原语
运行时监控runtime 指标goroutine/GC/内存一键接入
关联traceID 注入日志、Trace、Metrics 用同一 ID 串起
告警Metrics 持续条件可执行、分级、防风暴

落地记住六件事:全部日志用 slog 结构化输出、请求级 logger 常驻 traceID、Span 必须 defer End 且错误要 RecordError、指标按 RED/USE 选型而非随意埋、Error 日志必带堆栈与上下文、告警用 Metrics 持续条件触发并附 traceID 查询链接。把 Logging、Tracing、Metrics 当作一套协同体系来设计,你的 Go 服务才能在任何故障面前"有据可查、有链可追、有数可依"。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. Go 测试与基准实战:表驱动、Mock、Fuzz 与 pprof 基准分析
  2. Go 错误处理最佳实践:error 包装、errors.Is/As 与错误码体系
  3. Go GC 与内存调优:逃逸分析、内存池、GOGC、GOMEMLIMIT 实战