这是我把一套 Java 日志配置迁到 Go 服务时的笔记。方案换过两次,代码大量是让 AI 写的,然后我从架构和并发正确性上一条条挑回去。被推翻的部分我留着,因为回头看,走错的地方比结论有用。
配置片段和参数取值都是为说明问题构造的示例,数值要按自己的日志量重新标定。
目录
共 10 节 · 点此折叠 / 展开
1. 起点:把一份 logback 配置拆成五件事
一个 Java 服务要重写成 Go,日志需求全写在 logback-spring.xml 里。我的第一反应是找”Go 里的 logback 等价物”——这个反应是错的。真正有用的第一步是把那份 XML 按职责拆开,拆完发现它混了五件本来不相干的事:
- 分级落盘:三个
RollingFileAppender,INFO / WARN / ERROR 各进一个文件。 - 轮转与保留:按小时切分并压成 gz,
maxHistory和totalSizeCap限份数和总量。轮转指写满或到点就把当前文件封存改名、另开一个继续写;保留指旧文件压不压缩、留多少份、留多久。 - 异步与背压:
AsyncAppender配有界队列,neverBlock=true表示队满直接丢,不阻塞业务线程。 - 概率采样:一个自定义
Filter按比例丢弃低级别日志,WARN 及以上一条不丢。 - 环境差异:
springProfile让 prod 写文件、dev 只打 stdout。
拆开之后关键的判断浮出来了:这五件事在 Go 里的归属完全不同。
轮转和保留是运维口径,硬编码进二进制意味着改一次保留时长要重新发版。分级落盘在结构化日志里其实是冗余的——JSON 里已经有 level 字段,error.log 天然是 info.log 的子集,物理拆三个文件只是把”按字段过滤”提前到了写入时。而异步和采样必须留在进程里,因为采集器只能加工已经写出来的行,它没法决定一条日志要不要被产生。
五件事里唯一带业务判断的是概率采样,逻辑一句话能说完:高 QPS 下 INFO 全量落盘扛不住,按概率丢;WARN 和 ERROR 全留。有的实现还会加个 marker 让个别日志无视采样,这个后门我最初照抄了,后来删了(第 8 节)。
这一步之后我对迁移的理解变了:目标不是把 XML 翻译成 Go,而是把它表达的需求在新的部署形态里重新落位。照搬会得到一个既不像 Java 也不像 Go 的东西——比如在已有采集器的 K8s 集群里,费力实现进程内按小时切分加 gz,然后采集器把这些 gz 又读一遍。
2. 两次被否:全外置方案,和 zap
第一版:能推出进程的都推出去。 按上面的拆解,结论很自然:进程只产出一条结构化事件流写到 stdout,路由、轮转、压缩、保留全交给节点上的采集器和后端。这是 12-factor 的日志观,也是容器日志模型的默认形态。进程内只剩两层装饰(脱敏、采样)加一个 JSONHandler;分流放到进程外,按字段而不是按文件:
[transforms.route] # Vector
type = "route"
inputs = ["app"]
route.errors = '.level == "ERROR"' # → 告警
route.audit = '.audit == true' # → 长期留存
# _unmatched → 对象存储做离线分析
好处很实在:改分流规则和保留时长都不用发版,一套采集规则对多语言服务一视同仁,日志部分零第三方依赖。我当时觉得这就是答案。
然后需求补了一句:不用考虑跨语言复用采集栈,但日志行为要和原来的 Java 服务贴近。 这一句直接把第一版推翻了——“贴近原有行为”意味着分级分文件、按小时轮转加压缩、有界异步队列,都得在进程里做出来,正好是反方向。
我一开始有点抵触,觉得是在往回走。想通的点是:第一版成立的前提是”环境里有采集器承接搬运和分流”,而这个前提是环境给的,不是我能选的。运维要求登机器就能 tail -f error.log,或者交付环境根本没有采集栈(自建机房和私有化交付里非常常见),那”进程只写 stdout”就不是简洁,而是把活儿丢给一个不存在的组件。
第二个被否的是 zap。 我让 AI 给企业级 Go 日志方案,它的第一选择是 zap:零分配、生态最全、lumberjack 和 OTel 桥现成、NewTee 做多 sink、NewSamplerWithOptions 的采样策略比概率采样聪明(首现场必留,刷屏才开始丢)、AtomicLevel 自带 HTTP handler 能动态改级别。方案本身没错,是主流答案。
没选它的理由不是性能,是这两条:
- 采样一开,性能差异基本吃不到。 量降到十分之一之后,handler 快 200ns 还是慢 200ns,在一条有几十毫秒预算的请求里不构成差别。我原本担心的顺序是错的:应该先把量降下来,再谈快慢,反过来会花大量时间优化一个不是瓶颈的地方。
- 要付出的在别处。 多三个依赖(zap、lumberjack、桥接层)要跟着升级和过安全扫描,而它省下的主要是我自写组件的工作量——那部分我愿意付,因为写完我知道每一行在干什么,出问题能改。
这个判断有前提:如果单进程吞吐已被日志实测成瓶颈,或团队没人愿意维护自写 handler,选 zap 是更负责的决定。
3. 真正的选型依据是 slog 的接口
拒掉 zap 之后我才想清楚,标准库 slog 值得作为默认的理由跟性能无关——它把日志 API 和日志实现彻底分开了。业务代码只依赖 *slog.Logger,背后是什么由一个四方法接口决定:
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
后果是选型不再是一次性赌注。上面方案换了两次(全外置 → 进程内复刻),调用点一行没改,因为改的全是 handler。真撞到性能墙,换一个桥到 zap 的 handler 也一样不动调用点。
这让我对”先简单,以后再说”的态度变了:在有接口兜底的前提下,它是个可以负责的决定,因为改回来的成本是可算的。附带好处是横切能力(采样、脱敏、上下文注入)都变成装饰器,各自单一职责、能单测、能开关。
另外两个细节后面反复用到,是我读文档时才注意到的:
Info(msg, ...)和InfoContext(ctx, msg, ...)的唯一区别是哪个 context 传进 handler——前者内部传context.Background()。Enabled在构造Record之前被调用,文档要求它廉价、无副作用;Handle才拿得到完整记录。级别判定属于前者,逐条采样属于后者。
这两条当时都没当回事,结果第 6、8 节各撞上一次。
顺带,四个候选里 logrus 可以直接排除(维护模式、不再引入新特性);zerolog 性质和 zap 接近,排除理由相同。
4. 三套方案,和一个决策顺序
前后过了三版,摊开对比才看得出各自适合什么。
| A:slog + 采集器 | B:slog 接口 + zap | C:进程内复刻 | |
|---|---|---|---|
| 第三方依赖 | 零 | zap + lumberjack + 桥接 | lumberjack |
| 热路径分配 | 中 | 低 | 中 |
| 采样 | 自写 | zap sampler 现成 | 自写 |
| 轮转 / 保留 | 采集器 + 后端 | 进程内 | 进程内 |
| 多路分流 | 采集器按字段路由 | NewTee | fanout |
| 改分流规则 | 不发版 | 改配置或发版 | 改配置重启 |
| 无采集器环境可用 | 否 | 是 | 是 |
| 自写组件正确性负担 | 低 | 低 | 高 |
| 换实现成本 | 低 | 低 | 低 |
选的时候按这个顺序问,多数情况两问之内有答案:
- 交付环境有没有日志采集基础设施? 没有就是 C。这是硬约束,不用往下讨论。
- 进程内多 sink 或进程内轮转是不是硬需求?(运维流程依赖登机器看分级文件、审计要求日志落本地盘留证)是就 C,或 B 的文件 sink。
- 单进程吞吐是否已被日志实测成瓶颈? 是就 B,不是就 A。
跑在 K8s 上的无状态服务基本都落在 A。
这个排序本身包含一个我改过的判断:性能是最后一个问题,不是第一个。 真正决定日志方案成败的是”出故障能不能查""峰值下会不会拖慢业务""账单能不能承受”,这三件事都不靠库快 30%。
表格最后一行也值得单独说:三套方案的换实现成本都低。我在前两版之间来回改时一直担心选错要重写,后来发现担心错了。
至于分级分文件,我现在的看法是它在结构化日志时代确实冗余,唯一价值是运维习惯——但运维习惯是真实成本,值得为它付费,只是要知道自己在为什么付费。
5. 哪些事推不出进程
三版之间来回改,慢慢收敛出一条线:有些事无论选哪套方案都必须留在进程内,因为采集器拿到的只是一行已经写出来的文本。
| 关注点 | 归属 | 原因 |
|---|---|---|
结构化字段、稳定的 msg | 进程内 | 下游一切路由和检索都靠字段 |
trace_id / request_id 注入 | 进程内 | 采集器无法从一行文本反推请求上下文 |
| 采样 | 进程内 | 见下 |
| PII 脱敏 | 进程内 | 明文出了进程,合规上就算已泄露到日志系统 |
| 按字段分流 | 基础设施 | 采集器的强项,进程不该关心去处 |
| 轮转、压缩、保留时长 | 基础设施 | 运维口径,进二进制就得发版才能改 |
采样这条我一开始以为”采集器过滤也一样”,算了一遍才发现差的是数量级:进程内丢弃只付一次级别判定的钱;全量写出再让采集器丢,要把参数求值、序列化、写 syscall、采集器解析全付一遍——成本全花在最贵的地方。另外”同一请求的日志要么全留要么全丢”这种一致性,只有进程内做得到。
脱敏那条的理由不是技术而是合规口径:在采集器里脱敏等于信任采集链路上每一跳,多数审计不接受;而明文一旦落到日志后端,清理要走数据删除流程。
一句话概括分工:进程内负责生成与内容,基础设施负责搬运与分流。 方案 C 是这条原则的例外,但那是被迫的——环境里没有能承接搬运分流的组件,进程只好兼任。
方案 C 的装配形态是一条装饰链:
业务代码 (*slog.Logger)
▼
sampleHandler INFO 按概率减量,WARN/ERROR 全量
▼
fanoutHandler 按级别分带分发(对应三个 appender)
├─ leveledHandler[INFO, WARN) → JSON → asyncWriter → lumberjack(info.log)
├─ leveledHandler[WARN, ERROR) → JSON → asyncWriter → lumberjack(warn.log)
└─ leveledHandler[ERROR, +∞) → JSON → asyncWriter → lumberjack(error.log)
有个让我意外的发现:逐项复刻 logback 不需要换掉标准库。分带用自定义 leveledHandler(slog 原生 Enabled 只有下限,没有区间),轮转用 lumberjack 当 io.Writer,按小时切用一个对齐整点的 ticker 调 Rotate(),异步用有界 channel 加一个 drain goroutine。库层只多了一个 lumberjack。
代价也清楚:这些组件全是自己写的,正确性得自己担——下一节就是账单。
6. 自定义 Handler 的四个坑
装饰链的骨架我让 AI 写,它给的形状是对的,但第一版有个必现的 bug。四个坑里前两个出自官方的 writing slog handlers,第三个我追问出来,第四个我在指南和博客里都没见人提。
坑一:装饰会被静默剥离。 AI 给的 leveledHandler 内嵌了 slog.Handler 且没重写 WithAttrs / WithGroup:
type leveledHandler struct {
slog.Handler // 内嵌 + 未重写派生方法 = bug
min, max slog.Level
}
看着自然,我差点用了。问题是没重写时派生方法是提升方法,log.With("k", "v") 实际调的是被内嵌的下游 handler,返回下游的类型——外层这层壳就此丢了。业务代码只要 With(...) 一次,分带过滤和采样全失效,退化成裸 JSONHandler。而 New 里本身就有 .With("service", ...),也就是说它一上线就生效,不是偶发并发问题,是必现的,而且不报错,只是安静地不干活。
修法是每个装饰 handler 都显式重写、重新包回自己。顺带养成一个习惯:用 next slog.Handler 字段而不是内嵌——内嵌的”方便”正是这个 bug 的来源,显式字段会让编译器提醒你漏了方法。
坑二:派生时共享切片。 fanoutHandler 里存的是 []slog.Handler,派生时如果共享同一个底层数组或对它 append,高并发下多个派生 logger 会互相串数据甚至触发 data race。必须 make 全新切片,逐个调子 handler 的 WithAttrs。
坑三:Enabled 短路会漏解。 聚合层如果按直觉写成”所有子 handler 都 enabled 才放行”,就会把只该进某一路的日志在源头掐掉——error.log 那一路要求 level >= ERROR,于是所有 INFO 在根上被否决,连 info.log 都写不进去。正确形状是 Enabled 取并集,各子 handler 在 Handle 里二次过滤:
func (h *fanoutHandler) Enabled(ctx context.Context, l slog.Level) bool {
for _, c := range h.children {
if c.Enabled(ctx, l) { // 任一放行即放行
return true
}
}
return false
}
func (h *fanoutHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, c := range h.children {
if !c.Enabled(ctx, r.Level) { // 二次过滤:各带各的判定
continue
}
// Clone:某一路若要 AddAttrs,不能写进兄弟节点看到的同一份记录
if err := c.Handle(ctx, r.Clone()); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (h *fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h // 指南要求:空属性原样返回
}
children := make([]slog.Handler, len(h.children)) // 全新切片,不共享不 append
for i, c := range h.children {
children[i] = c.WithAttrs(attrs)
}
return &fanoutHandler{children: children}
}
由此推出两条约束:分带判定放在 leveledHandler.Enabled,内层 JSONHandler 给最宽松的级别,否则两处 level 判定互相打架;采样绝不能放进 Enabled——它每次结果不同、有副作用,只能在 Handle 里做。上面那个 r.Clone() 还顺带处理了指南另一条要求:Handle 收到的 Record 不得原地修改,它的属性底层是共享数组。
坑四:异步入队必须拷贝字节。 这个来源不在 slog 而在 io.Writer 的约定:Write 不得保留 p,调用方返回后就能复用那块缓冲——而 slog 的内建 handler 正是从 buffer pool 取缓冲、写完归还。所以直接 queue <- p 是错的,症状是日志行错乱、串行、截断,只在有并发且队列积压时出现,本地基本测不出来。入队前必须 copy 一份。
前三个坑其实是一件事的三层,收敛成一条契约就好记了:handler 构造完成后内部状态永不改变,派生一律产生新实例。 满足这条,整条链不用加任何锁就是并发安全的。
验收要点是”派生”和”并发”的组合——单独派生不出问题,单独并发也不出问题,只有两者叠加才暴露状态污染。测试跑在 -race 下,断言写成精确相等而不是”大于零”:ERROR 不采样,任何一行的缺失都是 bug,不给自己留”可能是正常波动”的解释空间。
7. 骨架与配置面
包放在 internal/observability/logging,对外只暴露一个 logger 和一个管后台工作的 manager:
logging/
├── logging.go // New(Options) (*slog.Logger, *Manager, error)
├── fanout.go // fanoutHandler + leveledHandler
├── sample.go // sampleHandler
├── async.go // asyncWriter
└── rotate.go // lumberjack sink + 整点轮转
logger, logs, err := logging.New(cfg.Log)
if err != nil {
return err
}
defer logs.Close() // 停 ticker、flush 队列、关文件
slog.SetDefault(logger)
返回一对值而不是一个 logger,是因为这个包启动了 goroutine、打开了文件,这些资源得有主人,而 *slog.Logger 上没地方挂 Close。
几个设计选择值得记:
- 分带用半开区间(
[INFO,WARN)、[WARN,ERROR)、[ERROR,+∞))而不是照 logback 逐级精确匹配。精确匹配有缝:将来出现一个自定义级别(如NOTICE = INFO+2)会凭空消失,区间覆盖能让它落进 INFO 带。 - 采样率用函数而非值(
rate func() float64),每条读一次,能接到配置中心,线上排障时临时调高。随机源用math/rand/v2的顶层函数,它对并发调用安全,省掉自己管锁——AI 最初给的版本自己塞了*rand.Rand+sync.Mutex,能用但没必要。 - 只采样 INFO。Java 那版把 WARN 一起采样了,这是我明确改掉的一处,理由见第 8 节。
- asyncWriter 的锁不是为了保护 channel(channel 本身并发安全),而是消除”
Close关了 channel、另一个 goroutine 正要往里发送”这个 panic 窗口。这一处是我 review 时补的,第一版没有锁,Close和Write并发就崩。
配置面直接对着第 1 节拆出来的五件事:
log:
level: ${LOG_LEVEL:-info} # 只认 info | warn | error
path: ${LOG_PATH:-/var/log/app} # 空 = 只写 stdout;非空 = 分级文件
sampleRate: ${LOG_SAMPLE_RATE:-0.1} # 只作用于 INFO
rotation: { everyHour: true, maxBackups: 24, maxSizeMB: 128, compress: true }
async: { queueSize: 1024, neverBlock: true }
path 空/非空正好表达了环境差异,不必再引一个 profile 字段——开发态不设 LOG_PATH 就是 stdout 单流,生产挂载了目录就是分级文件。少一个概念。
这里差点出一次静默回归。 sampleRate 原来是 float64,而它的缺省零值是 0,意思是一条 INFO 都不留。哪个环境漏写这一行,线上就只剩 WARN 和 ERROR,而且没人会立刻发现——少写日志本来就是看不见的。改法是用指针区分”未设置”和”显式设为零”:
type Log struct {
Level string `yaml:"level"`
Path string `yaml:"path"`
// 指针而非 float64:缺省这个 key 时默认全量,
// 而不是默认 0——后者会静默丢掉一个漏配环境的所有 INFO。
SampleRate *float64 `yaml:"sampleRate"`
}
由此得到一条给自己的规矩:缺配置时选那个”浪费资源”而不是”丢数据”的方向。 磁盘涨了会有人发现,日志少了不会。
关闭顺序有依赖,反了就丢日志或者 panic:先停 ticker(否则可能在文件关闭后调 Rotate()),再关 asyncWriter(close(queue) 后等 drain 排空),最后关文件。Close 要用 sync.Once 做成幂等,因为它常同时出现在 defer 和信号处理里。另外两处是我 review 时才发现的:装配中途失败会泄漏 goroutine(建第三个文件失败时前两个的 drain 已在跑,直接 return err 就把它们留在那儿),以及日志要在最后关(main 里 defer 后进先出,logs.Close() 应尽早 defer,这样其他组件关闭过程中打的日志还能落盘)。
最后一件是让丢弃可见。 neverBlock 的语义是队满就丢,而丢日志本身是静默的:磁盘变慢或日志量突增时,进程悄悄丢掉一部分记录,而你翻日志是发现不了”日志少了”的——少的正是没写出来的那些。所以计数器不是可选项,用 Prometheus 的 CounterFunc 在抓取时读已有的原子计数,热路径一行都不用多碰。告警规则 rate(app_log_dropped_total[5m]) > 0 的含义是”从现在起日志不完整了”,这个信号必须在事发时就到人手上。同一思路下还值得暴露队列深度、按级别的写入速率、轮转次数——ERROR 速率突增是我见过最好用的故障信号之一。
8. 后来删掉的三样东西
骨架跑通之后,接下来做的事出乎我意料:大部分是在删,不是在加。 这三样都是我自己(或 AI 建议、我同意)先加上去,用了一段才判断它们不该在这儿。
上下文注入。 最先加的是一个 ctxHandler,从 ctx 取 request_id、trace_id 贴到每条日志上,也就是 Java MDC 的等价物。写完发现它是死代码——因为第 3 节那个细节:log.Info(...) 内部传的是 context.Background(),只有调用点写成 InfoContext(ctx, ...) 才生效,而代码里满地都是普通调用。
接着我把入口和调用点全改成 XxxContext,删掉七八处手写的重复字段,看起来很漂亮。但用了一段之后我整个删掉,回到统一的普通调用。理由不是技术上哪个更强,而是:这个能力要求全团队记住”有 ctx 就带上”,忘了不会报错,只会安静地少几个字段。最后的状态一定是一半日志有 request_id、一半没有——那时排障最难受,因为你无法判断某条日志缺字段是因为它不在请求路径上,还是因为有人忘了写。
两种选择都站得住:要”用一个 ID 把一次请求的日志全捞出来”就付那个心智成本,要”只有一种写法”就放弃自动关联,改成在少数关键日志上手写。真正错的是混用。 我选了后者,因为这个服务的排障主要靠指标和链路;换个场景我可能选反。
绕过采样的后门。 Java 那版的 marker 我照抄成了 logging.Force()。删掉的理由有两层:表层是容易被滥用,一旦热路径上每条 INFO 都 Force 就等于把采样关了;深层是我后来意识到,一个”必须每条都在”的事件本身就不该是 Info——它要么是 Warn(真的有人该知道),要么该是一条指标(要的是计数和趋势)。这个后门存在的时候,它在掩盖一个级别用错的问题。
Debug 级别,以及对 WARN 的采样。 级别最后只留 info / warn / error,配置里写 debug 直接启动失败——不悄悄改成 info,那样更难查。WARN 不再采样:Info 是过程信息,海量且大多可有可无;Error 是缺陷,一条不能丢;而 Warn 是一次成功的降级,本身就该是低频事件。如果 WARN 多到需要采样,说明系统真的在大面积降级——这时候最不该做的就是把证据丢掉。WARN 的量本身就是一个信号,采样会把它抹掉。
这一节是我从整件事里得到的最有用的一条经验。让 AI 出方案,它给的东西总是”能力齐全”的:ctx 注入、动态级别端点、脱敏、OTel 桥、多路 sink、采样绕过,一次全给你。这些能力单看都对,也都有场景。但工程决策里有一半是决定不做什么,而这半边它给不了——它不知道你的团队会不会记得用、你的排障主要靠什么、你愿意为一个能力付多少长期维护成本。
顺便记下当时想加、最后只留了缝的几件事:
- 动态改级别的端点值得做(线上把级别临时调到 debug,重启一次现场就没了)。
Manager暴露内部的*slog.LevelVar挂个端点即可。这里纠正一个流传很广的说法:slog.LevelVar并没有实现http.Handler,那是zap.AtomicLevel的能力——AI 给我的第一版就说”一行挂载”,查文档才发现不对,用 slog 得自己写十几行。另外这是管理端点,只能挂内部端口。 - PII 脱敏三种做法:类型上实现
LogValuer(最 Go、最难绕过,比如DeviceID打印成稳定哈希,既能对上同一设备的日志又不带出个人标识)、ReplaceAttr按字段名拦截、或加一层redactHandler。实践上前两者并用比较稳,因为总有人写slog.String("ifa", raw)绕过类型。 - 审计日志单独一路。它的要求和诊断日志正好相反:不能采样、不能丢、要留很久。给它 fanout 里一条不挂 sampler、不挂
neverBlock的通道(宁可阻塞也不能丢)。让审计和诊断共享同一套丢弃策略,是合规审计里很常见的失分点。 AddSource只给 ERROR 开。文件行号要走runtime.CallersFrames,有实际开销,而分带路由天然能做差异化。这是分带除了分文件之外的一个附带好处。- 收口第三方库的日志。依赖树里总有库只接受
*log.Logger,用slog.NewLogLogger(logger.Handler(), slog.LevelWarn)反向桥回去。我一开始觉得可有可无,后来在采集端看到一堆解析失败才明白——一个没被收口的库会用自己的格式往 stdout 写非 JSON 的行,把整条管道的解析器搞脏。
9. 热路径、背压与写法规矩
一条日志从调用到落盘有六步:级别判定、参数求值、构造 Record、序列化、写 fd、采集器解析。我原本盯着”构造和序列化”优化,实际值得关注的是另外两步。
参数求值常常最贵,且和用哪个库完全无关。 一个 Debug 即使被级别关掉,参数也已经算完了:
log.Debug("candidate set", "candidates", string(mustJSON(candidates))) // Marshal 无论如何都执行
if log.Enabled(ctx, slog.LevelDebug) { // 先问级别,再付代价
log.Debug("candidate set", "candidates", string(mustJSON(candidates)))
}
写 fd 是唯一上限不可预测、也唯一能让日志拖垮业务的一步。 这里有个我一度信了的说法:“只写 stdout 所以不用担心阻塞”——不对,容器的 stdout 是有限缓冲的 pipe,采集器消费不过来时写入方一样会阻塞,和写文件没有本质区别。
所以背压按三层来防,顺序很重要:先源头减量(采样,写不出去的问题先从少写解决),再进程外扩缓冲(让采集器宁可丢也不要背压上游,如 Vector 的 buffer.when_full = "drop_newest"),最后才是进程内有界异步队列。
第三层是个明确取舍:队满就丢日志,而不是阻塞业务。对有硬性回包时限的服务(RTB 里交易所在请求里直接给出 tmax,超时的响应等于没响应),丢一条日志远好过错过时限。但前提是丢弃必须可见,否则最坏情况是你翻日志看到”没有异常”,而真相是异常那段正好被丢了。也得说清楚什么时候不该上异步队列:日志量低(每秒几百条以内)同步写完全扛得住;服务没有严格延迟预算;以及不能容忍进程崩溃时丢队列里的日志(审计日志尤其)。我一开始想直接上,后来想明白它带来的是”日志丢在进程内""关机丢日志""多一个 goroutine 要管生命周期”三重复杂度,就改成默认不上、被实测逼到再上。
三个库的绝对数字我没有可信实测,所以不给——日志性能高度依赖字段数量、字符串长度、writer 是什么。要测的话有两个我踩过的点:一定要跑 RunParallel(串行 benchmark 看不出互斥锁和单 drain goroutine 的瓶颈,而线上是并发的);io.Discard 只能测到序列化那一步,评估真实吞吐得写到真实 sink,还要故意让 sink 变慢看丢弃计数怎么涨——那才是背压设计的验收方式。
方案再好,落不到约定上就会漂移。这套东西上线后我真正写下来的规矩:
级别语义,判据是”谁该做什么”而不是”代码走进了哪个分支”。Error 要人介入;Warn 是一次成功的降级,还能服务但得有人知道;Info 是生命周期事件(启动、关闭、配置加载、模型热更);Debug 生产关闭。最常被误用的是 Error——一个被正确处理并降级的下游超时是 Warn,写成 Error 会让告警被淹掉,然后这个级别整体失去意义。这不是风格问题,是会直接毁掉告警体系的问题。
消息与字段:msg 用稳定的小写短语,变量一律进 K/V,不用 fmt.Sprintf 拼消息(既分配又让日志无法按 msg 聚合)。字段名 snake_case 且稳定,复用既有名字,别造同义词(err / error / errMsg 三个并存是真实会发生的)。构造用 LogAttrs 加强类型属性(slog.Int、slog.Duration)而不是 ...any,避开装箱分配;固定字段用 With 预绑定一次。
热路径禁忌:成功分支不打 Info 及以上;Debug 一律包在级别判断里;每请求的降级、丢弃、命中走指标不写日志。最后这条是最省钱的一条——很多团队日志量失控的头号原因,是用日志做了本该由指标做的事:
log.Warn("budget exhausted, request dropped", "campaign_id", id) // QPS 一万就是一万条/秒
metrics.Rejected("budget_exhausted") // 常数成本,可告警、可画趋势
判据很简单:想知道”比率、分布、趋势”是指标;想知道”这一次为什么这样”才是日志。
还有一条边界我一开始没意识到:诊断日志和业务事件日志是两回事。前者是启动、降级、异常、排障细节,丢一条只是少一点线索;后者是曝光、点击、计费、转化,丢一条就是少一笔钱。两者投递语义根本不同——诊断日志是 best-effort,可以采样、队满可丢;业务事件要 at-least-once 加下游幂等,通常客户端直发 Kafka,或走 Flume → Kafka → Flink 这条链路。本文所有”采样""满则丢”的设计,前提都是只作用于诊断日志。 一旦业务事件走了这条通道,等于把计费准确性押在日志管道的可用性上。
至于 logger 怎么拿:组件通过构造函数注入 *slog.Logger 存成 log 字段,nil 兜底 slog.Default(),不在函数里凭空 slog.New(...)。这条在《Go 工程目录》里讲过,不重复。
10. 参考
- log/slog 官方文档 ——
Handler和Attr的文档注释里写着不少别处找不到的约束。 - Writing slog handlers —— 第 6 节的坑一到坑三都出自这里,自定义 handler 之前读它能省掉我走的弯路。
- Structured Logging with slog —— 设计动机和性能取舍的第一手说明。
- uber-go/zap —— 就算不用,
zapcore的Tee和Sampler也值得读源码。 - natefinch/lumberjack —— 进程内轮转的事实标准,注意它只按大小切。
- The Twelve-Factor App · Logs / Vector 文档 —— 第一版方案的理论来源和采集层模型。
- Apache Flume 与广告日志不丢链路 —— 业务事件那条管道怎么做到不丢。
回头看
改了三版方案,删掉的功能比留下的多。最后落在纸上的结论有三条。
一是把职责放对位置比选对库重要得多。 采样、脱敏、上下文注入必须在进程内,因为采集器只能加工已经写出来的行;轮转、保留、分流该在进程外,因为它们是运维口径。我第一版之所以被推翻,不是技术判断错了,是它依赖的那个前提(环境里有采集器)不由我决定。
二是把”日志会丢”当一等公民。 采样会丢、队满会丢、进程崩溃会丢,这些取舍本身都合理,问题在于丢得静默。所有丢弃都要能计数、能告警,否则你会在最需要日志的时候,看着一段”什么都没发生”的日志得出错误结论。
三是选型没那么可怕。 业务只依赖 *slog.Logger,前后换两次实现调用点一行没动。真正难改的只有两样:字段命名(它是给采集器和报表的契约,改名不会有编译错误但会静默改坏下游)、脱敏边界(明文一旦写出去,清理要走数据删除流程)。这两件事值得第一天就做对,其余的都可以以后再缝。
一句操作性的建议:从一个 slog.NewJSONHandler(os.Stdout, ...) 开始,让每一层装饰都由一个真实出现过的问题驱动。我加过的那些”企业级应该有”的能力,最后删掉的比留下的多。