Go slog 入门:用结构化日志记录更清楚的服务行为

本文详解 Go 标准库 log/slog 的基本用法,包含文本与 JSON 格式、With 字段传递、HTTP 请求日志中间件、敏感信息处理和生产环境最佳实践。

日志从字符串变成结构

传统日志经常这样写:

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,也要看业务合规要求。日志不是数据库,不应该成为另一个敏感数据仓库。

日志字段命名规范

统一的字段命名能让日志查询和分析事半功倍。建议遵循以下规范:

含义推荐字段名
请求 IDrequest_id
用户 IDuser_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 是平滑的升级路径。不需要一次性改造所有代码,可以按模块逐步推进。关键是先把日志格式统一,再逐步丰富字段信息。结构化日志的回报是长期的,它让运维、监控和故障排查的效率成倍提升。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练