Contents

Go slog 结构化日志实战:告别 fmt.Println,构建可观测的日志体系

为什么你需要结构化日志

如果你的 Go 服务还在用 fmt.Println 或标准库 log 包打日志,你大概率遇到过这些痛苦:

  • grep 查日志像考古:想找出所有支付失败的请求?你得正则匹配字符串 "payment failed for user 12345",换个模块格式又变了
  • 日志无法被机器消费:ELK、Loki、Datadog 都期望 JSON 输入,你吐一堆非结构化文本进去,索引质量堪忧
  • 上下文丢失:一个请求经过中间件 → 业务逻辑 → 数据库层,每层各打各的日志,串不起来

结构化日志解决的就是这些问题:每条日志是一个键值对集合,输出为 JSON 或其他可解析格式,配合 request ID 可以完整追踪一条请求的全链路。

Go 1.21 引入了 log/slog 标准库,终于让结构化日志成为语言级的一等公民。本文从基础用法讲到生产级实践。

slog 基础:三分钟上手

最简单的开始

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import (
    "log/slog"
)

func main() {
    // 默认输出到 stderr,格式为文本
    slog.Info("user logged in", "user_id", 42, "ip", "10.0.0.1")
    // 输出: time=2026-07-15T08:00:00.000+08:00 level=INFO msg="user logged in" user_id=42 ip=10.0.0.1
}

slog 的 API 设计很直觉:第一个参数是消息,后面的参数是交替的键值对。日志级别有 DebugInfoWarnError 四档。

切换 JSON 输出

生产环境推荐 JSON 格式,方便日志收集器解析:

1
2
3
4
5
func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    logger.Info("user logged in", "user_id", 42, "ip", "10.0.0.1")
    // 输出: {"time":"2026-07-15T08:00:00+08:00","level":"INFO","msg":"user logged in","user_id":42,"ip":"10.0.0.1"}
}

用 With 创建带上下文的 Logger

这是 slog 最实用的特性之一。在中间件中给 logger 附上 request ID,后续所有日志自动携带:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := uuid.NewString()
        
        // 创建带上下文的 logger,存入 context
        logger := slog.Default().With(
            "request_id", requestID,
            "method", r.Method,
            "path", r.URL.Path,
        )
        
        ctx := withLogger(r.Context(), logger)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 业务代码中取出 logger 使用
func handleOrder(ctx context.Context, orderID string) error {
    logger := getLogger(ctx)
    logger.Info("processing order", "order_id", orderID)
    
    if err := validateOrder(ctx, orderID); err != nil {
        logger.Error("order validation failed", "error", err)
        return err
    }
    
    logger.Info("order processed successfully")
    return nil
}

每条日志自动带上 request_idmethodpath,排查问题时一条 grep request_id 就能拉出完整链路。

进阶:自定义 Handler 控制一切

slog 的 Handler 接口让你完全掌控日志的格式化、过滤和输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type filteringHandler struct {
    next    slog.Handler
    minLevel slog.Level
}

func (h *filteringHandler) Enabled(ctx context.Context, level slog.Level) bool {
    // 生产环境关闭 Debug 日志
    return level >= h.minLevel
}

func (h *filteringHandler) Handle(ctx context.Context, r slog.Record) error {
    // 给每条日志注入 hostname
    r.AddAttrs(slog.String("hostname", getHostname()))
    return h.next.Handle(ctx, r)
}

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

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

// 使用
func setupLogger() *slog.Logger {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    })
    wrapped := &filteringHandler{
        next:     jsonHandler,
        minLevel: slog.LevelInfo, // 生产环境只输出 Info 以上
    }
    return slog.New(wrapped)
}

这个模式可以做很多事情:日志采样(每 100 条 Debug 只输出 1 条)、敏感字段脱敏、按级别路由到不同输出等。

日志分组:组织嵌套字段

当日志属性越来越多,扁平结构会变得难以阅读。slog 提供 Group 来组织嵌套结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
slog.Info("order completed",
    slog.Group("user",
        slog.Int("id", 42),
        slog.String("name", "Alice"),
    ),
    slog.Group("order",
        slog.String("id", "ORD-789"),
        slog.Float64("amount", 299.00),
        slog.String("currency", "CNY"),
    ),
)

输出的 JSON 会带嵌套结构:

1
2
3
4
5
6
7
{
  "time": "2026-07-15T08:00:00+08:00",
  "level": "INFO",
  "msg": "order completed",
  "user": {"id": 42, "name": "Alice"},
  "order": {"id": "ORD-789", "amount": 299.0, "currency": "CNY"}
}

在 ELK 中,你可以按 order.amount 做范围查询,按 user.id 聚合统计,这是扁平日志做不到的。

性能对比:slog vs zap vs log

方案 每秒日志量 分配次数/条 是否标准库
log ~500K 0
slog (JSON) ~300K 2
zap (Sugared) ~400K 1
zap (Logger) ~800K 0
fmt.Println ~1000K 0

slog 在性能上不是最强(zap 的零分配 Logger 依然领先),但作为标准库它有一个巨大优势:没有外部依赖。对于大多数服务来说,每秒 30 万条日志的吞吐已经远超实际需求。选择 slog 意味着:

  • 不用维护第三方依赖的版本升级
  • Go 团队负责长期维护和优化
  • 与标准库 logtesting 等无缝衔接

生产级实践清单

1. 全局 Logger 替换

main() 启动时设置全局 logger,让整个程序的所有 slog.Info() 调用都走你的配置:

1
2
3
4
5
6
7
func main() {
    logger := setupLogger()
    slog.SetDefault(logger)
    
    // 之后所有包里的 slog.Info() 都使用你的配置
    runApplication()
}

2. 错误日志的正确姿势

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌ 不要这样:error 会被格式化为字符串,丢失类型信息
slog.Error("failed", "err", fmt.Sprintf("%v", err))

// ✅ 正确:使用 slog.Any 保留错误链
slog.Error("failed", "err", err)

// ✅ 更好:附加上下文帮助排查
slog.Error("database query failed",
    "query", query,
    "err", err,
    "retry_count", retryCount,
)

3. 不要在日志中泄露敏感信息

1
2
3
4
5
// ❌ 危险
slog.Info("user login", "password", password)

// ✅ 脱敏
slog.Info("user login", "email", maskEmail(email), "password", "***")

在自定义 Handler 中可以自动过滤已知敏感字段名(password、token、secret),实现统一的脱敏策略。

4. 结合 context 传递 trace 信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 将 trace_id 从 HTTP header 提取后注入 logger
func tracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-Id")
        if traceID == "" {
            traceID = generateTraceID()
        }
        logger := slog.Default().With("trace_id", traceID)
        ctx := context.WithValue(r.Context(), loggerKey{}, logger)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

总结

log/slog 作为 Go 标准库的结构化日志方案,在 API 设计、性能和可扩展性上取得了很好的平衡。如果你的项目还在用 fmt.Printlnlog.Printf,是时候迁移了。如果你的项目已经在用 zap 或 zerolog,也不必急于更换——slog 的价值在于为 Go 生态提供了一个统一的日志接口标准,第三方库可以输出到同一个 Handler,实现日志的统一收集和格式化。

迁移建议:新项目直接上 slog;老项目先用 slog 替换标准库 log 的调用,第三方日志库通过 slogutil 适配器接入,逐步收敛到统一日志体系。