Skip to content
Charles Shao
Go back

Go 日志设计笔记:从照搬 logback 到删掉一半设计

–views

这是我把一套 Java 日志配置迁到 Go 服务时的笔记。方案换过两次,代码大量是让 AI 写的,然后我从架构和并发正确性上一条条挑回去。被推翻的部分我留着,因为回头看,走错的地方比结论有用。

配置片段和参数取值都是为说明问题构造的示例,数值要按自己的日志量重新标定。


目录

共 10 节 · 点此折叠 / 展开
  1. 起点:把一份 logback 配置拆成五件事
  2. 两次被否:全外置方案,和 zap
  3. 真正的选型依据是 slog 的接口
  4. 三套方案,和一个决策顺序
  5. 哪些事推不出进程
  6. 自定义 Handler 的四个坑
  7. 骨架与配置面
  8. 后来删掉的三样东西
  9. 热路径、背压与写法规矩
  10. 参考

1. 起点:把一份 logback 配置拆成五件事

一个 Java 服务要重写成 Go,日志需求全写在 logback-spring.xml 里。我的第一反应是找”Go 里的 logback 等价物”——这个反应是错的。真正有用的第一步是把那份 XML 按职责拆开,拆完发现它混了五件本来不相干的事:

  1. 分级落盘:三个 RollingFileAppender,INFO / WARN / ERROR 各进一个文件。
  2. 轮转与保留:按小时切分并压成 gz,maxHistory 和 totalSizeCap 限份数和总量。轮转指写满或到点就把当前文件封存改名、另开一个继续写;保留指旧文件压不压缩、留多少份、留多久。
  3. 异步与背压:AsyncAppender 配有界队列,neverBlock=true 表示队满直接丢,不阻塞业务线程。
  4. 概率采样:一个自定义 Filter 按比例丢弃低级别日志,WARN 及以上一条不丢。
  5. 环境差异: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,选 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 也一样不动调用点。

这让我对”先简单,以后再说”的态度变了:在有接口兜底的前提下,它是个可以负责的决定,因为改回来的成本是可算的。附带好处是横切能力(采样、脱敏、上下文注入)都变成装饰器,各自单一职责、能单测、能开关。

另外两个细节后面反复用到,是我读文档时才注意到的:

这两条当时都没当回事,结果第 6、8 节各撞上一次。

顺带,四个候选里 logrus 可以直接排除(维护模式、不再引入新特性);zerolog 性质和 zap 接近,排除理由相同。


4. 三套方案,和一个决策顺序

前后过了三版,摊开对比才看得出各自适合什么。

A:slog + 采集器B:slog 接口 + zapC:进程内复刻
第三方依赖零zap + lumberjack + 桥接lumberjack
热路径分配中低中
采样自写zap sampler 现成自写
轮转 / 保留采集器 + 后端进程内进程内
多路分流采集器按字段路由NewTeefanout
改分流规则不发版改配置或发版改配置重启
无采集器环境可用否是是
自写组件正确性负担低低高
换实现成本低低低

选的时候按这个顺序问,多数情况两问之内有答案:

  1. 交付环境有没有日志采集基础设施? 没有就是 C。这是硬约束,不用往下讨论。
  2. 进程内多 sink 或进程内轮转是不是硬需求?(运维流程依赖登机器看分级文件、审计要求日志落本地盘留证)是就 C,或 B 的文件 sink。
  3. 单进程吞吐是否已被日志实测成瓶颈? 是就 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。

几个设计选择值得记:

配置面直接对着第 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、采样绕过,一次全给你。这些能力单看都对,也都有场景。但工程决策里有一半是决定不做什么,而这半边它给不了——它不知道你的团队会不会记得用、你的排障主要靠什么、你愿意为一个能力付多少长期维护成本。

顺便记下当时想加、最后只留了缝的几件事:


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. 参考


回头看

改了三版方案,删掉的功能比留下的多。最后落在纸上的结论有三条。

一是把职责放对位置比选对库重要得多。 采样、脱敏、上下文注入必须在进程内,因为采集器只能加工已经写出来的行;轮转、保留、分流该在进程外,因为它们是运维口径。我第一版之所以被推翻,不是技术判断错了,是它依赖的那个前提(环境里有采集器)不由我决定。

二是把”日志会丢”当一等公民。 采样会丢、队满会丢、进程崩溃会丢,这些取舍本身都合理,问题在于丢得静默。所有丢弃都要能计数、能告警,否则你会在最需要日志的时候,看着一段”什么都没发生”的日志得出错误结论。

三是选型没那么可怕。 业务只依赖 *slog.Logger,前后换两次实现调用点一行没动。真正难改的只有两样:字段命名(它是给采集器和报表的契约,改名不会有编译错误但会静默改坏下游)、脱敏边界(明文一旦写出去,清理要走数据删除流程)。这两件事值得第一天就做对,其余的都可以以后再缝。

一句操作性的建议:从一个 slog.NewJSONHandler(os.Stdout, ...) 开始,让每一层装饰都由一个真实出现过的问题驱动。我加过的那些”企业级应该有”的能力,最后删掉的比留下的多。


–views
Share this post on:

Previous Post
AI Agent 是什么:拆解模型、工具、记忆与调度循环
Next Post
Go 工程目录:企业级项目结构与设计规范