跳至内容
让服务真正可观测:slog、请求关联与错误上下文

让服务真正可观测:slog、请求关联与错误上下文

2026年6月14日·
yanlong

日志不是把字符串写进文件,而是给一次请求留下可查询的因果线索。log/slog 提供结构化记录和可替换 Handler,但字段设计、关联方式和记录边界仍要由服务自己决定。

日志、指标、追踪各回答什么

    flowchart TD
    Q["服务为什么异常?"] --> M["指标:何时开始、影响多大"]
    Q --> T["追踪:慢在哪个调用段"]
    Q --> L["日志:这次请求发生了什么"]
    M --> C["通过 service / route / trace_id 关联"]
    T --> C
    L --> C
  

不要用日志承担所有观测职责。请求量、错误率、延迟分布属于指标;跨服务调用关系属于追踪;包含业务上下文的离散事件才适合日志。

建立稳定的字段契约

level := new(slog.LevelVar)
level.Set(slog.LevelInfo)

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
	Level: level,
	AddSource: true,
})
logger := slog.New(handler).With(
	"service", "order-api",
	"env", os.Getenv("APP_ENV"),
)
slog.SetDefault(logger)

生产环境通常使用 JSON Handler,便于日志平台索引;本地可以使用 Text Handler。字段名应稳定,例如统一使用 request_idtrace_iduser_id,不要在不同模块里出现三套拼写。

Logger.With 适合创建带固定上下文的子 Logger:

orderLog := logger.With(
	"request_id", requestID,
	"trace_id", traceID,
	"component", "order_service",
)
orderLog.Info("order created", "order_id", order.ID, "amount_cent", order.Amount)

嵌套对象用 Group 保持命名空间:

logger.Info("upstream completed",
	slog.Group("http",
		slog.String("method", method),
		slog.Int("status", status),
		slog.Duration("duration", elapsed),
	),
)

Context 不会自动变成日志字段

InfoContext 会把 Context 传给 Handler,但标准 Handler 不会自动提取 request ID 或 trace ID。可以在中间件构造带请求字段的 Logger,并通过小型辅助函数传递;不要把整个业务对象塞进 Context。

type ctxKey struct{}

func withLogger(ctx context.Context, l *slog.Logger) context.Context {
	return context.WithValue(ctx, ctxKey{}, l)
}

func loggerFrom(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok { return l }
	return slog.Default()
}

另一种方式是自定义 Handler,在 Handle 中从 Context 提取追踪信息。无论哪种方式,都要让提取逻辑集中,避免每条日志手工拷贝字段。

    sequenceDiagram
    participant C as 客户端
    participant M as HTTP 中间件
    participant S as Service
    participant D as 下游
    C->>M: request_id / trace context
    M->>M: 构造请求 Logger
    M->>S: ctx + logger
    S->>D: 传播 ctx
    D-->>S: wrapped error
    S-->>M: 返回错误,不重复打印
    M-->>C: 映射状态码并记录一次完成日志
  

错误在底层包装,在边界记录

底层应增加可用于定位的上下文,同时保留错误链:

return fmt.Errorf("load order %s: %w", orderID, err)

如果 repository、service、handler 每层都打印同一个错误,一次失败会变成三条噪声。更清晰的策略是:底层包装,能够决定最终响应和严重级别的边界记录一次。日志中同时保留可分类的错误类型或码:

logger.ErrorContext(ctx, "request failed",
	"error", err,
	"error_code", code,
	"route", routePattern,
	"status", status,
	"duration", time.Since(start),
)

route 应使用低基数的模式 /users/{id},不能直接拿原始 URL 做指标标签。日志字段可以高基数,但仍要控制体积和查询成本。

安全与性能边界

  • 密码、令牌、Cookie、身份证号等不进入日志;对类型实现 LogValuer 可集中脱敏。
  • 不记录完整请求/响应 Body 作为常规手段。必要的审计日志应有独立规范、访问控制和保留策略。
  • 热路径先调用 logger.Enabled(ctx, level),避免为被过滤的日志做昂贵计算。
  • 已知字段较多时用 LogAttrsslog.Attr,减少临时分配。
  • LevelVar 可动态调整级别,但临时 Debug 应有自动回收机制,避免日志量失控。
type Email string

func (e Email) LogValue() slog.Value {
	s := string(e)
	if i := strings.IndexByte(s, '@'); i > 1 {
		s = s[:1] + "***" + s[i:]
	}
	return slog.StringValue(s)
}

最后,记录一条统一的请求完成日志:方法、路由模板、状态码、耗时、响应大小和关联 ID。它既是排障入口,也能用来抽样核对指标与追踪。真正的可观测性不是“日志很多”,而是从告警能沿着共同标识快速走到一条具体失败链路。

进一步阅读:log/slog 包文档Go 官方 slog 设计文章