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

Go slog.Handler 如何串起日志字段:WithAttrs、分组和并发写入边界

来源:17golang原创

时间:2026-08-28 15:30:52 400浏览 收藏

线上服务把请求编号、租户和耗时都写进日志后,真正容易出问题的地方往往不是调用 slog.Info,而是字段怎样经过 LoggerHandlerRecord,以及多个 goroutine 同时写入时谁负责保证一条记录不被撕开。理解这条链,才能决定公共字段放在 WithAttrs,还是在每次日志调用里重新传入。

可以把 slog 看成“Logger 组装 Record,Handler 决定如何处理”;公共字段交给 WithAttrs,层级字段交给 WithGroup,自定义 Handler 则必须自己处理并发写入和 Record 修改边界。

实践要点
  • WithAttrs 适合绑定请求或服务级公共字段,减少重复格式化。
  • WithGroup 用于隔离子系统同名键,避免日志字段互相覆盖。
  • 内置 Handler 会保护单条记录的完整写入,自定义 Handler 不能默认继承这个保证。

先看清 Logger、Handler 和 Record 的分工

Logger.Info 这一层主要负责接收消息和键值参数,创建一个 Record,再把它交给关联的 Handler。Handler 决定记录是否启用、字段怎样格式化,以及最终写到哪里。也就是说,字段并不是直接从调用点“打印”出去的,中间还要经过 Record 的统一承载。

handler := slog.NewTextHandler(os.Stdout, nil)
logger := slog.New(handler)
logger.Info("order loaded", "order_id", 42)

这里的 order_id 会作为属性进入 Record。换成 JSONHandler,字段的输出形态会变,但 Logger 到 Handler 的调用关系没有改变。这个分工也解释了为什么格式、级别和输出锁应当放在 Handler 一侧判断。

Go slog Logger Handler Record WithAttrs 字段流转关系图
字段从 Logger 进入 Record,再由 Handler 处理;WithAttrs 为后续记录附加公共字段。

WithAttrs 适合放哪些公共字段

如果一个请求产生十几条日志,每条都重复传 request_idtenant,调用点会很快变得嘈杂。可以先构造一个带公共属性的新 Logger:

requestLogger := logger.With(
    slog.String("request_id", requestID),
    slog.String("tenant", tenantID),
)
requestLogger.Info("query started", slog.String("table", "orders"))

从 Handler 的视角看,With 会走到 Handler 的 WithAttrs。内置 Handler 可以把这批公共属性提前准备,后续每一条记录只处理变化部分。适合放进去的是请求范围、模块范围都稳定的值;一次性异常详情仍应在具体日志调用中传入。

不要把会变化的计数器快照误当成公共字段。比如 queue_depth 每次都不同,就应该随着那一次记录传入,否则日志看到的值可能只是创建 Logger 时的旧值。

WithGroup 解决子系统字段重名

订单服务和库存服务都可能写出 idstatus。不分组时,读者要靠消息内容猜字段属于谁。用 WithGroup 给子系统建立命名空间:

serviceLogger := logger.With(slog.String("request_id", requestID))
inventoryLogger := serviceLogger.WithGroup("inventory")
inventoryLogger.Info("reserved", "id", sku, "status", "ok")

TextHandler 会把分组字段表现为类似 inventory.id 的层级键;JSONHandler 则会把它放到 inventory 对象中。分组不是额外的业务字段,它改变的是 Handler 解释属性名称的上下文。

Go slog WithGroup Handle io.Writer 并发写入边界图
WithGroup 为字段增加子系统上下文,Handle 负责处理记录,最终由 io.Writer 完成写入。

自定义 Handler 时别直接改原 Record

日志脱敏或增加字段时,自定义 Handler 经常需要调整 Record。Record 的公开字段看起来可以直接复制,但它内部的属性状态可能仍被共享。官方建议在修改前调用 Record.Clone,或者新建 Record 后遍历旧记录的 Attrs。

func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
    r = r.Clone()
    // 在这里遍历 r.Attrs,并替换 password 等敏感字段
    return h.next.Handle(ctx, r)
}

这条边界很实际:Handler 链中如果前一个处理器意外改动了共享状态,后一个处理器可能看到与调用点不同的字段。先 Clone,再做脱敏、补充或重排,是更稳的处理顺序。

并发写入时谁来保证一条日志完整

内置 TextHandler 和 JSONHandler 在调用底层 io.Writer 前会加锁,使一条 Record 的输出保持完整;它们不会因为每条记录带时间戳就自动按时间排序。自定义 Handler 如果把多个 goroutine 的字节直接写入共享 Writer,就需要自行设计锁或串行写入队列。

建议把“字段处理”和“最终写出”分成两个阶段:先在 Handle 中完成级别判断、分组和脱敏,再用一个明确的写入边界提交完整缓冲区。这样既能避免半行交错,也不会把锁扩大到昂贵的业务计算。

func (h *safeHandler) Handle(ctx context.Context, r slog.Record) error {
    buf := h.format(r) // 先在本地构造完整记录
    h.mu.Lock()
    defer h.mu.Unlock()
    _, err := h.w.Write(buf)
    return err
}

三个容易踩到的边界

禁用级别也不会阻止参数先求值

日志调用里的参数会先求值,再由 Handler 判断是否输出。昂贵计算可以考虑使用 LogValuer 延迟到真正需要格式化时。

公共字段不等于全局字段

WithAttrs 返回的是带附加属性的新 Logger,不会把原 Logger 永久改成另一个状态。请求级 Logger 应在请求边界内传递,避免跨请求复用。

并发安全不包含业务对象快照

Handler 的写入锁只能保护日志输出本身,不能替调用方冻结一个正在变化的 map、slice 或指针对象。需要稳定日志值时,应在调用点先生成快照。

落地前的核对清单

  • 公共字段是否通过 WithAttrsLogger.With 绑定在正确的请求/模块范围?
  • 同名字段是否通过 WithGroup 分到明确的子系统命名空间?
  • 自定义 Handler 修改 Record 前是否执行 Clone
  • 共享 io.Writer 是否有完整记录级别的写入边界?
  • 是否用并发测试确认日志行没有互相穿插,而不是只看单线程样例?

相关问题

WithAttrs 和每次传键值参数怎么选?

稳定的请求、租户和模块信息用 WithAttrs;只在当前事件成立的字段随当前日志调用传入。

WithGroup 会改变 JSON 的字段类型吗?

它会改变字段的层级组织方式,JSONHandler 通常表现为嵌套对象,TextHandler 通常表现为带组名前缀的键。

自定义 Handler 必须自己加锁吗?

如果多个 goroutine 共享同一个 Writer,通常需要。内置 Handler 的写入保证不能自动传递给你自己的 Handler。

结语

把 slog 的字段问题拆成“谁创建 Record、谁附加属性、谁解释分组、谁负责写出”四个问题,设计就不会停留在 API 记忆层面。多数服务用内置 Handler 已经能获得完整记录写入;只有在脱敏、路由到多个后端或改变格式时,才需要谨慎扩展自定义 Handler。

参考资料

Go log/slog 官方文档Go 官方 Handler 源码

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