Go使用Slog打结构化日志的做法:分级、上下文字段与自定义Handler实战

作者:袖梨 2026-08-10

线上出了问题,你 grep 日志想按 user_id 把一次请求的所有日志串起来,结果发现日志长这样:

Go使用Slog打结构化日志的方法:分级、上下文字段与自定义Handler实战

2026/08/07 09:12:33 order created for user 8812 amount 199.002026/08/07 09:12:33 payment failed: gateway timeout

第二行根本不知道是哪个用户、哪个订单。用 fmt.Sprintf 拼出来的日志,机器没法解析,人也串不起来。Go 1.21 把 log/slog 收进标准库,专治这个病。这篇手把手把它用明白。

先看朴素写法的问题

老代码通常是这样:

log.Printf("order created for user %d amount %.2f", userID, amount)

问题有三个:字段名和值混在一句话里,日志系统(Loki、ELK)无法结构化索引;级别只有一个,线上想只看 error 得靠 grep;想给一次请求的所有日志都带上 request_id,得每行手动拼。

slog 三分钟上手

slog 的核心是 key-value 结构化输出。默认给你一个文本 Handler:

package mainimport ("log/slog""os")func main() {// JSONHandler 直接吐 JSON,喂给日志采集器最省事logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))logger.Info("order created",slog.Int("user_id", 8812),slog.Float64("amount", 199.00),)}

输出:

{"time":"2026-08-07T09:12:33Z","level":"INFO","msg":"order created","user_id":8812,"amount":199}

现在 user_id 是独立字段,采集器能直接按它建索引、做聚合。slog.Int/slog.Float64 这类强类型辅助函数比裸传 "user_id", 8812 更快(避免 any 装箱),高频日志路径上值得用。

也可以设成全局默认 logger,然后直接用包级函数:

slog.SetDefault(logger)slog.Warn("gateway slow", "latency_ms", 820) // 无需再传 logger

分级:让线上只看你想看的

slog 内置 Debug/Info/Warn/Error 四级。生产环境通常只想要 Info 以上,本地调试才开 Debug。通过 HandlerOptions.Level 控制:

opts := &slog.HandlerOptions{Level: slog.LevelInfo, // 低于 Info 的直接丢弃,零开销}logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))logger.Debug("cache hit", "key", "u:8812") // 不会输出logger.Error("payment failed", "err", "gateway timeout")

想运行时动态调级别(比如线上临时开 Debug 排查),用 slog.LevelVar:

var lvl = new(slog.LevelVar) // 默认 Infolvl.Set(slog.LevelInfo)logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))// 收到信号后热切到 Debug,不用重启进程lvl.Set(slog.LevelDebug)

LevelVar 内部用原子操作,并发调 Set/Level 是安全的。

上下文字段:把一次请求的日志串起来

这才是结构化日志真正省事的地方。用 With 派生一个带固定字段的子 logger,之后每条日志自动带上:

func handleOrder(reqID string, userID int) {// 派生一次,后面所有日志都带 request_id 和 user_idlog := slog.With(slog.String("request_id", reqID),slog.Int("user_id", userID),)log.Info("order created", "amount", 199.00)log.Error("payment failed", "err", "gateway timeout")}

两行日志都会带上 request_id,线上直接按它一 grep 就把整条链路捞出来了。With 返回的是新 logger,不改原来的,并发安全。

配合 context.Context 跨函数传递更自然,用 InfoContext 系列方法:

type ctxKey struct{}func withLogger(ctx context.Context, l *slog.Logger) context.Context {return context.WithValue(ctx, ctxKey{}, l)}func loggerFrom(ctx context.Context) *slog.Logger {if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {return l}return slog.Default()}func chargeUser(ctx context.Context) {// 从 ctx 拿到带 request_id 的 logger,深层函数也能续上同一条链路loggerFrom(ctx).InfoContext(ctx, "charging")}

分组:给字段加命名空间

字段多了容易撞名(两个子系统都叫 id)。用 WithGroupslog.Group 加前缀:

logger.Info("request done",slog.Group("http",slog.String("method", "POST"),slog.Int("status", 200),),)// JSON: "http":{"method":"POST","status":200}

自定义 Handler:脱敏与字段改写

真实项目里常有两个需求:密码/token 不能进日志,时间字段要换格式。用 HandlerOptions.ReplaceAttr 在写出前拦截每个字段:

opts := &slog.HandlerOptions{ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {// 把 password 字段的值统一抹掉if a.Key == "password" {return slog.String("password", "***")}// 把内置 time 字段换成 Unix 秒if a.Key == slog.TimeKey && len(groups) == 0 {return slog.Int64("ts", a.Value.Time().Unix())}return a},}logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))logger.Info("login", "user", "amy", "password", "hunter2")// {"ts":1754557953,"level":"INFO","msg":"login","user":"amy","password":"***"}

ReplaceAttr 每个字段都会走一遍,高频路径上别写太重的逻辑。返回一个 Key 为空的 Attr 可以彻底丢弃该字段。

一个容易踩的坑:奇数个参数

slog 支持 logger.Info("msg", "key", val) 这种松散写法,但如果 key-value 个数对不上(漏了一个值),它不会 panic,而是把落单的那个当成一个特殊的 !BADKEY 字段:

logger.Info("oops", "user_id") // 少了值// ...,"!BADKEY":"user_id"}  —— 静默出错,不易发现

高频或关键日志建议一律用 slog.Int/slog.String 这类强类型形式,既快又不会漏配对。可以用 go vet(启用 sloglint 或第三方 linter)在 CI 里把松散写法的配对问题挡住。

小结

  1. slog 是 Go 1.21+ 标准库,输出 结构化 key-value,机器可索引、人可串链路,取代 log.Printf 拼字符串。
  2. JSONHandler 喂日志采集器;HandlerOptions.LevelLevelVar运行时热调级别,不用重启。
  3. With / slog.With 派生带固定字段的子 logger,把 request_id 一次带上,是串联一次请求所有日志的关键。
  4. ReplaceAttr脱敏和字段改写;强类型 slog.Int 等既快又避免 !BADKEY 静默配对错误。
  5. 记忆点:别再 Printf 拼日志了——把值当字段传,让机器帮你串链路。

相关文章

精彩推荐