在 Go 中使用多个 goroutine 并发消费同一个 channel,这本是日常开发里的常规操作,但稍不留神就容易翻车——尤其是跟共享状态打交道的时候。你碰到的那个怪现象:“所有日志只写进了第四个文件”,真相并不是 channel 分发逻辑出了岔子,而是对 Go 标准库 log 包的使用踩了坑。
问题出在哪?log.SetOutput() 和 log.SetFlags() 这两个方法,修改的是全局、包级的默认 logger(也就是 log.Default() 返回的那个实例)。当 4 个 LogWorker.Work() 方法依次跑起来之后,每个方法都会调一次 log.SetOutput(...),把全局 logger 的输出目标换成自己的 lumberjack.Logger。最后执行的那个 goroutine(通常是第 4 个)的设置会留下来,覆盖掉前面三个。其余 goroutine 虽然也在老老实实地调 log.Println(...),但实际输出的目标早就被最后那个覆盖成了同一个——第四个文件。这就是为什么你只看到一个日志文件里有内容,其余三个文件干干净净。
✅ 正确的解决方案其实不复杂:给每个 goroutine 创建一个专属的 *log.Logger 实例,别再动全局 logger 了。把 LogWorker.Work 方法改成下面这样:
func (lw *LogWorker) Work(evChannel <-chan Event) {
fmt.Printf("LogWorker started: %s\n", lw.FileName)
// ✅ 为本 goroutine 创建独立 logger
lg := log.New(&lumberjack.Logger{
Filename: lw.FileName,
MaxSize: int64(lw.MaxSize) * 1024 * 1024, // 注意:lumberjack.MaxSize 单位是字节
MaxBackups: lw.MaxBackups,
MaxAge: lw.MaxAge,
}, "", log.LstdFlags) // 可选:加个时间戳之类的标志
// ✅ 使用 range 遍历 channel,优雅退出
for event := range evChannel {
lg.Println(Csv(event))
}
}
这段代码有四个关键改动值得注意:
- 独立 logger:
log.New(...)返回一个全新的实例,每个 goroutine 各持一份,互不干扰,彻底切断全局状态的污染。 - channel 类型注解:参数改成了
<-chan Event(只接收通道),语义更清晰,也防止误写操作。 - range 代替 for-select:
for event := range evChannel在 channel 关闭后会自动结束循环,避免for {}空转;写法也更符合 Go 的惯用风格。 - 单位修正:
lumberjack.MaxSize接收的是字节数,原代码里传的是int(MB),需要转成字节int64(lw.MaxSize) * 1024 * 1024,否则日志轮转可能会失效。
⚠️ 还有几个细节需要留意:
- channel 关闭管理:上面代码没有展示如何关闭
evChannel。生产环境中,建议在服务关闭前(比如收到 SIGINT 信号时)显式关闭 channel,通知所有 worker 退出。可以配合sync.WaitGroup等待 worker 结束。 - 错误处理增强:如果
Csv(event)有可能 panic 或返回 error,那在lg.Println()之前最好加一层防御性检查。 - 性能考量:
lumberjack.Logger本身是线程安全的,不需要额外加锁。但如果 CSV 序列化开销比较大,可以考虑在 worker 内部做批处理——比如先缓冲 N 条记录,再一次性写入。
经过上面这番改造,4 个 LogWorker 就能真正并行、独立地消费 evChannel,各自把日志写到指定的文件里(比如 event_0、event_1……),实现负载分散和日志隔离,再也不会互相干扰了。