日志从字符串变成结构
传统日志经常这样写:
log.Printf("user %d login failed: %v", userID, err)
它能看,但机器不太好解析。后来很多服务会输出 JSON 日志,字段里有 level、time、user_id、path、duration_ms。这样日志平台可以按字段搜索、聚合和告警。Go 标准库里的 log/slog 就是为了结构化日志提供统一接口。
学习 slog 的重点不是记住所有 API,而是理解日志应该记录什么:动作、关键字段、错误、耗时、请求范围信息。好的日志能帮助你在服务出问题时快速定位;糟糕的日志只是大量无意义字符串。
这篇文章从最小用法讲起,再写一个 HTTP 请求日志中间件,最后分享生产环境中的配置经验。
最小 slog
logger := slog.Default()
logger.Info("server starting")
带字段:
logger.Info("server starting",
"addr", ":8080",
"env", "development",
)
错误日志:
if err != nil {
logger.Error("load config failed", "err", err)
}
字段是成对出现的 key/value。key 通常使用短小稳定的英文,比如 user_id、path、method、duration_ms。不要把整段中文描述塞进 key,中文可以放在 message,但字段名最好稳定,方便查询。
文本日志和 JSON 日志
创建文本 handler:
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("hello", "name", "Go")
创建 JSON handler:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello", "name", "Go")
本地开发用文本日志比较舒服,生产环境常用 JSON 日志,方便日志平台解析。你可以通过配置决定:
func NewLogger(format string) *slog.Logger {
switch format {
case "json":
return slog.New(slog.NewJSONHandler(os.Stdout, nil))
default:
return slog.New(slog.NewTextHandler(os.Stdout, nil))
}
}
使用:
logger := NewLogger(os.Getenv("LOG_FORMAT"))
日志格式属于运行时配置,不应该写死在业务函数里。
With 添加公共字段
服务启动时可以创建带公共字段的 logger:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
With("service", "user-api").
With("version", "v1.2.0")
之后每条日志都会带上这些字段。
请求级别也可以创建子 logger:
requestLogger := logger.With(
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
)
requestLogger.Info("request started")
这比每次手动写重复字段更清楚。With 不会修改原 logger,而是返回一个带额外字段的新 logger。
HTTP 请求日志中间件
先写状态码 recorder:
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
中间件:
func RequestLogger(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{
ResponseWriter: w,
status: http.StatusOK,
}
next.ServeHTTP(rec, r)
logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
使用:
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
server := &http.Server{
Addr: ":8080",
Handler: RequestLogger(logger, mux),
}
这条日志比普通字符串更容易搜索。你可以查询 status >= 500,也可以按 path 聚合耗时。
日志级别动态控制
生产环境中,日志级别通常需要动态调整。你可以在创建 handler 时设置最低级别:
level := new(slog.LevelVar)
level.Set(slog.LevelInfo)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}))
需要开启调试时,通过 HTTP 接口或信号动态切换:
func (s *Server) SetLogLevel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s.level.Set(slog.LevelDebug)
w.Write([]byte("log level set to debug"))
}
这样不需要重启服务就能临时放宽日志级别,排查问题时非常有用。
生产环境日志配置建议
生产环境配置日志 handler 时,建议加上时间格式和调用源信息:
opts := &slog.HandlerOptions{
AddSource: true,
Level: level,
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
AddSource: true 会在日志中包含文件名和行号,方便定位日志来源。不过这会增加分配开销,高频路径可以关闭。
对于需要写入文件并轮转的场景,可以使用 io.MultiWriter:
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal(err)
}
multi := io.MultiWriter(os.Stdout, file)
logger := slog.New(slog.NewJSONHandler(multi, nil))
更好的做法是用专门的日志轮转库,比如 lumberjack:
import "gopkg.in/natefinch/lumberjack.v2"
lw := &lumberjack.Logger{
Filename: "app.log",
MaxSize: 100, // MB
MaxBackups: 3,
MaxAge: 28, // days
}
logger := slog.New(slog.NewJSONHandler(lw, nil))
不要记录敏感信息
结构化日志更容易被系统收集、转发和长期保存,所以敏感信息更要谨慎。不要记录密码、token、完整身份证号、银行卡号、私密请求体。
错误示例:
logger.Info("login request",
"email", req.Email,
"password", req.Password,
)
正确做法:
logger.Info("login failed",
"email", req.Email,
"reason", "invalid_credentials",
)
即使是 email,也要看业务合规要求。日志不是数据库,不应该成为另一个敏感数据仓库。
日志字段命名规范
统一的字段命名能让日志查询和分析事半功倍。建议遵循以下规范:
| 含义 | 推荐字段名 |
|---|---|
| 请求 ID | request_id |
| 用户 ID | user_id |
| HTTP 方法 | method |
| 请求路径 | path |
| 响应状态码 | status |
| 耗时(毫秒) | duration_ms |
| 错误信息 | err |
| 服务名 | service |
| 版本 | version |
不要用驼峰命名(如 requestId),也不要混用下划线和驼峰。保持一致性是日志平台解析的前提。
常见错误与排查
日志不输出
检查 handler 的 level 设置。如果设置了 LevelInfo,Debug 日志不会输出。
性能问题
AddSource: true 每次日志都会调用 runtime.Caller,对高频路径有性能影响。核心路径建议关闭。
缺失级别
log/slog 相比第三方库功能精简。如果依赖复杂过滤、采样、批量发送,可能需要引入 zap 或 zerolog。
实战 FAQ
Q: slog 和 zap 怎么选?
A: 新项目从标准库开始完全合理。slog 覆盖了结构化和级别控制的核心需求。如果性能极度敏感(每秒百万级日志),再考虑 zap。
Q: 日志量太大怎么办?
A: 调整日志级别,使用采样(sampling),或者把日志发送给外部收集器(如 Fluentd、Filebeat)。
Q: 测试里怎么验证日志输出?
A: 用 slog.NewTextHandler 写到 bytes.Buffer,然后检查 buffer 内容。
自定义 Handler 实现日志采样
高并发服务中,完全记录所有请求日志可能会产生海量数据。可以通过自定义 Handler 实现采样:
type SamplingHandler struct {
handler slog.Handler
rate int // 每rate条记录1条
count atomic.Int64
}
func (h *SamplingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
}
func (h *SamplingHandler) Handle(ctx context.Context, r slog.Record) error {
if r.Level >= slog.LevelError {
return h.handler.Handle(ctx, r)
}
if h.count.Add(1)%int64(h.rate) == 0 {
return h.handler.Handle(ctx, r)
}
return nil
}
func (h *SamplingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &SamplingHandler{handler: h.handler.WithAttrs(attrs), rate: h.rate}
}
func (h *SamplingHandler) WithGroup(name string) slog.Handler {
return &SamplingHandler{handler: h.handler.WithGroup(name), rate: h.rate}
}
使用采样 handler 后,只有部分请求会被记录,但错误日志始终完整保留。采样率可以根据日志量和存储成本调整。
集成到已有项目
如果项目中已经使用 log 或第三方日志库,可以逐步迁移到 slog:
func InitSlog() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
}
设置默认 logger 后,项目中使用 slog.Info、slog.Error 等包级函数即可。第三方库的日志也可以转到 slog:
func RedirectStdLog() {
stdLogger := slog.NewLogLogger(slog.Default().Handler(), slog.LevelWarn)
log.SetOutput(stdLogger.Writer())
}
这样标准库的 log 输出会被转到 slog,统一格式和级别控制。
错误日志聚合
生产环境中,分散在各个服务实例的错误日志需要聚合。常见方案是把日志输出到 stdout,由容器平台的日志收集器(如 Fluentd、Filebeat、Promtail)采集到 Elasticsearch 或 Loki。另一种方式是直接把错误发送到 Sentry 或 Datadog:
func ErrorInterceptor(logger *slog.Logger, notify func(error)) slog.Handler {
return &errorNotifyHandler{
handler: logger.Handler(),
notify: notify,
}
}
type errorNotifyHandler struct {
handler slog.Handler
notify func(error)
}
func (h *errorNotifyHandler) Handle(ctx context.Context, r slog.Record) error {
if r.Level >= slog.LevelError {
h.notify(errors.New(r.Message))
}
return h.handler.Handle(ctx, r)
}
这种方式对非预期错误做实时告警,比事后翻日志更快发现问题。
更完整的 FAQ
Q: slog 支持 context 中传递 logger 吗?
A: 标准库没有内置 context 集成,但可以在 context 中存 *slog.Logger,中间件统一注入。或者用 context.WithValue 传递 request_id,日志中统一读取。
Q: 如何处理日志中的循环引用?
A: slog 的 JSON handler 使用 encoding/json,对循环引用会直接报错。建议在记录前把对象转成不包含循环的结构。
Q: 并发写入日志安全吗?
A: slog 的 handler 内置了同步写入能力,多 goroutine 并发调用是安全的。如果是自定义 handler 写到文件或网络,需要自己保证同步。
Q: 手机或小程序后端用什么日志格式?
A: JSON 格式适合所有下游消费场景。如果日志量不大,文本格式开发调试更方便。
Q: 接入 ELK/Loki 时有什么建议?
A: 时间字段由日志平台自动附加,不需要在日志中重复。关键字段保持稳定命名结构,避免字段名随意变更导致 dashboards 失效。
Q: 日志中出现大量重复错误怎么处理?
A: 可以使用 deduplication 或者 rate limiting。高频率重复错误通常是某个依赖故障引起的,应该配合告警通知,而不是在日志中无限重复。
Q: 要不要在日志中记录堆栈信息?
A: 生产环境错误日志建议记录堆栈,方便定位。但堆栈信息很长,要评估对日志体积的影响。可以在错误级别记录,info 级别不记录。
零分配日志与性能优化
在极端性能敏感的场景中,减少日志分配的内存甚至比选择更快的日志库更重要。slog 的设计本身就考虑了低分配,但仍有优化空间。对于高频路径,预先创建好 logger 并避免在日志调用中动态分配对象:
var (
keyUserID = slog.String("user_id", "")
keyMethod = slog.String("method", "")
)
func FastLogRequest(logger *slog.Logger, userID int64, method string) {
logger.Info("request handled",
slog.Int64("user_id", userID),
slog.String("method", method),
)
}
使用 slog.Int64、slog.String 等类型化构造函数比直接传入原始值更高效,它们避免了接口转换带来的额外分配。slog.Attr 是预计算的键值对结构,在大量重复字段场景中可以复用。
AddSource: true 在高频路径下会增加 runtime.Caller 调用开销。对于每秒处理数十万次请求的 API 服务,建议在生产环境关闭 AddSource,或者仅对错误级别日志开启:
opts := &slog.HandlerOptions{
Level: level,
AddSource: false, // 高频路径关闭
}
与 OpenTelemetry 结合
结构化日志与链路追踪是相辅相成的。把 trace_id 和 span_id 注入日志字段,可以在日志中直接关联到分布式链路:
func InjectTraceContext(ctx context.Context, logger *slog.Logger) *slog.Logger {
span := trace.SpanFromContext(ctx)
if !span.IsRecording() {
return logger
}
spanCtx := span.SpanContext()
return logger.With(
"trace_id", spanCtx.TraceID().String(),
"span_id", spanCtx.SpanID().String(),
)
}
使用时在 handler 入口注入:
func handler(w http.ResponseWriter, r *http.Request) {
logger := InjectTraceContext(r.Context(), appLogger)
logger.Info("handling request", "path", r.URL.Path)
}
这条日志中的 trace_id 和 span_id 可以直接被日志平台索引。当用户报告某个请求失败时,用 trace_id 在 Jaeger 或 Grafana Tempo 中搜索,可以看到完整的调用链。这种日志与追踪的结合是云原生可观测性的最佳实践。
日志分组与嵌套结构
log/slog 支持通过 WithGroup 创建分组字段,让嵌套结构的表达更清晰:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
httpLogger := logger.WithGroup("http")
httpLogger.Info("request",
"method", "GET",
"path", "/api/users",
"status", 200,
)
输出结果为:{"http":{"method":"GET","path":"/api/users","status":200}}。分组在需要把多个相关字段归类的场景中非常有用,比如 HTTP 请求元数据、数据库连接信息、缓存操作详情等。不过要注意,并非所有日志消费系统都支持嵌套查询,使用分组前需要确认下游平台的能力。
小结
log/slog 让 Go 标准库拥有了结构化日志能力。你可以选择文本或 JSON handler,用 key/value 字段记录上下文,用 With 添加公共字段,用中间件记录 HTTP 请求。
日志的价值在于排查问题。每条关键日志都应该回答:发生了什么,和哪个用户或请求有关,在哪个路径,结果如何,耗时多久,错误是什么。字段稳定、信息克制、敏感数据不进日志,是结构化日志的基本要求。生产环境还要注意日志轮转、级别控制和性能平衡。
从迁移角度来说,把已有的 log.Printf 逐步替换为 slog.Info 是平滑的升级路径。不需要一次性改造所有代码,可以按模块逐步推进。关键是先把日志格式统一,再逐步丰富字段信息。结构化日志的回报是长期的,它让运维、监控和故障排查的效率成倍提升。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。