为什么你需要结构化日志
如果你的 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 设计很直觉:第一个参数是消息,后面的参数是交替的键值对。日志级别有 Debug、Info、Warn、Error 四档。
切换 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_id、method、path,排查问题时一条 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 团队负责长期维护和优化
- 与标准库
log、testing 等无缝衔接
生产级实践清单
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.Println 或 log.Printf,是时候迁移了。如果你的项目已经在用 zap 或 zerolog,也不必急于更换——slog 的价值在于为 Go 生态提供了一个统一的日志接口标准,第三方库可以输出到同一个 Handler,实现日志的统一收集和格式化。
迁移建议:新项目直接上 slog;老项目先用 slog 替换标准库 log 的调用,第三方日志库通过 slogutil 适配器接入,逐步收敛到统一日志体系。