Go 语言的日志记录功能经历了漫长的发展。过去,开发者依赖简单的标准 log 包或功能强大的第三方库(如 zapzerolog)。随着 Go 1.21 中 log/slog 包的引入,Go 语言现在拥有了一个原生的、高性能的、结构化日志解决方案,旨在成为新的标准。

结构化日志 (Structured Logging) 的核心在于使用键值对(Key-Value Pairs)来记录日志,这使得日志可以被机器解析、过滤、搜索和可靠地分析。这对于观察系统的详细行为和调试问题至关重要。

slog 的设计将日志逻辑与其最终输出分离开来,提供了一个通用的 API,同时允许不同的日志实现来控制输出格式和目的地。

slog 的核心组件

log/slog 包围绕三个核心类型构建:LoggerHandlerRecord

1. Logger (日志记录器)

  • Logger 是日志创建的入口点(API),提供面向用户的方法,如 Info()Debug()Error()
  • 当调用这些方法时,Logger 会创建一个 Record,然后将其传递给配置好的 Handler 进行处理。
  • 顶层函数(如 slog.Info)调用默认 Logger 的对应方法。

2. Handler (处理程序)

  • Handler 是一个接口,负责处理 Record。它是决定日志 如何写入何处 的引擎。
  • Handler 负责将 Record 格式化为特定的输出(如 JSON 或纯文本)并写入目标(如控制台或文件)。
  • log/slog 包含内置的 TextHandler(格式化为 key=value 键值对)和 JSONHandler(格式化为 JSON)实现。
  • Handler 接口的设计使其具有高度的可组合性,可以创建包裹其他 Handler 的“中间件”来丰富、过滤或修改日志记录。

3. Record (日志记录)

  • Record 代表一个独立的日志事件。
  • 它包含事件的所有必要信息:事件时间、严重级别(INFO, WARN 等)、日志消息以及所有结构化的键值属性。
  • Record 是日志条目在被格式化之前的原始数据。

slog 的基础使用与配置

1. 使用默认 Logger

slog 提供了顶层函数来使用默认 Logger,初始默认 Logger 的输出格式与旧 log 包相似,但会包含日志级别信息。

package main

import "log/slog"

func main() {
    slog.Info("hello, world")
    // 默认输出类似: 2023/08/04 16:09:19 INFO hello, world
}

要显式获取默认 Logger,可以使用 slog.Default()

2. 创建和定制 Logger

通常,你会通过 slog.New() 函数创建一个自定义 Logger,并为其指定一个 Handler

JSON Handler (生产环境推荐):

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user logged in", "user_id", 123)
/* 输出示例:
{ "time" : "..." , "level" : "INFO" , "msg" : "user logged in" , "user_id" : 123 }
*/

Text Handler (Logfmt 格式):

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("database connected", "db_host", "localhost", "port", 5432)
/* 输出示例:
time=... level=INFO msg="database connected" db_host=localhost port=5432
*/

3. 设置默认 Logger

使用 slog.SetDefault() 可以替换包级别的默认 Logger。这样做会使顶层函数(如 slog.Info)使用新的配置。

更重要的是,SetDefault() 还会更新 log 包的默认 Logger,从而使现有使用 log.Printf 的应用程序也能无缝切换到结构化日志输出。

slog 日志级别控制

1. 内置级别

slog 提供了四个默认严重性级别,它们都是整数值:

  • slog.LevelDebug (-4)
  • slog.LevelInfo (0)
  • slog.LevelWarn (4)
  • slog.LevelError (8)

默认情况下,所有 Logger 都配置为记录 slog.LevelInfo 及更高级别的消息,这意味着 DEBUG 消息会被抑制。级别之间的差距(4)是故意的,为自定义级别留出了空间。

2. 设置最低级别

通过 slog.HandlerOptions 可以控制将被处理的最低级别:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug, // 启用 DEBUG 级别日志
})
logger := slog.New(handler)

3. 动态更新日志级别

如果需要在生产环境中不重启服务的情况下更改日志详细程度,可以使用 slog.LevelVar 类型。这是一个动态的日志级别容器,可以通过其 Set() 方法随时并发安全地更新级别。

var logLevel slog.LevelVar // 默认是 INFO
// ...
// 构造 Logger 时传入 LevelVar 的指针
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: &logLevel,
}))

// 随时更新级别
logLevel.Set(slog.LevelDebug)

4. 自定义级别

你可以定义新的常量来创建自定义日志级别,例如 TRACE (-8) 或 FATAL (12):

const (
    LevelTrace = slog.Level(-8)
    LevelFatal = slog.Level(12)
)

使用自定义级别时,必须使用通用的 logger.Log()logger.LogAttrs() 方法,并显式指定级别:

logger.Log(context.Background(), LevelFatal, "database connection lost")

若要修复默认输出中显示的级别名称(例如 ERROR+4),可以使用 HandlerOptions 中的 ReplaceAttr() 函数将级别整数值映射到自定义字符串。

5. 性能优化(避免昂贵操作)

在记录日志之前,如果需要进行昂贵的计算来准备数据,应该先检查 Logger 是否启用了该级别,以防止不必要的性能损失:

if logger.Enabled(context.Background(), slog.LevelDebug) {
    // 只有在 DEBUG 级别启用时才执行昂贵操作
    logger.Debug("operation complete", "data", getExpensiveDebugData())
}

slog 结构化属性(Attrs)

结构化日志的优势在于通过键值对(Attr)来丰富日志条目,使其可查询。

1. 键值对风格 (Key-Value Pairs)

最方便的方式是传递交替的键和值序列:

logger.Info("incoming request", "method", "GET", "status", 200)

⚠️ 风险警告: 这种便利的键值对风格存在重大隐患。如果参数数量为奇数(缺少值),slog 不会 panic 或返回错误,而是静默创建一个带有特殊键 !BADKEY 的损坏日志条目。

2. 强类型 Attr(推荐)

为了保证日志的正确性和可靠性,强烈推荐使用强类型的 slog.Attr 辅助函数。这可以在编译时捕获错误,防止产生不平衡的键值对:

logger.Warn(
    "permission denied",
    slog.Int("user_id", 12345),
    slog.String("resource", "/api/admin"),
)

slog 提供了多种构造函数来创建 Attr,如 slog.Stringslog.Intslog.Boolslog.Time 和用于任意类型的 slog.Any

3. LogAttrs:最高效的方式

对于频繁执行的日志语句,使用 logger.LogAttrs 方法是最有效的方式。它只接受 slog.Attr 类型的参数,从而避免了内存分配,性能更高。

// LogAttrs 仅接受 Attr 类型,效率更高
logger.LogAttrs(
    context.Background(),
    slog.LevelInfo,
    "hello, world",
    slog.String("user", os.Getenv("USER")),
)

4. 日志分组(Groups)

你可以使用 slog.Group() 将多个属性收集到一个命名组下,以增加日志的结构性并避免键冲突。

  • JSONHandler 将组显示为嵌套的 JSON 对象。
  • TextHandler 将组名作为前缀,用点号分隔(例如 properties.width=4000)。

Go 1.25.0 增加了更高效的 slog.GroupAttrs(),它只接受 Attr 列表。

logger.Info(
    "image uploaded",
    slog.Int("id", 23123),
    slog.Group("properties",
        slog.Int("width", 4000),
        slog.Int("height", 3000),
    ),
)

5. 子 Logger 和属性继承

使用 logger.With() 方法可以创建一个新的 Logger,该 Logger 继承父 Logger 的所有属性,并添加新的属性。这些公共属性只会被格式化一次(在调用 With 时),这对于性能优化非常有益。

你也可以使用 logger.WithGroup(name) 创建一个子 Logger,该子 Logger 的所有后续属性(包括在日志调用点添加的属性)都将以该组名限定。这有助于在大型系统中避免命名冲突。

slog 自定义 Handler

Handlerslog 灵活性的关键所在。

1. HandlerOptions 配置

内置的 TextHandlerJSONHandler 可以通过 slog.HandlerOptions 进行配置:

  • Level: 设置最低日志级别。
  • AddSource: 设置为 true 会自动包含日志语句的源代码文件、函数和行号信息。但请注意,这会带来性能开销,因为它需要调用 runtime.Caller()
  • ReplaceAttr: 这是一个函数,用于在日志记录之前重写每个非分组属性。它可以用于更改内置属性(如时间和级别)的键名,转换类型,或删除敏感信息。ReplaceAttr 会为每个日志记录中的每个属性调用一次,因此其逻辑应尽可能快。

2. 写入文件

最佳实践通常是记录到 stdoutstderr,并让运行时环境管理日志流。如果需要直接写入文件,可以将 *os.File 实例传递给 Handler。对于日志文件轮换,可以使用标准的 logrotate 工具或 lumberjack 包。

3. 自定义和第三方 Handler

Handler 接口定义了四个方法,负责处理日志记录的生命周期:

type Handler interface {
    // Enabled 报告是否处理给定级别的记录,用于快速丢弃不必要的日志事件。
    Enabled(context.Context, Level) bool
    // Handle 处理实际的日志记录,仅在 Enabled 返回 true 时调用。
    Handle(context.Context, Record) error
    // WithAttrs 返回一个新的 Handler,其属性包含接收者属性和传入的属性。
    WithAttrs(attrs []Attr) Handler
    // WithGroup 返回一个新的 Handler,将后续属性限定在给定名称的分组下。
    WithGroup(name string) Handler
}

由于 Handler 是一个接口,你可以实现自定义 Handler 来实现不同的格式化或输出目的地。社区也提供了许多有用的第三方 Handler:

  • slog-sampling: 用于丢弃重复日志条目,实现日志采样。
  • tint: 用于在开发环境中将日志输出彩色化。
  • slog-multi: 用于高级组合模式,例如扇出、缓冲、条件路由等。

处理重复键: 内置 Handler 不会自动对日志中的重复键进行去重,这可能导致观测工具行为不确定。如果需要去重,你需要使用第三方“中间件” Handler,例如 slog-dedup

4. 集成第三方日志处理器

slog 的设计目标之一是提供一个统一的 API ,允许开发者在不修改核心日志代码的情况下切换高性能的 Handler。这使得 slog 可以与高性能库(如 Zap 或 Zerolog)结合使用,以获得高性能和标准 API 的双重优势。

slog 高级日志模式

1. 上下文日志(Contextual Logging)

slog 提供了一组带有 context.Context 参数的方法(如 InfoContext()),这些方法允许 Handler 提取上下文中的信息,例如追踪 ID (Trace ID)。

重要说明: 内置的 slog Handler 不会自动从 context.Context 中拉取值。你必须使用上下文感知 Handler(如社区的 slog-context 包)才能实现上下文属性的传播。

使用全局 Logger 和上下文 Handler (推荐做法之一): 这种模式使用全局 Logger 并配置一个上下文感知的 Handler。请求处理中间件将上下文属性(如 correlation_id)附加到 context.Context 中(例如使用 slogctx.Prepend())。后续的日志调用(如 slog.InfoContext(r.Context(), ...))将上下文传递给全局 Logger,Handler 则从 Context 中提取并写入这些属性。

不推荐的做法: 尽管某些第三方库允许将 Logger 实例本身放入 context.Context 中,但 Go 团队最终从 slog API 中删除了相关辅助函数(如 slog.NewContext()),因为它被认为是一种隐式的依赖关系,使代码难以理解和测试。

依赖注入模式: 另一种推荐模式是将 Logger 视为正式的依赖项,通过结构体字段或函数参数显式传递,这使得代码更具可测试性和灵活性。

2. LogValuer 接口:控制类型输出

通过实现 slog.LogValuer 接口,你可以精确控制自定义类型在日志中的显示方式。

  • 隐藏敏感数据: 你可以利用此接口来隐藏结构体中的敏感字段(如密码哈希),确保只有安全的信息被记录,例如只记录用户 ID。
  • 延迟计算: 可以将昂贵的计算操作封装在 LogValuer 中,确保只有在日志级别启用时,LogValue() 方法才会被调用,从而避免不必要的性能开销。
type Token string
// LogValue 实现 slog.LogValuer 接口,避免泄露秘密
func (Token) LogValue() slog.Value {
    return slog.StringValue("REDACTED_TOKEN")
}
// 记录时,Token 值将被替换为 "REDACTED_TOKEN"

3. 错误日志记录

slog 中记录错误时,应使用 slog.Any() 来包含错误值。

err := errors.New("payment gateway unreachable")
logger.Error("Payment processing failed", slog.Any("error", err))

如果使用自定义错误类型,可以实现 LogValuer 接口来提供结构化的错误信息(如错误码和原因),这对生产系统的分析非常有价值。

堆栈跟踪 (Stack Trace) 捕获: slog 没有内置捕获堆栈跟踪的功能。你需要集成第三方包(如 go-xerrors)并使用 HandlerOptions 中的 ReplaceAttr() 函数,来提取、格式化和添加堆栈跟踪信息到日志记录中。

slog 性能考量与最佳实践

性能概述

尽管 slog 的设计考虑了性能,但在某些基准测试中,它仍然比高度优化的第三方库(如 zerologzap)慢。这种性能差异是设计权衡的结果,Go 团队的优化集中在最常见的日志模式(超过 95% 的调用属性少于 5 个)。

性能优化策略:

  • 预格式化属性: 使用 Logger.With 添加通用属性,Handler 只会格式化一次,从而提高速度。
  • 避免反射和分配: 尽可能使用 logger.LogAttrs,因为它只接受 Attr 类型,可以最小化内存分配,实现最高效的日志输出。
  • 延迟计算: 使用 logger.Enabled()LogValuer 接口,确保昂贵的计算操作仅在日志级别启用时才执行。

可观测性与日志处理

  1. 中央化日志系统: 将结构化日志从服务器发送到中央可观测性平台(如 Dash0)至关重要。
  2. 日志关联性: 为了实现真正的可观测性,日志条目必须与分布式追踪等其他遥测信号共享公共上下文(如 TraceID)。这可以通过集成 OpenTelemetry 的 otelslog bridge 来实现。
  3. 日志规范化: 实施 LogValuer 接口以标准化应用程序中自定义类型的日志表示,并确保敏感数据被省略。
  4. Linting 规范: 由于 slog 允许混合键值对和 slog.Attr 两种风格,为保证代码库一致性,应使用 sloglint 等 Linter 工具来强制执行日志风格规则(例如,强制只使用 Attr)。
  5. 日志采样: 在高流量环境中,使用日志采样(如 slog-sampling)仅记录具有代表性的子集,以控制数据摄取成本。
  6. 本地持久化: 最佳实践通常是将日志输出到 stdout/stderr,让运行时环境(如 Docker 或 Systemd)或专用的日志转发器(如 Vector, Fluentd)负责收集和持久化。将日志先写入本地文件可以提供备份和缓冲,以防集中式日志管理系统出现问题。

总结: log/slog 是 Go 生态系统的一个重要里程碑,它提供了一个强大的基础,支持开箱即用地构建高度可观测的系统。通过理解 LoggerHandlerRecord 的核心概念,并遵循结构化日志的最佳实践,你可以将服务从不透明的黑盒转变为透明、易于诊断和故障排除的系统。


也可以看看