一个例子看懂 slog
log/slog(Go 1.21)写出结构化的记录:一条消息、一个级别和若干键值属性。
每一行的输出都形如 time=... level=INFO msg="user logged in" user=ada attempts=1。因为每个值都是独立的字段,日志收集系统(Loki、Elasticsearch、CloudWatch、Datadog)可以直接按 user=ada 或 level=ERROR 过滤,不用在自由文本上跑正则表达式。
本页的示例都写到 os.Stdout,这样输出会按顺序显示。在真实的服务中,日志通常写到 os.Stderr,默认 logger 也写到那里。
日志级别
| 级别 | 值 | 用途 |
|---|---|---|
slog.LevelDebug | -4 | 给开发者看的细节,生产环境中关闭 |
slog.LevelInfo | 0 | 正常事件:已启动、请求已处理、任务已完成 |
slog.LevelWarn | 4 | 发生了意外,但程序已经处理 |
slog.LevelError | 8 | 某个操作失败了 |
处理器会丢弃低于其最低级别的记录,而默认的最低级别是 Info。这就是为什么在你配置一个 Level: slog.LevelDebug 的处理器之前,slog.Debug(...) 什么都不会打印。各个值之间留有间隔,可以容纳 slog.Level(2) 这样的自定义级别。
要在运行时修改级别(通过命令行标志、管理接口或信号),就在选项里放一个 slog.LevelVar,之后再对它调用 Set:
var level slog.LevelVar // zero value: Info
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: &level}))
level.Set(slog.LevelDebug) // from now on, debug records are written
文本还是 JSON
slog.NewTextHandler 写出 key=value 键值对,在终端里很容易阅读。slog.NewJSONHandler 每行写一个 JSON 对象,这是大多数日志管道期望的格式。记录日志的调用保持不变,只需要更换处理器。
值会保留各自的类型:在 JSON 输出中 status 是数字,retry 是布尔值;time.Duration 在文本中打印为 42ms,在 JSON 中打印为纳秒数;error 则打印它的错误信息。ReplaceAttr 是用来改写或删除属性的钩子,这里用它去掉时间戳,实际使用中常用来重命名键(把 msg 改成 message)或隐藏敏感值。
属性
松散类型的写法是键和值交替排列:"user", "ada", "attempts", 3。它很简短,但有一种出错的情况:参数个数是奇数。多出来的那个值会以 !BADKEY 为键记录下来。go vet 能发现这个问题:
./main.go:14:2: call to slog.Info missing a final value
为了类型安全,也为了少一点内存分配,可以使用属性构造函数;在热点路径上记录日志时,使用 LogAttrs:
logger.Info("order placed",
slog.Int("order_id", 1017),
slog.String("currency", "EUR"),
slog.Float64("total", 59.90),
slog.Duration("took", elapsed),
)
logger.LogAttrs(ctx, slog.LevelInfo, "order placed", slog.Int("order_id", 1017))
整个代码库的键要使用同一种命名约定(处处都用 user_id,而不是这个包里用 userID、那个包里用 uid)。日志系统中的查询都依赖于此。
With:携带上下文的 logger
logger.With(attrs...) 返回一个新的 logger,它会把这些属性加到每一条记录上。为每个请求或每个任务创建一个,它写出的每一行就都能关联起来:
每一行都带有 service、version、request_id 和 user,不用在每次调用时重复写。slog.Group 用来嵌套属性,JSON 处理器会把它写成嵌套对象("payment":{"amount":25,"currency":"USD"}),文本处理器则写成带点号的键(payment.amount=25)。logger.WithGroup("db") 会把这个 logger 之后的所有属性都放进一个分组。
把请求作用域的 logger 作为参数或结构体字段往下传。把它存进 context.Context 也可以,但会把依赖关系藏起来;slog 的 InfoContext(ctx, ...) 这类方法会把 context 传给处理器,自定义处理器可以借此取出追踪 ID。
用 LogValuer 隐藏敏感信息
一个类型可以通过实现 slog.LogValuer 来控制自己被记录成什么样子。这样无论是谁记录这个值,密码和令牌都不会进入日志:
User 只记录它的 ID 和邮箱,而单独记录的 Token 会打印成 REDACTED。处理器只在记录真正被写出时才调用 LogValue,所以它也适用于计算代价很高的值。
经典的 log 包
log 比 slog 出现得早,对于小程序和脚本来说仍然够用。它把带日期和时间前缀的行写到标准错误:
| 标志 | 添加的内容 |
|---|---|
log.LstdFlags(默认) | 日期和时间 2009/11/10 23:00:00 |
log.Lmicroseconds | 时间精确到微秒 |
log.LUTC | 使用 UTC 时间 |
log.Lshortfile / log.Llongfile | main.go:14 / 完整路径 |
log.Lmsgprefix | 把前缀放在消息前面,而不是行首 |
有三类函数会退出或 panic,它们的区别很重要:
log.Fatal、log.Fatalf、log.Fatalln打印后调用os.Exit(1)。defer 调用不会执行。 只在main中处理启动失败时使用它们,绝不要在库代码或请求处理器中使用。log.Panic及其同类函数打印后触发 panic,所以 defer 调用会执行,而且这个 panic 可以被恢复。- 其他所有函数都只是写一行日志。
要把日志写入文件,就打开文件并把它传给 log.New 或 log.SetOutput;io.MultiWriter(os.Stderr, f) 可以同时写到两处。
同时使用 log 和 slog
slog.SetDefault(logger) 会把 logger 设为顶层 slog.Info 等函数的默认 logger,同时也让 log 包的输出经过它。这样,你的代码或依赖中已有的 log.Printf 调用,都会以 Info 级别的结构化记录输出:
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil)))
log.Printf("legacy message") // {"time":"...","level":"INFO","msg":"legacy message"}
在调用 SetDefault 之前,默认的 slog logger 是通过 log 包输出的,这就是为什么单独一句 slog.Info("hi") 会打印 2026/09/23 14:30:00 INFO hi。
实用规则
- 错误要么记录,要么返回,不要两样都做。 一个函数如果既记录错误又返回它,同一个失败就会在调用栈的每一层都被记录一次。把错误连同上下文向上返回,在处理它的地方记录一次即可。
- 把变化的数据放进属性,而不是消息里。
logger.Info("user created", "user_id", id)在日志系统中很好归类;logger.Info(fmt.Sprintf("user %d created", id))则会为每个用户生成一条不同的消息。 - 绝不要记录敏感信息或完整的请求体。 用
LogValuer或ReplaceAttr隐藏它们。 - 生产环境用 JSON,开发环境用文本。 在启动时根据命令行标志或环境变量选择处理器。
- 慎重选择日志级别。 如果所有内容都以 Error 级别记录,针对错误的告警就会变成噪音。
常见问题
Go 里的 slog 是什么?
log/slog 是 Go 1.21 加入标准库的结构化日志包。每条记录不再是格式化的字符串,而是包含一条消息、一个级别(Debug、Info、Warn、Error)和若干键值属性,再由处理器把它写成 key=value 文本或 JSON:slog.Info("login", "user", "ada", "attempts", 3)。
slog 怎么开启 debug 日志?
默认的最低级别是 Info,所以 slog.Debug 什么都不会打印。创建一个级别更低的处理器,并把它设为默认:slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))。如果想在程序运行时修改级别,就用 slog.LevelVar 代替常量。
Go 中 log 和 slog 有什么区别?
log 写的是自由格式的行,可以加时间戳前缀,没有级别。slog 写的是带级别和带类型键值属性的记录,日志收集系统可以解析和过滤它们。两者都在标准库中;slog.SetDefault 还会把 log 包的输出也转交给 slog 处理器。
log.Fatal 会执行 defer 的函数吗?
不会。log.Fatal 和 log.Fatalf 打印消息后调用 os.Exit(1),这会跳过所有 defer 调用。只在 main 或没有需要清理的初始化代码中使用它们。log.Panic 则会触发 panic,所以 defer 调用会执行。