备忘用系统性笔记。忘了直接翻这一篇。

Go 标准库有两个日志相关的包:

  • log:Go 1.0 就有,简单、无级别、纯文本。
  • log/slog:Go 1.21 引入,结构化日志,带级别、Handler、上下文。

一句话区分:log 适合小工具/起步,slog 适合要采集、要过滤、要结构化字段的服务。


一、标准库 log

1.1 零配置即用

go
1import "log"
2
3func main() {
4    log.Print("hello")            // 2026/10/05 17:00:00 hello
5    log.Printf("port=%d", 8080)   // 2026/10/05 17:00:00 port=8080
6    log.Println("a", "b")         // 2026/10/05 17:00:00 a b
7}

默认输出到 stderr,默认前缀是日期+时间(LstdFlags = Ldate | Ltime)。

1.2 会退出的两个函数

go
1log.Fatal("boom")     // 打印后调用 os.Exit(1)
2log.Fatalf("boom %v", err)
3log.Panic("boom")     // 打印后 panic

注意:Fatal 内部是 os.Exit,不会执行 defer。想知道区别可以记:

函数行为
Print*只打印
Panic*打印 + panic(会走 defer,可被 recover)
Fatal*打印 + os.Exit(1)(不走 defer)

1.3 配置输出目标 / 前缀 / 格式

go
1log.SetOutput(os.Stdout)                       // 改输出流
2log.SetPrefix("[tabapp] ")                     // 前缀
3log.SetFlags(log.LstdFlags | log.Lshortfile)   // 日期时间 + 短文件名:行号

flag 可组合:

flag含义
log.Ldate2026/10/05
log.Ltime17:00:00
log.Lmicroseconds17:00:00.000000
log.Llongfile完整路径 + 行号
log.Lshortfile文件名 + 行号
log.LUTC时间用 UTC(默认本地时区)
log.Lmsgprefix把前缀放到消息前,而非行首
log.LstdFlags等于 `Ldate

1.4 自定义 Logger(多实例)

go
1logger := log.New(os.Stdout, "API: ", log.LstdFlags|log.Lshortfile)
2logger.Printf("request %s", r.URL.Path)

log.New(out io.Writer, prefix string, flag int) *log.Logger。这样每个模块可以有独立的输出流和前缀。

1.5 log 的局限

  • 没有日志级别:只能靠字符串前缀 [ERROR] 自己约定,无法按级别过滤。
  • 非结构化:只能拼字符串,机器难解析。
  • 没有公共字段:每条日志都要重复拼 service=xxx。
  • 没有上下文:无法把 request_id、trace_id 自动带上。

这些正是 slog 要解决的。


二、log/slog

2.1 设计模型(最重要的图)

slog 分四层:

text
 1        ┌────────────┐
 2调用 →  │   Logger   │  接收调用,组装 Record(时间、Level、msg、Attrs)
 3        └─────┬──────┘
 4              ▼
 5        ┌────────────┐
 6        │  Handler   │  决定“怎么输出/写到哪/要不要输出”(级别过滤在此)
 7        └─────┬──────┘
 8              ▼
 9        ┌────────────┐
10        │  Record    │  一次日志的数据
11        └─────┬──────┘
12              ▼
13        ┌────────────┐
14        │   Attr     │  键值对
15        └────────────┘
  • Logger:日志入口。
  • Handler:真正干活的地方,负责格式化、过滤、写出。内置 TextHandler 和 JSONHandler,也可以自定义。
  • Record:一条日志记录。
  • Attr:一个键值对字段。

关键点:slog.Info(...) 这类包级函数用的是全局默认 Logger,级别过滤发生在 Handler。

2.2 快速上手

go
 1import "log/slog"
 2
 3func main() {
 4    // 1. 选一个 Handler + 输出目标
 5    handler := slog.NewJSONHandler(os.Stdout, nil)
 6    // 2. 包装成 Logger
 7    logger := slog.New(handler)
 8    // 3. 设为全局默认(之后 slog.Info 等就用它)
 9    slog.SetDefault(logger)
10
11    slog.Info("配置加载成功", "app_name", "tabapp", "port", 8080)
12}

输出:

json
1{"time":"2026-10-05T17:00:00.000+08:00","level":"INFO","msg":"配置加载成功","app_name":"tabapp","port":8080}

字段说明:time / level / msg 是 Handler 自动加的,其余是你传的键值对。

2.3 级别(Level)

go
1slog.Debug("debug")   // -4
2slog.Info("info")     //  0
3slog.Warn("warn")     //  4
4slog.Error("error")   //  8

底层就是 int,越大越严重。级别最核心的作用是过滤:

text
1record.Level >= 阈值  → 输出
2record.Level <  阈值  → 整条丢弃(连字段都不会出现)

阈值默认是 Info:所以默认情况下 Debug 日志完全不输出。

2.4 结构化字段

go
1slog.Info("login",
2    "user", "alice",
3    "id", 42,
4    "ok", true,
5    "elapsed", 120*time.Millisecond,
6)

规则:

  • 第一个参数是 msg。
  • 后面是成对的 key/value,数量必须为偶数。
  • 若某个参数本身是 slog.Attr,会直接使用。
  • key 不是字符串或参数不成对时,slog 会产生 !BADKEY。

也可以用强类型构造避免拼写/成对错误:

go
1slog.LogAttrs(ctx, slog.LevelInfo, "login",
2    slog.String("user", "alice"),
3    slog.Int("id", 42),
4    slog.Bool("ok", true),
5    slog.Duration("elapsed", 120*time.Millisecond),
6)

slog.Attr 构造函数一览:

构造函数类型
slog.String(key, string)字符串
slog.Int(key, int) / slog.Int64整数
slog.Uint64(key, uint64)无符号
slog.Float64(key, float64)浮点
slog.Bool(key, bool)布尔
slog.Time(key, time.Time)时间
slog.Duration(key, time.Duration)时长
slog.Any(key, v)任意值
slog.Group(key, args...)嵌套分组

2.5 公共字段:With / WithGroup

每条日志重复写 service、request_id 很烦,用 With 一次性绑定:

go
1logger := slog.Default().With(
2    "service", "tabapp",
3    "version", "1.0.0",
4)
5logger.Info("started")          // 自动带 service/version
6logger.Warn("slow request")     // 自动带 service/version

WithGroup 把字段收进一个命名空间:

go
1logger.WithGroup("http").Info("request", "method", "GET", "path", "/health")

Text 输出约为 http.method=GET http.path=/health;JSON 输出为嵌套对象 {"http":{"method":"GET","path":"/health"}}。

2.6 Handler:Text 还是 JSON

go
1// 人类友好,开发环境用
2slog.NewTextHandler(os.Stderr, nil)
3// 输出:time=2026-10-05T17:00:00.000+08:00 level=INFO msg=started port=8080
4
5// 机器友好,生产/采集用
6slog.NewJSONHandler(os.Stdout, nil)
7// 输出:{"time":"...","level":"INFO","msg":"started","port":8080}

2.7 HandlerOptions

go
1&slog.HandlerOptions{
2    AddSource: true,                 // 加 source(文件:行号),有性能开销
3    Level:     slog.LevelDebug,      // 过滤阈值
4    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
5        // 每条属性都会回调,可改 key/value,或返回零值删除
6        return a
7    },
8}

Level 字段

类型是接口 slog.Leveler,凡是实现了 Level() Level 的都能传。常用两种:

go
1Level: slog.LevelWarn     // 固定值
2Level: levelVar           // *slog.LevelVar,可运行时改

AddSource

go
1AddSource: true
2// JSON: {"source":{"function":"main.main","file":"/app/main.go","line":42}}

开启后能定位日志来源,但会增加开销(每次要取调用栈),生产环境按需开。

ReplaceAttr(万能出口)

内置字段 time / level / msg / source 都会经过这个回调,可用来改格式或删字段:

go
 1ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
 2    // 只处理顶层
 3    if len(groups) > 0 {
 4        return a
 5    }
 6    switch a.Key {
 7    case slog.TimeKey:
 8        // 时间转毫秒 + UTC
 9        return slog.String(slog.TimeKey,
10            a.Value.Time().UTC().Format("2006-01-02T15:04:05.000Z07:00"))
11    case slog.LevelKey:
12        // 级别改小写
13        return slog.String(slog.LevelKey,
14            strings.ToLower(a.Value.String()))
15    }
16    return a
17}

删除某个字段就返回零值 slog.Attr{}。

2.8 LevelVar:动态调级

级别常常要等读完配置才知道,LevelVar 支持运行时修改,且并发安全:

go
1level := new(slog.LevelVar)
2level.Set(slog.LevelInfo)
3
4slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
5    Level: level,
6})))
7
8// 之后任意时刻可改,立即生效
9level.Set(slog.LevelDebug)

典型用法:先默认 Info,配置里 mode: debug 时降到 Debug。

2.9 带 context 的版本

有 ctx 时用带 Context 的函数(便于接入 trace):

go
1slog.InfoContext(ctx, "handled", "path", r.URL.Path)
2slog.ErrorContext(ctx, "failed", "err", err)
3slog.LogAttrs(ctx, slog.LevelWarn, "slow", slog.Duration("took", d))

注意:内置 Handler 不会自动读取 ctx 里的值,需要自定义 Handler 才能把 trace_id 之类提取出来。

2.10 LogAttrs:性能优先

LogAttrs 只接受 slog.Attr,避免了 ...any 装箱,热路径推荐:

go
1logger.LogAttrs(ctx, slog.LevelInfo, "query",
2    slog.String("sql", stmt),
3    slog.Int("rows", n),
4)

配合 Enabled 避免昂贵参数计算:

go
1if logger.Enabled(ctx, slog.LevelDebug) {
2    logger.Debug("dump", "data", expensiveDump())
3}

2.11 与 log 互操作

把 log 的输出接到 slog

go
1slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
2slog.SetLogLoggerLevel(slog.LevelDebug) // log.Print 会以 Debug 级走 slog handler

调用 SetDefault 后,log.Print 等默认输出会转交给 slog 的 Handler;SetLogLoggerLevel 控制这些输出的级别。

让 slog 输出成一个 *log.Logger

go
1handler := slog.NewJSONHandler(os.Stdout, nil)
2stdLogger := slog.NewLogLogger(handler, slog.LevelInfo)
3stdLogger.Printf("via log") // 走 slog handler

2.12 丢弃日志 / 多路输出

go
1// 完全丢弃(测试或临时静音)
2slog.SetDefault(slog.New(slog.DiscardHandler))
3
4// 同时写文件和控制台(新版 Go)
5h := slog.NewMultiHandler(
6    slog.NewJSONHandler(file, nil),
7    slog.NewTextHandler(os.Stderr, nil),
8)
9slog.SetDefault(slog.New(h))

2.13 自定义 Handler(了解即可)

想要特殊行为(脱敏、分流告警、注入 trace)时,实现接口:

go
1type Handler interface {
2    Enabled(context.Context, Level) bool
3    Handle(context.Context, Record) error
4    WithAttrs(attrs []Attr) Handler
5    WithGroup(name string) Handler
6}

通常更省事的做法是「包一层」内置 Handler,或在 ReplaceAttr 里做字段级处理。


三、log vs slog 对比

维度loglog/slog
引入版本1.01.21
输出形态纯文本结构化(Text / JSON)
日志级别无Debug/Info/Warn/Error
级别过滤不支持支持(Handler 阈值)
公共字段手动拼With / WithGroup
上下文无*Context / LogAttrs
可插拔仅换 io.WriterHandler 接口,可自定义
Fatal有无(需自己 Error + os.Exit)
适用小工具、脚本服务、需要采集/过滤

选型建议:

  • 学习、一次性脚本、极简工具 → log 足够。
  • 线上服务、要接 ELK/Loki、要按级别过滤、要结构化查询 → slog。
  • 已有大量 log 代码不想全改 → 用 SetDefault + SetLogLoggerLevel 让 log 转发到 slog。

四、实战:本项目 main.go 的 slog 配置

go
 1func main() {
 2    // 结构化日志统一走 JSON,便于采集(stdout)。
 3    // level 用 LevelVar,方便读到配置后按 mode 动态调整。
 4    level := new(slog.LevelVar)
 5    level.Set(slog.LevelInfo)
 6    slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
 7        Level: level,
 8    })))
 9
10    if err := run(level); err != nil {
11        // slog 没有内置 Fatal,这里手动 Error + os.Exit。
12        slog.Error("启动失败", "err", err)
13        os.Exit(1)
14    }
15}
16
17func run(level *slog.LevelVar) error {
18    // ... 解析 flag / 查找配置 ...
19
20    cfg, err := config.Load(path)
21    if err != nil {
22        return err
23    }
24
25    if cfg.Server.Mode == "debug" {
26        level.Set(slog.LevelDebug) // 动态降到 Debug
27    }
28
29    slog.Info("配置加载成功",
30        "app_name", cfg.Server.AppName,
31        "port", cfg.Server.Port,
32        "mode", cfg.Server.Mode,
33        "source", path,
34    )
35    return nil
36}

要点:

  1. LevelVar 用指针,run 里改的是 handler 持有的同一个对象。
  2. SetDefault 让 slog.Info 等包级函数生效。
  3. slog 没有 Fatal,用 run() error + os.Exit(1) 统一处理。
  4. 用了 os.Stdout 而非 stderr,便于容器采集;如需改回只需换参数。

五、常见坑

  1. slog.New 后忘记 slog.SetDefault:slog.Info 仍用默认 logger,你的 Handler 不生效。要么 SetDefault,要么直接用返回的 logger.Info(...)。
  2. 级别比较方向记反:是 >= 阈值才输出。LevelWarn 时 Warn 本身会输出。
  3. Fatal 不存在:slog.Fatal 会编译不过,需 slog.Error(...) 后 os.Exit(1)。
  4. os.Exit 跳过 defer:清理逻辑要放在 os.Exit 之前,或封装到 run() 里。
  5. 键值参数不成对:输出里会出现 !BADKEY;用 LogAttrs + 强类型 Attr 可避免。
  6. AddSource 有性能开销:生产环境按需开启。
  7. 内置 Handler 不读取 ctx 的值:要注入 trace_id 必须自定义 Handler。
  8. ReplaceAttr 忘记判断 len(groups) == 0:会把嵌套分组里同名字段一起改掉。
  9. log.Fatal 与 slog 混用:转发后 log 的默认 flag 会被清空(SetDefault 内部会 log.SetFlags(0)),别再依赖它的时间戳格式。
  10. 输出流变化:log 默认写 stderr,JSON handler 常写 stdout,部署时注意 stdout/stderr 分流策略。

六、API 速查表

log

go
1log.Print / Printf / Println
2log.Fatal / Fatalf / Fatalln          // 打印后 os.Exit(1)
3log.Panic / Panicf / Panicln          // 打印后 panic
4log.Default() *Logger
5log.SetOutput(io.Writer)
6log.SetPrefix(string)
7log.SetFlags(int)
8log.New(out, prefix, flag) *Logger
9// flags: Ldate Ltime Lmicroseconds Llongfile Lshortfile LUTC Lmsgprefix LstdFlags

log/slog:创建与全局

go
1slog.New(handler) *Logger
2slog.NewTextHandler(w, opts) *TextHandler
3slog.NewJSONHandler(w, opts) *JSONHandler
4slog.NewMultiHandler(...Handler) *MultiHandler   // 新版
5slog.SetDefault(*Logger)
6slog.Default() *Logger
7slog.SetLogLoggerLevel(Level) Level
8slog.NewLogLogger(Handler, Level) *log.Logger
9slog.DiscardHandler                              // 丢弃

log/slog:记录日志

go
1slog.Debug / Info / Warn / Error(msg, args...)
2slog.DebugContext / InfoContext / WarnContext / ErrorContext(ctx, msg, args...)
3slog.Log(ctx, level, msg, args...)
4slog.LogAttrs(ctx, level, msg, attrs...)
5
6logger.Enabled(ctx, level) bool
7logger.With(args...) *Logger
8logger.WithGroup(name) *Logger
9logger.Handler() Handler

log/slog:类型与常量

go
1slog.LevelDebug = -4
2slog.LevelInfo  =  0
3slog.LevelWarn  =  4
4slog.LevelError =  8
5slog.LevelVar{}                      // Set / Level / String,并发安全
6
7slog.TimeKey / LevelKey / MessageKey / SourceKey
8
9slog.String / Int / Int64 / Uint64 / Float64 / Bool / Time / Duration / Any / Group

Handler 与选项

go
 1type Handler interface {
 2    Enabled(context.Context, Level) bool
 3    Handle(context.Context, Record) error
 4    WithAttrs([]Attr) Handler
 5    WithGroup(string) Handler
 6}
 7
 8slog.HandlerOptions{
 9    AddSource: bool,
10    Level:     slog.Leveler,
11    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr,
12}

七、一句话总结

  • log:简单,够用,但无级别、非结构化。
  • slog:结构化 + 级别过滤 + Handler 可插拔 + 上下文,是服务端日志的现代标准做法。
  • 级别首先决定「日志存不存在」,其次才决定「长什么样」。
  • 记住四件套:Logger / Handler / Record / Attr,其余都是围绕它们展开。