Golang 标准库:slog

slog 是 Go 1.21 加入标准库的结构化日志包,为 Go 提供了统一的日志 API、日志级别、结构化字段和 Handler 扩展机制。
PS: golang.org/x/exp/slog 转正,变成了 log/slog

对于新项目,推荐直接使用 log/slog;对于已有 zaplogrus 等日志系统,也可以通过逐步迁移的方式引入,不需要一次性重构。

一、核心模型:Logger + Handler + Record

slog 的核心由三个部分组成:

  • Logger:业务代码使用的日志 API。
  • Handler:决定日志如何处理和输出。
  • Record:一条完整的日志记录。

最常见的使用方式:

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

logger.Info("user login",
    "user_id", 123,
)

一条日志可以理解为:

time + level + message + attributes

例如:

{
    "time": "2026-08-26T01:00:00Z",
    "level": "INFO",
    "msg": "user login",
    "user_id": 123
}

slog 提供了包级函数:

slog.Info(...)
slog.Warn(...)
slog.Error(...)

这些函数实际上使用的是 slog.Default()

如果需要自定义 Logger,则使用:

logger := slog.New(handler)

工程项目中建议将 Logger 作为依赖传递,而不是让业务代码大量依赖全局 slog

二、日志级别

slog 内置四个标准级别:

Debug (-4)
Info  ( 0)
Warn  ( 4)
Error ( 8)

默认级别是 Info,因此 Debug 日志默认不会输出。

可以通过 HandlerOptions 设置:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level:     slog.LevelInfo,
    AddSource: true,
})

生产环境通常建议:

Debug → 默认关闭
Info  → 正常业务日志
Warn  → 异常但可以继续运行
Error → 操作失败或需要关注的问题

不要滥用 Error。例如用户输入错误、资源不存在等通常属于业务层面的 Info/Warn,而不是系统错误。

三、生产环境推荐配置

Web 服务、微服务等后端程序,推荐:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level:     slog.LevelInfo,
    AddSource: true,
})

logger := slog.New(handler)

slog.SetDefault(logger)

生产环境推荐:

应用
 ↓
JSON
 ↓
stdout/stderr
 ↓
Docker / Kubernetes
 ↓
日志采集系统
 ↓
Loki / Elasticsearch / ClickHouse / 云日志平台

不要让业务程序自己承担完整的日志基础设施职责。

特别是在 Kubernetes 环境中,应用通常只需要输出到 stdout/stderr,日志采集、存储、轮转和生命周期管理交给平台。

写入文件

package main

import (
    "log/slog"
    "os"
)

func main() {
    f, err := os.Create("app.log")
    if err != nil {
        panic(err)
    }
    defer f.Close()
    logger := slog.New(slog.NewJSONHandler(f, nil))
    slog.SetDefault(logger)
    slog.Info("greeting", "say", "hello")
}

四、不要过度使用 ReplaceAttr

slog.HandlerOptions.ReplaceAttr 可以修改日志字段,例如:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        // 修改字段
        return a
    },
})

它适合做统一的日志 Schema 转换,例如:

  • 修改字段名称
  • 删除敏感字段
  • 修改 source 格式
  • 对 level 做统一映射
  • 对特定字段做脱敏

但是不要为了“格式好看”而大量修改标准字段。

尤其是时间字段,JSONHandler 已经能够提供标准化的时间表示。如果没有兼容现有日志平台的实际需求,没有必要重新格式化。

五、结构化日志的三个核心 API

1. With:注入公共字段

对于整个服务、模块或者请求生命周期都不会变化的字段,可以使用 With

logger := slog.Default().With(
    "service", "order",
    "version", "1.0.0",
)

之后:

logger.Info("create order")

自动包含:

{
    "service": "order",
    "version": "1.0.0",
    "msg": "create order"
}

适合:

service
version
env
host
module

2. Group:组织复杂字段

例如 HTTP 请求:

logger.Info("request",
    slog.Group("http",
        slog.String("method", "GET"),
        slog.String("path", "/users"),
        slog.Int("status", 200),
    ),
)

JSON 可以形成嵌套结构:

{
    "msg": "request",
    "http": {
        "method": "GET",
        "path": "/users",
        "status": 200
    }
}

对于复杂日志 Schema,Group 比简单堆积大量字段更加清晰。

WithGroup

WithGroup 返回带命名空间的子 Logger,后续字段自动归入该分组,适合组件级归类(db/http/kafka)。slog.Group 仅单次日志内联嵌套,而 WithGroup 是持久命名空间,一次定义、全局生效,日志查询时按分组过滤更高效。

// 创建带 db 分组的子 Logger
dbLogger := slog.WithGroup("db")
// 后续所有日志自动归入 db 命名空间
dbLogger.Info("query finished",
    "table", "orders",
    "rows", 12,
    "cost_ms", 45,
)
// WithGroup 输出 — 字段归入 db 对象
// {"time":"...","level":"INFO","msg":"query finished","db":{"table":"orders","rows":12,"cost_ms":45}}

// 对比:平铺 With 方式
flatLogger := slog.With("table", "orders")
flatLogger.Info("query finished", "rows", 12)
// With 输出 — 字段平铺
// {"time":"...","level":"INFO","msg":"query finished","table":"orders","rows":12}

3. LogAttrs:高频日志使用

slog 同时支持:

logger.Info("request", "status", 200)

和:

logger.LogAttrs(
    ctx,
    slog.LevelInfo,
    "request",
    slog.Int("status", 200),
)

LogAttrs 直接接收 slog.Attr,可以减少 key-value 参数转换带来的部分开销。

因此在:

  • 高频日志
  • 热循环
  • 高吞吐网关
  • 性能敏感服务

中,可以优先考虑 LogAttrs

但是不要把“LogAttrs 更快”理解成绝对结论。实际性能取决于 Handler、字段类型、日志级别以及是否真的产生日志,是否有性能收益应该通过 benchmark 验证。

业务普通日志优先考虑可读性,不要为了微小性能收益到处使用 LogAttrs

六、Context 不会自动产生 TraceID

这是使用 slog 时非常容易误解的一点。

slog.InfoContext(ctx, "request")

并不会自动把:

ctx.Value("trace_id")

写入日志。

Context 在这里主要用于向 Handler 传递上下文信息。

如果希望自动增加 TraceID,可以实现一个包装 Handler:

type ContextHandler struct {
    slog.Handler
}

func (h *ContextHandler) Handle(
    ctx context.Context,
    r slog.Record,
) error {
    if traceID, ok := ctx.Value("trace_id").(string); ok {
        r.AddAttrs(
            slog.String("trace_id", traceID),
        )
    }

    return h.Handler.Handle(ctx, r)
}

请求链路可以设计为:

HTTP Request
     ↓
提取/生成 TraceID
     ↓
context.WithValue
     ↓
业务函数
     ↓
slog.InfoContext(ctx, ...)
     ↓
ContextHandler
     ↓
JSONHandler

这样日志平台可以通过:

trace_id = xxx

查询整个请求链路。

七、Context 不应该成为“万能参数袋”

虽然可以把 TraceID、RequestID 等信息放入 Context,但不要把业务对象、配置、数据库连接等大量数据全部塞进去。

建议 Context 主要承担:

  • 请求生命周期
  • deadline / cancellation
  • tracing
  • 少量 request-scoped metadata

而业务依赖应该明确通过参数或者依赖注入传递。

例如:

type UserService struct {
    logger *slog.Logger
}

func NewUserService(logger *slog.Logger) *UserService {
    return &UserService{
        logger: logger,
    }
}

这样比在每个函数里调用:

slog.Default()

更加容易测试和维护。

八、业务代码不要到处使用全局 slog

不推荐:

func CreateOrder() {
    slog.Info("create order")
}

更推荐:

type OrderService struct {
    logger *slog.Logger
}

func (s *OrderService) CreateOrder(ctx context.Context) {
    s.logger.InfoContext(ctx, "create order")
}

初始化时:

logger := slog.Default().With(
    "service", "order",
)

service := NewOrderService(logger)

这样做的收益包括:

  • 依赖显式
  • 方便单元测试
  • 方便替换 Handler
  • 可以注入 service/module 字段
  • 避免业务代码依赖全局状态

包级 slog.Info() 等 API 可以主要用于 main、启动过程以及非常简单的基础设施代码。

九、错误日志

推荐统一使用 err 字段:

logger.ErrorContext(
    ctx,
    "create order failed",
    "order_id", orderID,
    "err", err,
)

slog 可以处理 error 类型,因此通常没有必要主动:

err.Error()

如果日志平台要求 err 必须是字符串,再使用:

slog.String("err", err.Error())

关键不是“error 一定应该使用哪一种”,而是整个项目保持统一的日志 Schema

例如统一:

err
error_code
operation

而不要一个模块使用:

error

另一个使用:

exception

第三个又使用:

message

十、LevelVar:动态调整日志级别

如果希望生产环境动态打开 Debug,可以使用 slog.LevelVar

var level slog.LevelVar

level.Set(slog.LevelInfo)

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

之后可以动态:

level.Set(slog.LevelDebug)

因此可以将它和:

  • 配置中心
  • Admin API
  • 运维控制台
  • 动态配置系统

结合,实现:

线上问题
    ↓
临时开启 Debug
    ↓
复现问题
    ↓
关闭 Debug

无需重新编译和发布服务。

生产环境应避免长期保持 Debug,否则容易产生大量无效日志和额外成本。

十一、日志轮转交给基础设施

Kubernetes

推荐:

Application
    ↓
stdout
    ↓
Container Runtime
    ↓
Log Collector
    ↓
Log Storage

应用不需要自己实现日志文件轮转。

裸机 / VM

如果应用确实需要直接写文件,可以使用:

  • Linux 日志轮转工具 logrotate
  • Golang 日志轮转库 lumberjack
  • 系统日志服务

例如 lumberjack

r := &lumberjack.Logger{
    Filename:   "./foo.log",
    LocalTime:  true,
    MaxSize:    1,
    MaxAge:     3,
    MaxBackups: 5,
    Compress:   true,
}
logger := slog.New(slog.NewJSONHandler(r, nil))
slog.SetDefault(logger)

十二、多路输出

slog 的 Handler 是可组合和可扩展的,但不同 Handler 需要不同格式时,不能简单使用 io.MultiWriter

例如:

Console → TextHandler
File    → JSONHandler

这时需要实现 fan-out Handler,让同一个 Record 分别交给多个 Handler。

如果只是:

stdout → JSON
file   → JSON

则可以考虑通过 Writer 层进行复制。

生产环境通常不建议应用同时维护 Console + File 两套日志。优先考虑:

Application → stdout → Log Collector

只有在明确的基础设施需求下才让应用直接写文件。

十三、敏感信息必须脱敏

结构化日志最大的优势也是风险来源。

因为业务代码可以非常方便地记录:

logger.Info("request",
    "user", user,
)

如果 user 中包含:

password
token
authorization
cookie
email
phone
身份证号
银行卡号

就可能造成敏感数据泄露。

因此生产系统应建立统一日志规范:

禁止记录:
password
access_token
refresh_token
Authorization
Cookie
完整银行卡号
完整身份证号

允许记录:
user_id
request_id
trace_id
脱敏后的手机号
脱敏后的邮箱

必要时可以通过 ReplaceAttr 做统一脱敏,但最优方案仍然是业务代码从源头避免记录敏感数据

十四、日志应该服务于可观测性

不要把日志当成“程序运行过程的文字记录”。

现代服务中,日志应该与:

Metrics
Tracing
Logs

共同构成 Observability。

例如一个 HTTP 请求:

trace_id
request_id
service
method
path
status
latency
user_id
error

可以让日志与 Trace、Metrics 互相关联。

推荐形成统一 Schema:

{
    "time": "...",
    "level": "INFO",
    "service": "order",
    "trace_id": "xxx",
    "request_id": "xxx",
    "method": "POST",
    "path": "/orders",
    "status": 200,
    "latency": 35,
    "msg": "request completed"
}

这样日志系统才能真正成为可查询的数据源,而不是简单的文本文件。

十五、与 zap / logrus 的关系

如果是新项目,我倾向于 slog,主要原因不是 slog 一定比 zapzerolog 更快,而是:

  • 标准库
  • API 稳定
  • 第三方依赖更少
  • Go 生态逐渐围绕 slog 建立统一接口
  • Handler 可以扩展
  • 方便替换输出实现

如果现有项目已经大量使用 zap,没有必要为了 slog 强行重写。

更合理的迁移方式是:

旧代码
  ↓
继续使用 zap
  ↓
新代码使用 slog
  ↓
逐步迁移
  ↓
最终统一

是否迁移应该考虑实际收益,而不是单纯追求“全部标准库化”。

十六、推荐的团队标准

如果让我给一个 Go Web 服务制定日志规范,我会采用下面这套基线:

  • 日志库:统一使用标准库 log/slog
  • 输出格式:生产环境统一使用 JSON;时间、level、source、msg 必备;加请求上下文但禁止明文打敏感信息(用 LogValuer 脱敏更高效)。
  • 输出位置:容器环境统一输出到 stdout/stderr,不由应用负责日志轮转(万一必须打到日志文件,必须配轮转 + 清理,防磁盘打满)。
  • 默认级别:生产环境默认使用 InfoDebug 默认关闭。
  • 日志级别:合理使用 DebugInfoWarnError,不要滥用 Error
  • 公共字段:使用 Logger.With() 注入 serviceversionenv 等公共字段。
  • 结构化字段:优先使用结构化属性,不要拼接日志字符串。
  • 嵌套字段:复杂对象使用 slog.Group() 组织。
  • 请求上下文:使用 InfoContext()ErrorContext() 等 API 关联 context.Context
  • TraceID:通过 Context + 自定义 Handler 自动注入 trace_id
  • RequestID:需要时统一注入 request_id,避免各业务模块自行生成。
  • 错误字段:统一使用 err 表示错误,保持日志 Schema 一致。
    在错误日志里塞一些结构化的、有业务语义的 key-value(比如业务模块 biz_module、错误类型 error_type 等),让监控系统能自动识别、分类、统计和告警,而不是只给人肉眼去翻日志。
  • 敏感信息:禁止记录密码、Token、Cookie、Authorization 等敏感信息。
  • 业务日志:避免业务代码直接依赖全局 slog,优先通过依赖注入传递 *slog.Logger
  • 高频日志:性能敏感场景可以使用 LogAttrs(),是否有收益以 Benchmark 为准。
  • 动态级别:需要线上动态调整日志级别时使用 slog.LevelVar
  • 日志轮转:Kubernetes 等容器环境交给日志基础设施;裸机使用 logrotate 等工具。
  • 多路输出:只有存在明确需求时才实现多 Handler 输出,避免应用同时维护 Console/File 两套日志。
  • 可观测性:日志应与 Metrics、Tracing 通过 trace_idrequest_id 等字段关联。
  • 日志 Schema:统一字段命名,不允许不同模块随意定义 errorerrexception 等同义字段。
  • 性能原则:不要为了理论上的日志性能优化牺牲业务代码可读性,先 Benchmark,再优化。

十七、结论

slog 的核心价值并不是“又一个日志库”,而是为 Go 建立了一套标准化的结构化日志抽象

真正值得采用的不是简单地把 log.Printf 替换成 slog.Info,而是建立完整的日志工程体系:

结构化 Schema
      +
统一字段
      +
Context / Trace
      +
统一 Handler
      +
动态 Level
      +
基础设施采集
      +
日志脱敏
      +
Metrics / Tracing

对于绝大多数 Go 后端服务,我的建议是:

优先使用 slog + JSON + stdout + Context/TraceID + Logger 依赖注入

只有在明确存在性能、生态或特殊 Handler 需求时,再考虑引入 zapzerolog 等第三方日志库。

如果你有魔法,你可以看到一个评论框~