登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go slog 如何给每条日志补 request_id:WithAttrs 与 Handler 包装的边界

来源:17golang原创

时间:2026-08-24 20:41:31 147浏览 收藏

接口日志里没有 request_id,排查一次慢请求往往要靠时间和猜测。Go 的 log/slog 已经提供了结构化字段能力,但固定字段和请求级动态字段并不是同一种问题:WithAttrs 适合把不会变化的上下文挂到 logger 上,真正随请求变化的值则应在请求边界注入,或者交给一个明确的 Handler 包装层处理。

最小原则是:服务名、版本、区域这类固定字段用 WithAttrsrequest_id、用户标识和 trace 信息从请求上下文取得,避免把一个请求的状态错误地共享给其他请求。

实践要点
  • 先创建基础 Handler,再用 logger.WithWithAttrs 固定服务级字段。
  • 请求级字段放在 handler、middleware 或显式 logger 参数的边界,不能写进全局共享 logger 的可变状态。
  • 用并发请求和字段断言验收:每条记录都应带自己的 request_id,且不能串号。
Go slog 请求上下文与 request_id 注入流程示意

先把固定上下文和请求上下文分开

一条日志通常同时包含两类信息。服务名称、版本号和部署区域在进程生命周期内基本不变;请求编号、路由和操作者则随着每一次调用变化。把它们都塞进同一个全局 logger,短期看起来省事,遇到并发后就很难证明字段属于谁。

固定字段可以在构造 logger 时一次绑定:

base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
logger := base.With(
    slog.String("service", "checkout-api"),
    slog.String("region", "cn-east-1"),
)

这里的 With 会把属性交给 Handler 的 WithAttrs 语义处理。它的价值是集中表达稳定上下文,而不是给后续请求留下一个可以被覆盖的“当前编号”。

request_id 应该在哪个边界注入

请求编号最适合在入口处生成或读取,然后沿着调用链显式传递。小型服务可以直接派生一个请求 logger,传给需要记录日志的函数:

func handleOrder(ctx context.Context, logger *slog.Logger) {
    requestID, _ := ctx.Value(requestIDKey{}).(string)
    reqLogger := logger.With(slog.String("request_id", requestID))
    reqLogger.Info("开始处理订单")
    // 后续函数继续接收 reqLogger,避免重新猜测请求上下文。
}

如果业务层已经统一使用 context.Context,也可以把 logger 放进 context,再由一个小函数取出。关键不是“必须使用哪种封装”,而是请求级 logger 的生命周期不能超过请求本身。

不要在全局 logger 上保存可变的 request_id。这种写法在单线程演示里可能正常,到了并发请求就会出现字段覆盖、串号和难以复现的日志。

什么时候需要包装 Handler

显式传 logger 的好处是依赖关系清楚;但如果项目里所有调用都经过统一的日志 Handler,包装层可以把“从上下文读取字段”的规则集中起来。包装 Handler 的 Handle 方法接收一条日志记录,在交给下层 Handler 之前补充属性:

type requestHandler struct {
    next slog.Handler
}

func (h requestHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.next.Enabled(ctx, level)
}

func (h requestHandler) Handle(ctx context.Context, r slog.Record) error {
    if id, ok := ctx.Value(requestIDKey{}).(string); ok && id != "" {
        r.AddAttrs(slog.String("request_id", id))
    }
    return h.next.Handle(ctx, r)
}

func (h requestHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return requestHandler{next: h.next.WithAttrs(attrs)}
}

func (h requestHandler) WithGroup(name string) slog.Handler {
    return requestHandler{next: h.next.WithGroup(name)}
}

包装层要完整转发 EnabledWithAttrsWithGroup。少实现一个,级别判断、分组字段或固定属性都可能和原 Handler 的行为不一致。

两种方案的取舍不要混成隐式魔法

WithAttrs 固定属性与 Handler 动态属性的边界对比

如果只有少数入口需要 request_id,显式的 logger.With 更容易阅读和测试;如果日志来自很多公共库、又能保证每次调用都携带正确 context,Handler 包装更适合统一治理。

两者也可以组合:基础 logger 用 WithAttrs 固定服务字段,包装 Handler 只负责从当前 context 补请求字段。组合时要约定字段名唯一,避免同一条记录先后出现两个同名属性,导致下游采集平台的取值规则不一致。

用并发验收防止日志串号

不要只看一条漂亮的 JSON 输出。至少构造两个不同的 context,并发写入多条记录,然后检查每一条 request_id 是否来自当前 context。可以把 Handler 接到测试用的内存 Handler,断言记录数量、编号分布和固定字段。

验收时重点看三个结果:没有 request_id 的后台任务是否仍能正常记录;同一请求的多条日志编号是否一致;两个并发请求是否完全没有交叉编号。若使用异步日志队列,还要把 context 中的值在入队前解析成属性,不能把可能已经结束的 context 指针交给后台线程。

常见问题

WithAttrs 会修改原来的 logger 吗?

它返回带有附加属性的新 logger 或 Handler,调用方应把返回值保存下来使用。不要把它理解成给所有共享调用者设置“当前属性”。

request_id 放在 context 里就会自动出现在日志里吗?

不会。只有显式从 context 取出并调用 logger.WithRecord.AddAttrs 或对应 Handler 逻辑,字段才会进入日志记录。

为什么包装 Handler 还要实现 WithGroup

日志调用方可能使用分组属性。包装层如果不转发分组语义,固定字段和请求字段在 JSON 中的层级就可能不一致,查询规则也会变得不稳定。

小结

WithAttrs 解决的是稳定属性复用,Handler 包装解决的是统一处理动态请求上下文。把 request_id 的来源、生命周期和验收方式写清楚,再按项目规模选择显式传递或集中封装,日志系统才既方便查询,也不容易在并发下制造假线索。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>