
为什么 log.SetOutput 直接换文件会导致日志丢失
Go 标准库的 log.Logger 默认不缓冲,写入时直接调用 Write 方法。如果在日志写入中途用 log.SetOutput 切换到新文件,旧文件句柄可能已被关闭,而当前 goroutine 正在执行 Write,就会触发 write: bad file descriptor 或静默丢弃日志。
根本原因是:标准 log 不支持原子切换输出目标,也没有写入重试或队列机制。
- 别在日志写入热点路径中调用
log.SetOutput - 避免手动管理
*os.File的Close()和OpenFile()交替 - 真正安全的做法是让日志写入始终面向一个稳定的、线程安全的中间层(比如带锁的 writer 或 channel)
用 lumberjack.Logger 实现开箱即用的滚动切割
lumberjack 是最轻量、最可靠的第三方方案,它把文件操作、大小/时间判断、归档压缩全封装在一个 io.WriteCloser 里,和标准 log 完全兼容。
关键配置项含义要清楚:
-
Filename:必须是绝对路径,相对路径在 daemon 场景下容易因工作目录变化失效 -
MaxSize(单位 MB):触发切割的单文件最大尺寸,设为100比设为0(禁用)更可控 -
MaxAge(单位天):归档文件保留天数,和MaxBackups冲突时优先遵守MaxAge -
LocalTime:设为true才按本地时区切分(否则用 UTC),对排查问题更友好
示例初始化:
lw := &lumberjack.Logger{
Filename: "/var/log/myapp/app.log",
MaxSize: 100,
MaxBackups: 7,
MaxAge: 28,
LocalTime: true,
}
log.SetOutput(lw)
自定义滚动逻辑时,为什么必须用 os.O_APPEND 且禁止截断
手动实现切割时,常见错误是每次打开新文件都用 os.O_CREATE | os.O_WRONLY,这会清空已有内容;或者用 os.Truncate 清零,导致正在写的日志被截断。
正确做法只有一条:所有日志写入必须走 os.O_APPEND 模式,且永不主动 Truncate 当前活跃文件。
- 切割动作本质是:关闭旧文件 → 重命名旧文件(如加时间戳)→ 用
os.O_CREATE | os.O_WRONLY | os.O_APPEND打开新文件 - 重命名必须原子(
os.Rename在同分区下是原子的),避免出现“文件不存在但日志还在写”的窗口期 - 如果跨分区迁移归档,需先
Copy再Remove,并确保失败时回滚旧文件名
高频写入场景下,lumberjack 的性能瓶颈在哪
lumberjack.Logger 本身是同步写磁盘的,没有内置 buffer 或 goroutine 转发。当单秒日志量超过 5k 行(尤其每行 >1KB),Write 调用会成为瓶颈,表现为 CPU 占用不高但日志延迟升高。
这时不能简单加锁或换 buffer —— 因为 lumberjack 的 Write 内部已含文件检查与切换逻辑,加锁反而扩大临界区。
- 推荐组合:用
chan string+ 单独 goroutine 批量写入,再把该 goroutine 的输出接给lumberjack - 注意控制 channel 缓冲大小(如
make(chan string, 1000)),避免 OOM - 务必处理 goroutine panic 和 channel close 后的写入,否则日志静默丢失
真正的难点不在切割逻辑本身,而在「切割时机判定」和「写入不丢不重」之间的权衡——前者要低开销感知状态,后者要求写入路径完全避开竞争。这两个目标天然有张力。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











