Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Go slog 结构化日志:从入门到生产最佳实践(2026)

前言

Go 1.21 引入了标准库 log/slog 包,标志着 Go 语言在可观测性领域迈出了关键一步。三年后的今天(2026年),slog 已经从"新选择"变成了生产环境默认方案。但很多团队仍然在使用 slog 时踩坑:Schema 不统一、Trace 无法关联、敏感信息泄露、性能瓶颈。

本文将从架构设计出发,覆盖 slog 的核心原理、生产 Schema 设计、OpenTelemetry 集成、性能优化以及常见陷阱,为你提供一份完整的生产级 slog 使用指南。

一、slog 架构设计:三板斧模型

slog 的设计哲学可以用一句话概括:将日志 API 与格式化/输出方式彻底分离。这个分离由三个核心组件完成:

┌─────────────────────────────────────────────────────────────────┐
│                         slog 架构                                │
│                                                                 │
│  应用代码                     slog 核心                          │
│  ┌──────────┐     emit      ┌──────────┐     format/export     │
│  │  Logger  │ ────────────► │ Handler  │ ──────────────────►   │
│  │ (API层)   │              │ (格式化层) │                      │
│  └──────────┘              └──────────┘                    输出 │
│                                  │                          目标 │
│                           ┌──────▼──────┐                        │
│                           │   Record    │                        │
│                           │ · timestamp │                        │
│                           │ · level     │                        │
│                           │ · message   │                        │
│                           │ · []Attr    │                        │
│                           └─────────────┘                        │
└─────────────────────────────────────────────────────────────────┘
组件职责使用者
Logger对外 API,提供 Info/Debug/Warn/Error 等方法应用开发者
Handler控制输出格式(JSON/Text)、过滤级别、写入目标平台/基础设施团队
Record单条日志的数据实体,携带时间戳+级别+消息+属性slog 内部流转

这种设计的精巧之处在于:切换输出格式只需换一个 Handler,应用代码零改动

go
// 开发环境:可读文本
devLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
devLogger.Info("server starting", "port", 8080)
// 输出: time=2026-06-09T10:00:00.000+08:00 level=INFO msg="server starting" port=8080

// 生产环境:机器可解析 JSON
prodLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
prodLogger.Info("server starting", "port", 8080)
// 输出: {"time":"...","level":"INFO","msg":"server starting","port":8080}

1.1 内置 Handler 对比

Handler适用场景输出示例
TextHandler本地开发、CLI 工具level=INFO msg="..." key=value
JSONHandler生产环境首选{"level":"INFO","msg":"...","key":"value"}

JSONHandler 是生产环境的唯一推荐选择:Elasticsearch、Grafana Loki、Datadog 等观测平台可以直接解析 JSON 日志,无需额外的解析配置。

1.2 日志级别体系

Debug  ──→  Info  ──→  Warn  ──→  Error
  ↑                                    ↑
详细诊断(仅开发)                  生产告警(需要行动)
级别语义生产环境策略
Debug详细诊断信息默认关闭,通过 LevelVar 按需开启
Info正常业务事件默认级别,服务启动、请求完成等
Warn异常但未失败接近限额、降级、重试
Error操作失败触发告警,需要人工介入

1.3 运行时动态调整日志级别

这是 slog 最实用的特性之一——无需重启服务即可切换日志级别:

go
package main

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

var logLevel = new(slog.LevelVar)

func main() {
    logLevel.Set(slog.LevelInfo) // 默认生产级别

    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: logLevel,
    })
    slog.SetDefault(slog.New(handler))

    // 暴露 HTTP 端点用于动态调整级别
    http.HandleFunc("/debug/loglevel", func(w http.ResponseWriter, r *http.Request) {
        level := r.URL.Query().Get("level")
        switch level {
        case "debug":
            logLevel.Set(slog.LevelDebug)
        case "info":
            logLevel.Set(slog.LevelInfo)
        case "warn":
            logLevel.Set(slog.LevelWarn)
        case "error":
            logLevel.Set(slog.LevelError)
        default:
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        w.Write([]byte("log level set to " + level))
    })

    slog.Info("server starting", "port", 8080)
    http.ListenAndServe(":8080", nil)
}

生产事故时不需要重新部署,一个 HTTP 请求就能打开 Debug 日志,排查完后再切回 Info——这在定位偶发问题时价值巨大。

二、生产环境日志 Schema 设计

日志 Schema 是团队最容易忽视但影响最大的问题。不统一的字段命名、随意的 key-value 传递,会导致跨服务查询断裂、告警规则失效。

2.1 必备标准字段

┌──────────────────────────────────────────────────────────┐
│              生产日志 Schema 必备字段                      │
├──────────────┬───────────────────────────────────────────┤
│ timestamp    │ 时间戳,与 Metrics/Traces 对齐             │
│ level        │ 严重程度(INFO/WARN/ERROR)                │
│ message      │ 简短描述,不重复结构化字段内容               │
│ service      │ 服务名称,多服务环境必备                     │
│ environment  │ dev / staging / production                 │
│ trace_id     │ 分布式追踪 ID,跨服务关联                   │
│ span_id      │ Span ID,精确定位调用链                     │
│ latency_ms   │ 请求耗时,性能分析必备                      │
│ user_id      │ 用户标识(注意隐私合规)                     │
└──────────────┴───────────────────────────────────────────┘

2.2 Schema 设计原则

go
// ✅ 正确:强类型 Attr + 统一命名规范
slog.Info("payment processed",
    slog.String("order_id", "ord_12345"),
    slog.Float64("amount", 49.99),
    slog.String("currency", "USD"),
    slog.Int64("latency_ms", 127),
)

// ❌ 错误1:裸 key-value(奇数个参数会静默产生 BADKEY)
logger.Warn("permission denied", "user_id", 12345, "resource") // BADKEY!

// ❌ 错误2:fmt.Sprintf 传递字符串
logger.Info(fmt.Sprintf("user %s logged in", userID)) // 丢失结构化信息!

// ❌ 错误3:不一致的命名风格
logger.Info("request done", "traceId", tid)   // camelCase
logger.Info("request done", "trace_id", tid)  // snake_case ← 不一致!

黄金法则

  1. 使用 slog.Attr 强类型构造,禁止裸 key-value(用 sloglint 强制检查)
  2. 命名风格统一为 lowercase_snake_case,与观测平台生态兼容
  3. 跨服务字段名保持一致trace_id 不能在一个服务叫 traceId,另一个叫 traceID
  4. 引入 schema_version 字段,支持渐进式 Schema 演进和向后兼容

2.3 用 sloglint 强制规范

yaml
# .golangci.yml
linters:
  enable:
    - sloglint
linters-settings:
  sloglint:
    attr-only: true      # 禁止裸 key-value,强制 slog.Attr
    no-global: true      # 禁止直接使用 slog.Info()(应使用注入的 Logger)
    context-only: true   # 强制使用 InfoContext/ErrorContext
    key-naming-case: snake_case  # 强制 snake_case 命名

CI 中跑一次 golangci-lint run,不规范代码无法合入。

2.4 解决重复 Key 问题

slog 内置 Handler 不会去重,下面的代码会产生非法 JSON:

go
logger.Info("deploy",
    slog.String("app", "my-service"),
    slog.String("app", "auth-module"), // 重复 key!
)
// 输出: {"app": "my-service", "app": "auth-module"}  ← 非法 JSON!

解决方案:使用 slog-dedup 中间件:

go
import slogdedup "github.com/veqryn/slog-dedup"

handler := slogdedup.NewOverwriteHandler(
    slog.NewJSONHandler(os.Stdout, nil), nil,
)
logger := slog.New(handler)

logger.Info("deploy",
    slog.String("app", "my-service"),
    slog.String("app", "auth-module"),
)
// 输出: {"app": "auth-module"}  ← 后者覆盖前者,合法 JSON

三、敏感信息防护

3.1 实现 LogValuer 接口

这是 slog 最优雅的安全机制——让数据类型自己决定如何在日志中呈现:

go
type User struct {
    ID           string
    Email        string
    PasswordHash string // 绝不能进入日志
    SSN          string // PII 敏感信息
}

// LogValuer 实现:只暴露安全的 ID 字段
func (u *User) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("id", u.ID),
    )
}

关键优势:新增字段默认不会被日志记录。如果将来给 User 添加了 PhoneNumber 字段,忘记更新 LogValue(),该字段不会出现在日志中——这是一种 fail-safe 设计

3.2 自定义 Handler 脱敏

对于外部类型(无法修改源码的场景),可以用中间件 Handler 做字段级脱敏:

go
type RedactHandler struct {
    handler slog.Handler
}

var sensitiveKeys = map[string]bool{
    "password":      true,
    "token":         true,
    "api_key":       true,
    "secret":        true,
    "authorization": true,
}

func (h *RedactHandler) Handle(ctx context.Context, r slog.Record) error {
    newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
    r.Attrs(func(a slog.Attr) bool {
        if sensitiveKeys[a.Key] {
            a.Value = slog.StringValue("[REDACTED]")
        }
        newRecord.AddAttrs(a)
        return true
    })
    return h.handler.Handle(ctx, newRecord)
}

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

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

func (h *RedactHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.handler.Enabled(ctx, level)
}

3.3 结构化错误对象

go
type PaymentError struct {
    Code    string
    Message string
    Cause   error
}

func (pe PaymentError) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("code", pe.Code),
        slog.String("message", pe.Message),
        slog.String("cause", pe.Cause.Error()),
    )
}

// 使用
err := PaymentError{
    Code:    "GATEWAY_TIMEOUT",
    Message: "payment gateway unreachable",
    Cause:   fmt.Errorf("dial tcp 10.0.1.5:443: i/o timeout"),
}
logger.Error("payment failed", slog.Any("error", err))

输出:

json
{
  "level": "ERROR",
  "msg": "payment failed",
  "error": {
    "code": "GATEWAY_TIMEOUT",
    "message": "payment gateway unreachable",
    "cause": "dial tcp 10.0.1.5:443: i/o timeout"
  }
}

四、OpenTelemetry Trace 关联

4.1 核心问题

原生 slog 不感知 OpenTelemetry Trace Context,日志中没有 trace_idspan_id,导致无法在 Loki/Elasticsearch 中从日志跳转到对应的 Trace。

4.2 方案一:slog-context(轻量方案)

使用 slog-context 将关联属性注入 Context:

go
package main

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

    slogctx "github.com/veqryn/slog-context"
    "github.com/google/uuid"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
    // 包装 handler,使其能读取 context 中的属性
    handler := slogctx.NewHandler(
        slog.NewJSONHandler(os.Stdout, nil),
        nil,
    )
    slog.SetDefault(slog.New(handler))
}

// 中间件:从 OTel Trace Context 提取 trace_id 注入 context
func traceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        spanCtx := otel.GetTextMapPropagator().Extract(r.Context(), 
            propagation.HeaderCarrier(r.Header))
        
        ctx := r.Context()
        if spanCtx.TraceID().IsValid() {
            ctx = slogctx.Prepend(ctx,
                slog.String("trace_id", spanCtx.TraceID().String()),
                slog.String("span_id", spanCtx.SpanID().String()),
            )
        }
        
        // 同时注入 request_id
        requestID := uuid.New().String()
        ctx = slogctx.Prepend(ctx, slog.String("request_id", requestID))
        w.Header().Set("X-Request-ID", requestID)
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
    // 所有日志自动携带 trace_id + span_id + request_id
    slog.InfoContext(r.Context(), "handling request")
    
    // 模拟业务逻辑
    result, err := processOrder(r.Context(), "ord_123")
    if err != nil {
        slog.ErrorContext(r.Context(), "order processing failed", 
            slog.Any("error", err))
        http.Error(w, "internal error", 500)
        return
    }
    
    slog.InfoContext(r.Context(), "request completed",
        slog.Any("result", result))
    w.Write([]byte("OK"))
}

4.3 方案二:otelslog Bridge(完整 OTel 原生方案)

如果需要将 slog 日志完整地纳入 OpenTelemetry 体系(包含 Resource 属性、Severity 标准化),使用 otelslog bridge:

go
import (
    "go.opentelemetry.io/contrib/bridges/otelslog"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
    "go.opentelemetry.io/otel/sdk/log"
    "go.opentelemetry.io/otel/sdk/resource"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func initLogger() (*slog.Logger, error) {
    // 创建 OTLP Log Exporter
    exporter, err := otlploghttp.New(context.Background())
    if err != nil {
        return nil, err
    }

    // 定义 Resource(服务身份标识)
    res := resource.NewWithAttributes(
        semconv.SchemaURL,
        semconv.ServiceName("order-service"),
        semconv.ServiceVersion("v2.3.0"),
        semconv.DeploymentEnvironment("production"),
    )

    // 创建 LoggerProvider
    provider := log.NewLoggerProvider(
        log.WithResource(res),
        log.WithProcessor(
            log.NewBatchProcessor(exporter),
        ),
    )
    otel.SetLoggerProvider(provider)

    // 通过 otelslog bridge 创建 slog.Logger
    handler := otelslog.NewHandler("order-service",
        otelslog.WithLoggerProvider(provider),
    )
    logger := slog.New(handler)
    slog.SetDefault(logger)

    return logger, nil
}

效果:所有 slog.InfoContext(ctx, ...) 调用自动附加 Trace Context,并在 Dash0/Honeycomb 等 OTel 原生平台中与 Traces 统一展示。

4.4 两种方案对比

维度slog-contextotelslog Bridge
侵入性低,只需中间件中,需替换全局 LoggerProvider
OTel 集成深度浅(仅 Trace 关联)深(Resource/Severity/Metrics 完整)
依赖slog-contextotel SDK + otelslog bridge
适用场景已有日志管道的团队全面拥抱 OTel 的新项目

五、性能优化

5.1 性能基准数据

slog 的分配开销比 zerolog/zap 高(主要来自 Record.AddAttrs 的管道操作),但对绝大多数微服务来说足够:

耗时 (ns/op)内存分配适用场景
zerolog3801 allocs极高性能(100k+ logs/s)
zap6565 allocs高性能
slog2,48142 allocs标准微服务(推荐)
logrus11,65479 allocs遗留项目

5.2 关键优化策略

策略一:执行昂贵操作前检查级别

go
// ❌ 直接传入
logger.Debug("expensive data", "data", computeExpensiveDebugData())

// ✅ 先检查级别
if logger.Enabled(ctx, slog.LevelDebug) {
    logger.Debug("expensive data", "data", computeExpensiveDebugData())
}

computeExpensiveDebugData() 可能涉及数据库查询、内存分配——在生产级别(Info)下,logger.Enabled() 返回 false,完全避免这笔开销。

策略二:减少属性数量

go
// ❌ 放太多字段
logger.Info("request done",
    slog.String("full_url", "https://api.example.com/v1/users/123/orders/456?..."),
    slog.String("raw_body", `{"items":[...], ...}`),  // 几千字节的 payload
)

// ✅ 只记录关键标识
logger.Info("request done",
    slog.String("method", "GET"),
    slog.Int("status", 200),
    slog.Int64("latency_ms", 127),
    slog.String("request_id", "req_abc123"),
)

规则:日志不是数据仓库。完整 URL 和 payload 存入 Trace Attribute,需要时从 Trace 查看。

策略三:ReplaceAttr 保持轻量

go
// ❌ 在 ReplaceAttr 中做数据库查询
opts := &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "user_id" {
            // 每条日志都查一次数据库 — 灾难!
            userName := db.FindUserName(a.Value.String())
            return slog.String("user_name", userName)
        }
        return a
    },
}

ReplaceAttr每条日志的每个属性都会调用,务必保持 O(1) 的简单映射操作。

5.3 容器环境部署清单

✅ 日志输出到 stdout/stderr(Kubernetes 原生采集路径)
✅ 使用 JSONHandler(Fluent Bit / OTel Collector 直接解析)
✅ 所有 Pod 副本使用相同 Schema(保证跨 Pod 查询一致性)
✅ 日志级别通过环境变量注入(LOG_LEVEL=info)
❌ 不要写日志到容器内文件(聚合困难,容器消失即丢失)
❌ 不要混用 printf 风格和结构化字段

六、从 logrus/zap 迁移到 slog

6.1 渐进式迁移路径

阶段1:并行运行
  旧 Logger (zap) + 新 Logger (slog) 并存,逐步替换调用点

阶段2:统一接口
  使用 slog.Handler 适配器包装 zap,API 统一为 slog

阶段3:完全迁移
  移除 zap 依赖,使用 slog + 自定义 Handler

6.2 迁移检查清单

bash
# 1. 统一字段命名
grep -rn "traceId\|traceID" --include="*.go" .  # 统一改为 trace_id

# 2. 检查裸 key-value
grep -rn '\.Info(' --include="*.go" . | grep -v 'slog\.'

# 3. 确保 JSONHandler
grep -rn 'TextHandler' --include="*.go" .  # 生产环境应全部为 JSONHandler

# 4. 验证 Schema
# 在 CI 中运行 golangci-lint,确保 sloglint 通过
golangci-lint run --enable sloglint ./...

七、完整生产配置模板

go
package main

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

    slogctx "github.com/veqryn/slog-context"
    slogdedup "github.com/veqryn/slog-dedup"
    "go.opentelemetry.io/otel"
)

// 生产级 Logger 初始化
func NewProductionLogger(serviceName, environment string) *slog.Logger {
    logLevel := new(slog.LevelVar)

    // 从环境变量读取级别
    switch os.Getenv("LOG_LEVEL") {
    case "debug":
        logLevel.Set(slog.LevelDebug)
    case "warn":
        logLevel.Set(slog.LevelWarn)
    case "error":
        logLevel.Set(slog.LevelError)
    default:
        logLevel.Set(slog.LevelInfo)
    }

    // JSON Handler + 脱敏 + 去重 + Context 支持
    baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level:     logLevel,
        AddSource: true,
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            // 重命名标准字段,与 OTel 语义对齐
            switch a.Key {
            case slog.TimeKey:
                a.Key = "timestamp"
            case slog.MessageKey:
                a.Key = "message"
            case slog.LevelKey:
                a.Key = "severity"
            case slog.SourceKey:
                a.Key = "logging.googleapis.com/sourceLocation" // GCP 兼容
            }
            return a
        },
    })

    // 中间件链:Context提取 → 去重 → 脱敏
    ctxHandler := slogctx.NewHandler(baseHandler, nil)
    dedupHandler := slogdedup.NewOverwriteHandler(ctxHandler, nil)

    // 注入服务元信息
    logger := slog.New(dedupHandler).With(
        slog.String("service", serviceName),
        slog.String("environment", environment),
        slog.String("schema_version", "1.0"),
    )

    slog.SetDefault(logger)

    // 启动时输出配置信息
    logger.Info("logger initialized",
        slog.String("level", logLevel.Level().String()),
        slog.String("otel_enabled", "true"),
    )

    return logger
}

八、总结

slog 在 2026 年已经成为 Go 日志的事实标准。总结关键实践:

层级实践
API 层使用 slog.Attr 强类型构造,禁止裸 key-value
Schema 层统一 snake_case 命名,跨服务字段一致,引入版本管理
安全层LogValuer 控制输出字段 + 中间件脱敏
可观测性层Context-aware 方法 + slog-context 或 otelslog bridge
运维层JSON 输出到 stdout,LevelVar 动态调级,sloglint CI 检查
性能层Enabled() 前置检查,控制字段数量

记住:日志 Schema 就是你的 API 合约——像对待 HTTP API 一样认真对待它。


参考资料

上次更新于: