你的日志会"开口报警"吗?从追踪 ID 到事件回调
排查线上问题时,你是不是经常遇到这样的场景:日志打了一大堆,可真要定位某次请求发生了什么,只能靠肉眼在成千上万行里翻?怎么才能把同一次请求的日志串起来追踪?又怎么在出错的第一时间收到通知?
这篇文章聊聊 goddd 脚手架里的日志方案:它以 slog 为门面、zap 为内核,外加一套事件回调机制,把"日志追踪"和"错误告警"变成了几行配置的事。
为什么是 slog + zap 的组合
Go 1.21 之后,标准库有了 log/slog,结构化日志终于有了官方写法。但 slog 只是"接口层",真正干活的是背后的 Handler。
goddd 的做法是:对业务代码暴露 slog,底层用 zap 的 Core 来写日志。这样有两个好处:
- 业务代码写的是标准库 API,不绑定任何第三方库,以后想换实现也不用心疼;
- 写日志的性能由 zap 保证——它是 Go 生态里出了名的"零分配"选手。
桥接两者的靠的是 zap 官方的 zapslog 适配器,具体实现可以看 pkg/logger/logger.go 里的 SetupSlog 函数。
性能:快,而且不乱刷
日志库的性能损耗主要来自两处:格式化和磁盘写入。goddd 在这两处都做了处理。
第一,JSON 编码交给 zap。 zap 的编码器全程避免反射和多余的内存分配,同一条日志比标准写法快不少。对业务方来说无感——你只管调 slog.Info,快是底层的事。
第二,内置采样器(Sampler)。 想象一下:某个接口出了 bug,每秒刷一万条相同的错误日志,磁盘很快就被塞爆,真正有用的日志反而被淹没。采样的思路很朴素:
每个时间窗口内,前 N 条相同的日志照常记录,超出的部分每 M 条才记一次。
默认值是每秒前 5 条全记、之后每 5 条记 1 条,可以通过 Config.SetSampler 调整。配置定义在 pkg/logger/config.go。
第三,日志文件自动轮转。 按文件大小和时间双重切分,超期的旧日志自动清理,不用自己挂 logrotate。这部分用的是 timberjack 库,配置项(保留天数、单文件大小、是否压缩)都在 FileConfig 里,同样有默认值,零配置可用。
事件:让日志"开口说话"
普通日志库只管"写",写完就结束了。但有些场景我们希望日志能触发动作:
- 出现 Error 级日志时,立刻推送一条告警到钉钉/企微;
- 出现 Warn 级日志时,往监控面板塞一个指标。
goddd 的做法是参考了 service 项目的事件机制:在 Handler 外面包一层,写日志之前先按级别分发回调。用法是这样的:
cfg := logger.NewDefaultConfig().SetEvents(logger.Events{
OnError: func(ctx context.Context, r slog.Record) {
// 异步推告警、发钉钉,随便你
},
})
log, cleanup := logger.SetupSlog(cfg)
defer cleanup()
四个级别(Debug/Info/Warn/Error)各有一个回调,按需注册,没注册的级别零开销。
有两个细节值得说道:
- 回调是同步执行的。 如果回调里要发网络请求,记得自己
go出去异步处理,不然会拖慢打日志的链路。 - 回调拿到的
slog.Record是值拷贝,但内部数据是共享的。 想把 record 存起来异步用,先调record.Clone(),否则可能和后续日志产生数据竞争。
实现不长,推荐直接读源码:pkg/logger/slog.go 的 Handle 方法,加上 pkg/logger/events.go 一共也就几十行。
这里还踩过一个值得警惕的坑:包装 Handler 时必须自己实现 WithAttrs 和 WithGroup。否则业务代码一调 log.With("service_id", "xxx"),拿到的就是"脱壳"后的原始 Handler,事件回调会静默失效——不报错、不警告,就是再也不触发了。这类问题在源码注释里也标了出来。
自定义:上下文字段与日志追踪
回到开篇的问题:怎么把同一次请求的日志串起来?
答案是给每条日志都带上 trace_id。但手动每条都写 slog.String("trace_id", ...) 显然不现实。goddd 提供了一个小工具 WithAttrs:把字段挂到 context.Context 上,之后用这个 ctx 打的日志会自动带出这些字段:
// 在请求入口处挂一次
ctx = logger.WithAttrs(ctx,
slog.String("trace_id", traceID),
slog.String("user_id", userID),
)
// 之后不管调用链多深,日志里都有这两个字段
slog.InfoContext(ctx, "创建订单成功")
Web 中间件里已经内置了这套逻辑:每个请求进来自动生成 trace_id 挂进 ctx,业务代码用 InfoContext 系列方法打日志就天然带上了追踪标识。排障时拿 trace_id 一搜,一次请求的完整链路立刻浮现。实现见 pkg/logger/slog.go 末尾的 WithAttrs,中间件用法见 pkg/web/log.go。
这里有一个容易踩的坑。context.Context 设计上不可变,每次挂字段都要基于旧列表生成新列表。如果直接在旧 slice 上 append,当底层数组容量充足时就会原地写入——从同一个 ctx 派生出的两个"兄弟 ctx"各自挂字段时,会共享同一个底层数组,后写的一方覆盖先写的一方的数据,并发场景下还会触发数据竞争,且这类问题极难排查。
所以 WithAttrs 内部用标准库的 slices.Concat 拼接新旧字段:它始终分配新数组承载结果,从根上切断了共享。这也是 slog 官方文档对同类场景的建议做法。
除了上下文字段,常用自定义都在 Config 的链式方法里:
cfg := logger.NewDefaultConfig().
SetDir("./logs"). // 日志目录
SetLevel("info"). // 最低级别
SetService("id", "name", "v"). // 服务标识,每条日志都带
SetMaxAge(30) // 保留 30 天
NewDefaultConfig() 给了一套开箱即用的默认值,从零开始到落盘写日志只需要两行代码。
程序崩了,现场还在
普通日志记的是"你让它记的事",可程序 panic 闪退时,最关键的那段堆栈往往只打到 stderr,容器一重启就灰飞烟灭。
Go 1.23 在 runtime/debug 包里新增了一个不太起眼的函数:SetCrashOutput。它的作用是:告诉 Go 运行时,程序崩溃时把堆栈信息额外写进指定文件。哪怕是 fatal error 这种彻底救不回来的崩溃,现场也会被完整保留。
goddd 在 SetupSlog 里已经帮你接好了:启动时打开日志目录下的 crash.log,注册为崩溃输出,之后的 panic 堆栈自动落盘,和日志文件放在一起。实现见 pkg/logger/logger.go。
不喜欢 zap?整个内核都能换
slog 是接口层,意味着底层实现随便换。goddd 把事件分发逻辑做成了一个通用包装器 logger.New:给它任何 slog.Handler,它都能加上事件回调的能力:
// 比如想换 zerolog(需借助 samber/slog-zerolog 适配器)
log := slog.New(logger.New(
slogzerolog.Option{Logger: zerolog.New(w)}.NewZerologHandler(),
logger.WithEvents(events),
))
// 关键一步:覆盖全局默认 logger,否则第三方库的 slog 调用不会走你的实现
slog.SetDefault(log)
最后那行 slog.SetDefault 千万别漏——你精心配置的 logger 只有设为全局默认之后,散落在各处的 slog.Info(...) 才会真正走它。
这样做的好处
- 快:zap 内核 + 采样器,高并发下不拖后腿、不刷爆磁盘;
- 活:事件回调让日志从"死记录"变成"触发器",告警、监控随手接;
- 省心:追踪 ID 经 ctx 自动透传,排障不靠肉眼翻日志;
- 稳:接口层是标准库 slog,业务代码与实现解耦。
完整代码都在 pkg/logger 目录下,连同测试一起也就几百行,建议对照本文读一遍,收获会比看文章大。
关于 goddd
goddd 是一个 AI 驱动的 Go 语言脚手架,基于领域驱动设计(DDD)思想搭建,提供了一套适合中小项目快速启动的工程结构和最佳实践。
如果你想了解更多细节,可以访问:
- 官方文档站点:https://goddd.golang.space/
- GitHub 仓库:https://github.com/ixugo/goddd