生产环境优先选 zap.Logger 以保障结构化字段可检索和高性能,调试或迁移时可用 SugaredLogger;需配置 Encoding: "json" 实现 JSON 结构化输出;记录错误须用 zap.Error(err) 而非 logger.Error(err) 或字符串转换;HTTP 中通过 logger.With(zap.String("request_id", reqID)) 注入并传递请求 ID。

zap.Logger 和 zap.SugaredLogger 该怎么选
结构化日志的核心是字段可检索、可过滤,zap.Logger 原生支持结构化(用 zap.String、zap.Int 等构造字段),性能高;zap.SugaredLogger 更像传统 logger,支持类似 sugar.Infow("msg", "key", value) 或 sugar.Infof("msg: %s, code: %d", str, code),但底层仍转为结构化字段——只是语法糖。生产环境优先用 zap.Logger,调试或快速迁移旧日志时可临时用 SugaredLogger。
容易踩的坑:
-
SugaredLogger的Infof等格式化方法不自动结构化参数,比如sugar.Infof("user %s failed %d times", name, n)只会输出字符串,字段丢失;必须用Infow+ 显式键值对 - 混用两种 logger 容易导致字段命名不一致(如一个写
user_id,另一个写userID),后续查日志困难
如何正确配置 zap 以输出 JSON 结构化日志
默认的 zap.NewDevelopment() 输出的是带颜色的非结构化文本,不能直接被 ELK 或 Loki 解析。要结构化,必须用 zap.NewProduction() 或手动构建 zap.Config:
cfg := zap.Config{
Level: zap.NewAtomicLevelAt(zap.InfoLevel),
Encoding: "json",
OutputPaths: []string{"stdout"},
ErrorOutputPaths: []string{"stderr"},
EncoderConfig: zap.EncoderConfig{
TimeKey: "ts",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "msg",
StacktraceKey: "stacktrace",
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
},
}
logger, _ := cfg.Build()
关键点:
-
Encoding: "json"是结构化的前提,缺省是"console" -
EncoderConfig中的TimeKey、LevelKey等决定了字段名,和你的日志平台 schema 对齐很重要 - 如果日志要写文件,
OutputPaths改成[]string{"/var/log/app.json"}即可,但注意目录需存在且进程有写权限
记录错误时为什么不能只用 logger.Error(err)
err 是接口类型,直接传给 logger.Error()(无字段)只会输出 error=" 或调用 err.Error() 字符串,丢失堆栈、原始类型、附加字段等信息。正确做法是用 zap.Error(err) 这个专用字段构造器:
if err != nil {
logger.Error("db query failed",
zap.String("query", sql),
zap.Error(err), // ✅ 自动提取 error 的 Error() + Stack()(若实现了 StackTracer)
zap.String("user_id", userID),
)
}
它背后会检查 err 是否实现了 fmt.Formatter 或 stacktracer 接口,尽可能保留上下文。常见错误:
- 写成
zap.String("error", err.Error())—— 手动调用Error()丢弃了 stacktrace,且无法区分空 error 和真实 error 字符串 - 忽略
err为nil的情况:虽然zap.Error(nil)安全,但最好加判空避免冗余字段
如何在 HTTP handler 中注入请求 ID 并贯穿整个调用链
结构化日志的价值在于能按 request_id 聚合一次请求的所有日志。Zap 本身不提供上下文传播,得靠 logger.With() 手动传递:
func handler(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.New().String()
}
// 基于原 logger 派生带 request_id 的新 logger
logger := logger.With(zap.String("request_id", reqID))
logger.Info("request started", zap.String("path", r.URL.Path))
result, err := doSomething(r.Context(), logger)
if err != nil {
logger.Error("request failed", zap.Error(err))
}
}
注意点:
-
logger.With()返回新 logger,原 logger 不变;别忘了把新 logger 传进下游函数 - 不要在中间件里用
context.WithValue(r.Context(), key, logger)再塞回 context——Zap logger 不是 context 数据,且容易引发内存泄漏(logger 持有 encoder、sync.Pool 等) - 如果用了 Gin/Echo 等框架,确保每个 handler 都做一次
With(),否则跨 goroutine(如异步任务)的日志会丢失 request_id
最麻烦的其实是跨 goroutine 场景:比如启动一个 go func() 处理耗时任务,必须显式把带 request_id 的 logger 传进去,Zap 不会自动继承 goroutine 上下文。











