本文详解为何多个 goroutine 共享 log.SetOutput() 会导致日志全部写入最后一个文件,并提供基于独立 log.Logger 实例的正确实现方案,确保每个 LogWorker 写入专属日志文件。
在 Go 的并发编程中,处理多个日志协程(LogWorker)共享同一个通道的场景并不少见,但一个常见的坑是:开发者习惯性地复用全局日志对象,结果发现所有协程的日志最终都挤进了同一个文件。问题出在哪?答案藏在 Go 标准库 log 包的设计里,而解决方案也远比想象中简单。
先分析一下根本原因。Go 标准库的 log 包默认使用一个全局变量(log.std)来管理默认的 Logger。当你在代码中调用 log.SetOutput() 或 log.SetFlags() 时,实际上是在直接修改这个全局实例。这意味着,如果多个 LogWorker 的 Work() 方法并发执行,并且每个都调用了 log.SetOutput(&lumberjack.Logger{...}),那么后执行的 goroutine 会毫不犹豫地覆盖前一个设置的输出目标。最终,所有 log.Println() 调用都会流向最后一个被设置的 lumberjack.Logger,结果就是只生成了一个日志文件(比如 event_3),而其他 worker 的日志彻底失踪。
那么,正确的做法是什么?核心思路很简单:为每个 worker 创建独立的 log.Logger 实例,而不是复用全局 logger。来看看修改后的 LogWorker.Work 方法:
func (lw *LogWorker) Work(evChannel <-chan Event) { fmt.Printf("LogWorker started: %s\n", lw.FileName) // ✅ 为每个 worker 创建专属 logger,避免全局污染 lg := log.New(&lumberjack.Logger{ Filename: lw.FileName, MaxSize: lw.MaxSize, MaxBackups: lw.MaxBackups, MaxAge: lw.MaxAge, }, "", 0) // prefix 为空,flag 为 0(不加时间戳等) // ✅ 使用 range 遍历通道,支持优雅关闭 for event := range evChannel { lg.Println(Csv(event)) }}
这段代码有几个关键改进点值得细说:
- 独立的 logger 实例:log.New() 返回的是一个全新的 *log.Logger,每个 worker 持有自己完全隔离的日志输出对象,互不干扰。这一点是解决所有问题的根本。
- 只读通道参数:将 evChannel chan Event 改为 evChannel <-chan Event,语义上明确为“仅接收”,不仅提升了代码的可读性,也增强了安全性——调用方一眼就能看出这个通道是用来消费的,而非生产。
- 用 range 替代 for { <-ch }:这是一个经常被忽视的细节。当通道被关闭时(例如程序退出时需清理资源),range 循环会自动退出,避免了 goroutine 泄漏。而传统的无限 for { <-ch } 模式,在通道关闭后会持续接收零值,如果不检查 ok 标志,甚至可能引发 panic。
- 移除 log.SetFlags(0) 的副作用:这个调用原本会影响全局 logger,但有了专属 logger 之后,flag 可以直接在 log.New() 中指定,干净利落。
补充几点实践经验:在 main() 中,建议通过 sync.WaitGroup 来管理 worker 的生命周期,确保服务关闭前所有日志都已经落盘。如果业务场景需要动态增减 worker 数量,可以考虑使用带缓冲的 channel,或者结合 select + default 实现非阻塞写入,避免日志队列满时导致请求丢失。生产环境中,日志错误处理也不可忽视——比如检查 lg.Output() 返回的 error 并触发告警。另外,如果对性能有更高要求,或者需要结构化日志输出,可以考虑用 zap 这类日志库替代 log + lumberjack 的组合,灵活性和效率都会更上一层楼。
经过这样的重构,四个 LogWorker 将严格按预期分别写入 event_0、event_1、event_2、event_3 四个独立文件,真正实现并发日志分流。从根源上避免全局状态污染,这才是并发场景下日志处理的正确姿势。