os.file 不能并发写因 write 非原子,系统调用可能被抢占致日志错乱;需单 goroutine 统一处理滚动与写入,用 dup 或缓冲确保不丢日志;lumberjack 较稳妥但须显式 close。

为什么 os.File 不能直接在多个 goroutine 中并发写?
因为 os.File 的 Write 方法不是原子操作,底层调用系统 write() 时可能被调度抢占,导致日志行错乱(比如两行日志交叉写入同一行)。这不是 Go 的 bug,而是文件描述符共享写入的固有问题。
- 即使加了
sync.Mutex,也只解决写入顺序,不解决滚动时的文件句柄竞争 - 用
log.SetOutput指向一个全局*os.File,多个 goroutine 调用log.Println就会触发该问题 - 常见现象:日志中出现截断、乱码、重复时间戳,或
write: bad file descriptor错误
用 io.MultiWriter + 单 goroutine 日志分发是否可行?
可行,但仅适合中小流量场景。核心思路是把所有日志写请求通过 chan string 推给一个专属 goroutine,由它统一处理滚动和写入。
- 滚动判断必须在该 goroutine 内完成:检查文件大小、时间、是否需要 rename + reopen
- 不要在接收端做
os.Stat—— 频繁 stat 影响性能,建议用计数器 + 定时器双校验 -
io.MultiWriter可用于同时输出到文件和os.Stdout,但注意:它不会帮你做缓冲,写失败会直接返回 error - 示例关键片段:
go func() { for line := range logCh { if shouldRotate() { rotateFile() } _, err := file.Write([]byte(line)) if err != nil { /* 记录错误但不 panic */ } } }()
如何安全地滚动日志文件而不丢日志?
关键不是“rename”,而是“原子替换 + 句柄保留”。Linux 下推荐用 os.Rename + os.OpenFile(..., os.O_APPEND|os.O_CREATE|os.O_WRONLY) 组合,但要注意旧文件句柄仍有效。
- 滚动时先
os.Rename(old, new),再file.Close(),最后os.OpenFile(...)创建新句柄 —— 这中间有窗口期,新日志可能丢失 - 正确做法:用
syscall.Dup复制当前文件描述符,在 rename 后继续往旧 fd 写,等写完再 close;或者更简单:滚动后立刻用新 fd 写,旧 fd 保持打开直到当前批次写完(需 channel 缓冲) - Windows 下
os.Rename对正在写的文件会失败,必须先file.Close()再 rename,此时必须确保无 goroutine 正在写 —— 所以单 writer goroutine 是跨平台前提 - 别依赖
log.Lshortfile等 flag 在滚动时自动刷新 —— 它只影响格式,不参与 IO 控制
要不要用第三方库如 lumberjack?
如果项目允许引入外部依赖,lumberjack 是目前最稳妥的选择,但它默认不支持按小时滚动,且 Rotate 方法不是并发安全的 —— 必须确保只在一个 goroutine 调用。
- 它的
Write方法内部已加锁,可直接传给log.SetOutput,无需额外封装 - 注意设置
MaxSize(单位 MB)和MaxAge(天),LocalTime: true才按本地时区切分 - 若需按小时滚动,得自己 fork 修改
rotate逻辑,或改用zap+lumberjack组合(zap的WriteSyncer可桥接) - 真正容易被忽略的是:
lumberjack.Logger的Close()必须显式调用,否则进程退出时可能丢最后一段缓冲日志
滚动日志的难点不在协程数量,而在文件生命周期管理与写入时机的耦合 —— 一个没关掉的 fd、一次没等完的 write、一个没清空的 buffer,都可能让日志消失。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











