Go 生产日志体系——structured logging、日志采样、ELK 对接完整方案
Go 生产日志体系——structured logging、日志采样、ELK 对接完整方案
适读人群:需要在 Go 服务上建立完整可观测性的工程师 | 阅读时长:约17分钟 | 核心价值:从 fmt.Println 到生产级 structured logging 的完整升级路径
那个让我翻日志翻到怀疑人生的案例
接手一个老项目的时候,第一个任务是排查一个用户反馈的 bug:他说某笔交易记录"消失了"。
打开日志系统,日志是这样的:
2024-03-15 14:23:11 INFO: Processing transaction
2024-03-15 14:23:11 DEBUG: Transaction saved
2024-03-15 14:23:11 ERROR: Something went wrong
2024-03-15 14:23:12 INFO: Transaction complete这就是纯文本日志,4条。我拿着这 4 条日志,完全不知道:这是哪个用户的哪笔交易?"Something went wrong" 是什么错误?是回滚了吗?
然后我去翻代码,看到日志是这么打的:
log.Printf("Processing transaction")
// ... 一堆逻辑
log.Printf("Transaction saved")
// ...
log.Printf("Something went wrong")
// ...
log.Printf("Transaction complete")没有事务 ID,没有用户 ID,没有具体的错误信息,没有字段……我花了4个小时才弄明白那笔交易发生了什么。
这是那次经历之后,我建立的完整日志体系。
structured logging 的核心思想
Structured logging 就一句话:日志不是给人直接读的文字,而是给机器解析的数据,以 JSON 格式输出,包含固定字段。
最终日志长这样:
{"time":"2024-03-15T14:23:11.234Z","level":"INFO","msg":"transaction processed","trace_id":"abc123","user_id":"u456","tx_id":"tx789","amount":1000,"currency":"CNY","duration_ms":47}这才是可以被 ELK/Loki 高效索引和查询的日志。
Go 1.21 的 log/slog
Go 1.21 标准库引入了 log/slog,这是官方的 structured logging 方案,终于不用依赖第三方库了(zap、zerolog 依然更快,但 slog 足够满足大多数需求)。
package logger
import (
"context"
"io"
"log/slog"
"os"
)
// contextKey 用于在 context 里存储 logger
type contextKey struct{}
// Config 日志配置
type Config struct {
Level slog.Level
Format string // "json" or "text"
Output io.Writer
}
// New 创建一个 slog.Logger
func New(cfg Config) *slog.Logger {
output := cfg.Output
if output == nil {
output = os.Stdout
}
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: cfg.Level,
AddSource: cfg.Level == slog.LevelDebug, // Debug 模式下加上代码行号
}
if cfg.Format == "json" {
handler = slog.NewJSONHandler(output, opts)
} else {
handler = slog.NewTextHandler(output, opts)
}
return slog.New(handler)
}
// FromContext 从 context 取 logger,如果没有则返回默认 logger
func FromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(contextKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
// WithContext 把 logger 存入 context
func WithContext(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, contextKey{}, logger)
}
// WithFields 给 logger 加上固定字段(用于请求维度的上下文)
func WithFields(logger *slog.Logger, fields ...any) *slog.Logger {
return logger.With(fields...)
}请求维度的日志上下文
每个请求的所有日志都应该有相同的 trace_id、user_id 等字段,方便过滤。
package middleware
import (
"log/slog"
"net/http"
"time"
"github.com/myapp/logger"
)
// LoggingMiddleware HTTP 请求日志中间件
func LoggingMiddleware(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 从 header 或 OTEL 取 trace ID
traceID := r.Header.Get("X-Trace-Id")
if traceID == "" {
traceID = generateTraceID()
}
// 创建带请求上下文的 logger
requestLogger := logger.WithFields(log,
slog.String("trace_id", traceID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
)
// 把 logger 存入 context,后续所有函数都能取到
ctx := logger.WithContext(r.Context(), requestLogger)
r = r.WithContext(ctx)
// 包装 ResponseWriter 以获取状态码
wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
requestLogger.Info("request completed",
slog.Int("status", wrapped.statusCode),
slog.Int64("duration_ms", duration.Milliseconds()),
slog.Int("response_size", wrapped.size),
)
})
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
size int
}
func (rw *responseWriter) WriteHeader(statusCode int) {
rw.statusCode = statusCode
rw.ResponseWriter.WriteHeader(statusCode)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.size += n
return n, err
}
func generateTraceID() string {
// 实际实现用 UUID 或类似
return "generated-trace-id"
}在业务代码里使用:
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
log := logger.FromContext(ctx) // 自动带有 trace_id 等字段
log.Info("creating order",
slog.String("user_id", req.UserID),
slog.Float64("amount", req.Amount),
)
order, err := s.repo.Create(ctx, req)
if err != nil {
log.Error("failed to create order",
slog.String("error", err.Error()),
slog.String("user_id", req.UserID),
)
return nil, err
}
log.Info("order created",
slog.String("order_id", order.ID),
slog.Int64("duration_ms", 0), // 实际用计时
)
return order, nil
}踩坑实录
坑一:日志太多,ELK 索引压力大,查询慢
现象: 接入 ELK 后,Elasticsearch 磁盘用量每天涨 20GB,而且复杂查询越来越慢。
原因: Info 级别的日志太细了,把每个数据库查询、每个缓存命中都打了出来。
解法: 日志采样——高频、低价值的日志只采一部分:
package logger
import (
"log/slog"
"math/rand"
"sync/atomic"
)
// SampledLogger 采样日志器
type SampledLogger struct {
logger *slog.Logger
sampleRate float64 // 0.0-1.0,1.0=全采,0.01=1%采样
counter atomic.Uint64
}
func NewSampledLogger(logger *slog.Logger, sampleRate float64) *SampledLogger {
return &SampledLogger{
logger: logger,
sampleRate: sampleRate,
}
}
// Info 采样输出 Info 日志
func (s *SampledLogger) Info(msg string, args ...any) {
if rand.Float64() < s.sampleRate {
s.logger.Info(msg, args...)
}
}
// Error 错误日志永远不采样
func (s *SampledLogger) Error(msg string, args ...any) {
s.logger.Error(msg, args...)
}采样后,每天的日志量从 20GB 降到了 3.2GB,而且关键的错误日志一条都不丢。
坑二:日志里打印了用户密码
现象: Code review 时发现某处日志打印了 req 对象,而 req 里包含了 Password 字段。
原因: 用了 slog.Any("req", req),slog 会用反射把所有字段都序列化出来。
解法: 自定义 LogValue(),让 slog 只输出安全字段:
type CreateUserRequest struct {
Username string
Email string
Password string // 敏感字段
}
// LogValue 实现 slog.LogValuer 接口,控制日志输出
func (r CreateUserRequest) LogValue() slog.Value {
return slog.GroupValue(
slog.String("username", r.Username),
slog.String("email", r.Email),
// 不输出 Password
)
}坑三:日志丢失——同步写日志在高并发下成为瓶颈
现象: 高并发场景下,API 响应时间从 15ms 涨到了 89ms,pprof 显示大量时间花在了日志写入上。
原因: slog 默认是同步写,每条日志都同步 syscall 写入 stdout,高并发时 I/O 成为瓶颈。
解法: 加一个异步写入的 buffer,用 channel 解耦业务代码和日志写入:
这个问题的标准解法是用 zap 的异步写入,或者在 slog 外面包一层 channel buffer。但注意:异步写入在程序崩溃时可能丢失 buffer 中未写入的日志,需要在 shutdown 时 flush。
ELK 对接配置
# filebeat.yml - 采集 Go 服务的 stdout 日志
filebeat.inputs:
- type: container
paths:
- '/var/lib/docker/containers/*/*.log'
processors:
- decode_json_fields:
fields: ["message"]
target: ""
overwrite_keys: true
output.elasticsearch:
hosts: ["elasticsearch:9200"]
index: "go-services-%{+yyyy.MM.dd}"
# Index template 配置
# 为 trace_id, user_id 等常用字段建立关键字索引Go vs Java:SLF4J vs slog
Java 的 SLF4J + Logback 体系很成熟,MDC(Mapped Diagnostic Context)可以自动把上下文信息绑定到当前线程的所有日志里,用起来很方便。
Go 没有线程本地存储(因为 goroutine 可以在不同线程间切换),所以没法用 MDC 模式。上下文传递必须通过 context.Context 显式完成。
这是一个麻烦,但也是一个好事——你不得不显式地思考"这条日志应该包含什么上下文",而不是依赖框架魔法自动添加。
