平时组里写业务,大家不是 zap.NewProduction() 就是 zerolog,甚至老项目里还有一堆 log.Printf 混着 fmt.Sprintf。Go 1.21 官方正式把 log/slog 塞进标准库之后,我花了两周时间把内部几个轻量微服务底层的日志库替换掉了。
先说结论:完全可以平替第三方库进生产,但想榨干性能,必须注意传参姿势。
今天直奔主题,聊聊 slog 在生产环境的落地姿势,顺便贴一下我用 go test -bench 跑出来的真实压测数据。
一、 架构分层:别把 Logger 和 Handler 搞混
slog 的设计很清晰,核心就两层:
- Frontend (
*slog.Logger):暴露给业务调用的 API,比如Info、Error、With。 - Backend (
slog.Handler):真正决定日志怎么输出、打到哪里的底层处理器(比如输出为 JSON 还是 Text,写入文件还是os.Stdout)。
在生产环境,我们一般直接无脑用 slog.NewJSONHandler。
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true, // 生产环境建议按需开启,开 source 会有额外反射开销
}
handler := slog.NewJSONHandler(os.Stdout, opts)
logger := slog.New(handler)
slog.SetDefault(logger)
slog.Info("server starting", "port", 8080, "env", "prod")
}
二、 生产实践一:Trace ID 与 Context 隐式传递
后端写业务绕不开链路追踪。大家最讨厌的就是每行打日志都要手动传一次 slog.String("trace_id", traceID)。
最优雅的解法是写一个中间件或自定义 Handler,从 context.Context 自动提取:
type ContextHandler struct {
slog.Handler
}
func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if ctx != nil {
if traceID, ok := ctx.Value("trace_id").(string); ok && traceID != "" {
r.AddAttrs(slog.String("trace_id", traceID))
}
}
return h.Handler.Handle(ctx, r)
}
// 初始化时套一层
handler := &ContextHandler{
Handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
}
logger := slog.New(handler)
业务里直接:
// 请求进来时
ctx := context.WithValue(context.Background(), "trace_id", "trace-abc-123")
logger.InfoContext(ctx, "payment processed", "amount", 100)
输出的 JSON 会自动带上 {"trace_id":"trace-abc-123", ...},业务代码完全无感。
三、 生产实践二:敏感字段脱敏 (ReplaceAttr)
生产日志最忌讳打出用户密码、手机号或者 Token。slog.HandlerOptions 提供了一个非常轻量的钩子:ReplaceAttr。
func maskSensitiveData(groups []string, a slog.Attr) slog.Attr {
// 针对特定 key 进行掩码
switch a.Key {
case "password", "token":
return slog.String(a.Key, "******")
case "phone":
str, ok := a.Value.Any().(string)
if ok && len(str) == 11 {
return slog.String(a.Key, str[:3]+"****"+str[7:])
}
}
return a
}
opts := &slog.HandlerOptions{
ReplaceAttr: maskSensitiveData,
}
直接在底层拦截,比在业务逻辑里人肉 mask(phone) 靠谱得多。
四、 性能硬碰硬:Benchmark 数据说话
做性能调优必须看数字。很多人以为 slog 是标准库,性能就一定不如高度优化的 zap,或者觉得 slog 怎么写都一样快。
我写了个基准测试,跑在 M2 Pro 上(go test -benchmem -bench=.),对比了三种传参方式与 zap 的表现:
slog.Info(msg, "k", "v")(宽松传参,走any反射)slog.LogAttrs(ctx, LevelInfo, msg, slog.String("k", "v"))(强类型传参,零逃逸)zap.Logger.Info(msg, zap.String("k", "v"))
goos: darwin
goarch: arm64
BenchmarkSlogLoose-10 4824810 248.5 ns/op 64 B/op 2 allocs/op
BenchmarkSlogAttrs-10 10412850 115.2 ns/op 0 B/op 0 allocs/op
BenchmarkZapStructured-10 11985420 100.1 ns/op 0 B/op 0 allocs/op
从数据可以清晰看出两件事:
slog.Info("k", "v")会发生堆逃逸:因为...any接口转换,一次记录会有 2 次内存分配(64 B/op)。高并发网关如果每秒打几万条日志,GC 压力肉眼可见。slog.LogAttrs达到了 0 allocs/op:耗时从248.5 ns直接缩短到115.2 ns,性能紧追zap(100.1 ns)。
所以结论很明确:高并发核心链路,强制用 slog.LogAttrs 或 logger.LogAttrs,把参数封装成 slog.Attr。边缘业务或者低频调用,直接用宽松传参图省事。
五、 总结与迁移建议
对于我们 2 年左右经验的后端来说,日常开发追求的是维护成本与运行效率的平衡:
- 新项目:直接上
log/slog吧,标准库地位决定了以后的开源中间件、SDK 都会向它看齐,不再需要纠结到底是依赖zap还是别的三方库。 - 老项目:如果已经重度绑定
zap且性能稳定,没必要特意为了赶时髦重构,可以写个适配器把zap.Core包装成slog.Handler,做平滑过渡。 - 高并发核心路径:老老实实用
LogAttrs传强类型,别为了省几个字符去写可变参数Any,GC 不会骗人。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


