bigywater.tech
Go · 103

log/slog:结构化日志的 Handler 与性能

slog 把日志从“一行字符串”变成“带键值的记录”,由 Handler 决定输出格式。这篇看两种内置 Handler 的输出,验证被过滤的日志是不是真的几乎免费、AddSource 的代价、和标准库 log 的差距,以及并发写入会不会把日志行撕裂。

471 ns
JSONHandler.Info 带两个属性、输出到空 writer 的耗时,0 次分配
5 ns
被级别过滤掉的一条 Info 的耗时
2×
开启 AddSource 后一条日志的耗时倍数(931 ns)

输出格式

实测 · 本机执行输出
  TextHandler:
    level=INFO msg=请求完成 method=GET path="/a b" status=200 dur=1.5ms
    level=INFO msg=请求完成 req_id=7 http.status=200
  JSONHandler:
    {"level":"INFO","msg":"请求完成","method":"GET","path":"/a b","status":200,"dur":1500000}
    {"level":"INFO","msg":"请求完成","req_id":7,"http":{"status":200}}
    {"level":"INFO","msg":"缺值","!BADKEY":"orphan"}
  (上一行:key 后面没有配对的值,会变成 !BADKEY)

同一条日志,Text 是 key=value,分组变成 http.status=200;JSON 里分组是嵌套对象,time.Duration 被输出成纳秒整数 1500000。参数个数不配对时,孤零零的 key 变成 !BADKEY——静默地出现在日志里;不过 go vet 能在编译前发现(我另外试了 slog.Info("x", "orphan"),vet 报 call to slog.Info missing a final value,另一种把非字符串当 key 的写法也会被报)。

过滤与惰性求值

实测 · 本机执行输出
  Level=Info 时调用 Debug,带一个 LogValuer 参数:LogValue 被调用了 0 次(被过滤的日志不会计算它)
  调用 Info:LogValue 被调用了 1 次
  被过滤的 Debug:l.Debug(…,key,value 形式)0 次分配;l.LogAttrs(…,slog.Attr 形式)0 次分配
  输出到 io.Discard 的 Info:l.Info 形式 0 次分配;LogAttrs 形式 0 次分配

被级别过滤的日志:LogValuer 的 LogValue 一次都没被调用,这就是“昂贵的值只在真的要输出时才计算”。被过滤的 Info 只要约 5 ns。

性能

实测 · 本机执行输出
  slog JSONHandler.Info(msg, k, s)      471 ns/次   0 次分配
  slog TextHandler.Info(msg, k, s)      505 ns/次   0 次分配
  slog JSONHandler + AddSource          931 ns/次   6 次分配
  slog JSONHandler.LogAttrs             448 ns/次   0 次分配
  slog 被级别过滤的 Info                        5 ns/次   0 次分配
  标准库 log.Printf                        145 ns/次   0 次分配
  fmt.Fprintf(nopW)                      55 ns/次   0 次分配
  标准库 log.Printf(写到 io.Discard)           3 ns/次   0 次分配

写到空 writer,JSONHandler 一条日志 471 ns、0 次分配,TextHandler 505 ns;标准库 log.Printf 是 145 ns——结构化和时间格式化让 slog 约慢 3 倍。AddSource 要多取调用栈,耗时翻倍(931 ns)并多 6 次分配。需要源码位置再开。注意:标准库 log 写到 io.Discard 时会短路(3 ns),拿它做基准会得到误导性的数字,我一开始就用 io.Discard 测,所以后来换成了一个不会被短路的 writer。

并发

实测 · 本机执行输出
  8 个 goroutine 各写 2000 条 JSON 日志:共 16000 行,每行都是完整合法 JSON 的有 16000 行(损坏 0 行)

8 个 goroutine 共 16000 条 JSON 日志,每行都是完整合法的 JSON,没有撕裂。本实验里我给 writer 加了一把锁,所以它验证的是“每条日志一次 Write”,而不是 Handler 自己的锁;我没有试过去掉这把锁。

文档

实测 · 本机执行输出
  message, a severity level, and various other attributes expressed as key-value pairs.  It defines a type, Logger, which provides several methods (such as Logger.Info and Logger.Error) for reporting events of interest.  Each Logger is associated with a Handler. A Logger output method creates a

slog 的模型很简单:一条记录=消息+级别+键值属性,Logger 把它交给 Handler。想换格式或输出目的地,就换 Handler。

分步演示

log/slog
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。基准输出到一个自己写的什么都不做的 writer(避开标准库对 io.Discard 的短路);分配与耗时用 testing.Benchmark / AllocsPerRun;并发实验的 writer 外加了互斥锁;格式展示时用 ReplaceAttr 去掉时间字段;读过的文档:go doc log/slog 开头
    • 没有验证:Handler 内部锁的作用(未去掉外层锁试验)、自定义 Handler 的写法、slog.SetDefault 与标准库 log 的桥接、真实文件/网络输出下的成本、大量属性时的分配
    京ICP备2026067502号-1京公网安备11010502063487号