第7章:日志系统
本章分析 internal/bootstrap/logger.go:zap 核心如何组装、lumberjack 如何做文件轮转、格式与级别如何配置,以及全仓库统一的日志书写范式。日志是长任务系统唯一的「黑盒观测窗口」,这里的每个选择都服务于排障效率。
流程
日志初始化发生在 main 的第二位(仅次于配置)。InitLogger(cfg) 的流水线是:解析级别字符串(非法值回落 Info)→ 落实三个默认值(文件路径 logs/base.log、单文件 10MB、保留 10 份历史)→ 确保日志目录存在 → 组装 encoder(console 或 JSON,默认 JSON)→ 把 lumberjack.Logger 作为 zap 的 WriteSyncer → zapcore.NewCore 组合出 logger。
输入是 LoggerConfig{Level, Format, Filename, MaxSize, MaxBackups},输出是一个全局 *zap.Logger,随后注入到所有 Worker、service、draft 流水线。运行期所有关键路径都打结构化字段:job_id(任务号)、live_url、step(草稿步骤名)、removed(清理数量)——排障时按 job_id 过滤即可重放一次任务的完整轨迹。
轮转策略由 lumberjack 承担:单文件超过 MaxSize(MB)自动切分,按 MaxBackups 保留最新历史文件,超出删除。Docker 部署时 logs/ 目录挂载到宿主机 docker/logs/,容器重建日志不丢。
实现
实现只有 67 行,但细节讲究:
- 级别解析容错:
level.UnmarshalText失败静默回落 Info——日志系统绝不能因为自己的配置错误把主服务打挂,这是基础设施的「自我牺牲」原则。 - 默认 JSON 格式:生产环境
APP_LOGGER_FORMAT=json(compose 中显式声明),便于采集系统解析;本地调试可切console换取可读性。一个 encoderCfg 双格式复用(NewProductionEncoderConfig+ISO8601TimeEncoder)。 zap.AddCaller() + zap.AddStacktrace(ErrorLevel):所有日志带调用位置,仅 Error 级别附堆栈——常态低开销,事故时有现场。logger.Sync()在 main 里 defer(带//nolint:errcheck):退出前刷缓冲;错误被忽略是因为进程退出时 Sync 报错无处理价值。- 优雅降级约定:全仓库接受
*zap.Logger的构造函数都在 nil 时换zap.NewNop()(scheduler、draft、各 Worker 无一例外),调用方可以不关心日志,测试无需构造 logger。
GORM 的 SQL 日志同样在 bootstrap 交给 gormlogger.Default.LogMode(gormlogger.Info)(database.go),慢查询与错误 SQL 会进入同一输出体系。
📌 设计决策
- 文件轮转而非直写 stdout:单体部署下简单直观;代价是容器最佳实践(日志走 stdout 由 runtime 收集)没有遵循,若迁 K8s 需把 core 换为
zapcore.AddSync(os.Stdout),改动点只有一行。 - 不引入集中式日志(ELK 等):部署复杂度优先;ISO8601 时间 + JSON 格式已为未来接入留好接口。
- 每个 Logger 消费点都要求显式传 logger(而非全局单例):依赖清晰、单测可注入 Nop,代价是构造函数签名变长。
代码示例
完整的 core 组装——zap + lumberjack 的标准结合方式:
// internal/bootstrap/logger.go(节选)
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.ToLower(cfg.Format) == "console" {
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg) // 默认 JSON
}
// 日志轮转:单文件最大 10MB,最多保留 10 个历史文件
fileWriter := &lumberjack.Logger{
Filename: filename,
MaxSize: maxSize,
MaxBackups: maxBackups,
LocalTime: true,
}
core := zapcore.NewCore(encoder, zapcore.AddSync(fileWriter), level)
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
业务侧的结构化书写范式(draft 流水线实例):
// internal/draft/builder.go(节选)
b.Logger.Info("草稿裁剪前合并相邻片段",
zap.String("job_id", req.JobID),
zap.Int("clips_before", beforeMerge),
zap.Int("clips_after", len(clips)),
zap.Int64("merge_gap_ms", prepare.ClipMergeMS),
)
小结
- 日志 = zap core(级别 + encoder + lumberjack writer),默认 JSON + 文件轮转。
- 全仓库结构化字段命名统一(job_id / step / removed...),按 job_id 可重放任务轨迹。
- nil 换 Nop 的防御式约定让「不关心日志」成为合法选择。
思考题
- 容器化最佳实践要求日志走 stdout,本章方案的迁移成本与保留理由如何权衡?
- 若要为每次 LLM 调用记录 token 消耗,应在哪个层(llm 包 or service 层)加字段,为什么?
项目信息
- GitHub仓库:github.com/Chyona/live-mixer
- 项目案例:gogoshine.com