根本原因是log.setoutput()修改全局logger,导致多goroutine竞争;正确做法是各worker创建独立log.logger实例,并通过单个后台goroutine串行写入日志文件。

为什么直接用 log.Println 会写错文件
多个 goroutine 同时调用 log.Println 却写进了同一个日志文件(或最后一个 worker 的文件),根本原因不是 channel 不行,而是你调用了 log.SetOutput() —— 它修改的是标准库 log 包的全局 logger。所有 goroutine 共享这一个实例,最后执行 SetOutput 的那个 worker 就“赢了”,后续所有 log.Println 都流向它的文件句柄。
✅ 正确做法是:每个 worker 自己构造独立 log.Logger 实例,不碰全局状态:
lg := log.New(&lumberjack.Logger{
Filename: lw.FileName,
MaxSize: lw.MaxSize,
MaxBackups: lw.MaxBackups,
MaxAge: lw.MaxAge,
}, "", 0)
- 不要复用
log.Default()或调用log.SetOutput - 每个 goroutine 拿到的
lg是完全隔离的,底层*os.File互不干扰 - 如果文件路径相同,仍需注意操作系统级文件竞争(见下一条)
多个 goroutine 写同一文件名是否安全
不安全。即使每个 goroutine 拿到自己的 *os.File,只要打开的是同一个文件路径(比如都用 "app.log"),就可能因系统缓冲、调度时机、写入偏移不一致导致内容交错,例如:
"ReqID:123" 和 "status:200" 被拼成 "ReqID:st200" —— 这种损坏无法靠 Go 层面锁完全规避。
✅ 唯一稳妥方案是让写入行为串行化:
- 用一个带缓冲的 channel(如
make(chan string, 1024))接收所有日志字符串 - 启动**唯一一个**后台 goroutine,
for msg := range logChan循环写入文件 - 所有业务 goroutine 只做
logChan ,不碰文件 - 配合
sync.Once+os.OpenFile(...os.O_CREATE|os.O_APPEND)安全初始化文件
用 mutex + bufio.Writer 能不能凑合
可以,但限制明显:只适合低频日志(每秒几百条以内),且极易踩坑。
关键约束条件:
- 必须用
bufio.NewWriterSize(file, 4096)显式指定缓冲大小,避免默认 4KB 在小日志中滞留 - 每次写完必须立刻
writer.Flush(),否则多条日志可能粘连在缓冲区里被一起写出 -
Flush()可能返回 error,必须检查,不能忽略 - 锁内只做
WriteString+WriteRune('\n')+Flush(),禁止格式化、网络调用等耗时操作 - 若日志量突增,
mutex会成为瓶颈,goroutine 大量阻塞在Lock()
简单场景可用,但不如 channel 方案清晰、可扩展。
生产环境别手写,优先用 zap 或 zerolog
zap 和 zerolog 底层都默认采用“ring buffer + 单 writer goroutine”模型,天然并发安全,且做了大量性能优化(如无反射序列化、内存池复用)。
两行就能启用:
logger := zap.New(zapcore.NewCore(
zapcore.NewJSONEncoder(zapcore.EncoderConfig{}),
zapcore.AddSync(&lumberjack.Logger{Filename: "app.log"}),
zapcore.InfoLevel,
))
⚠️ 容易被忽略的点:
- zap 默认开启 caller 报告,高并发下字符串拼接和栈遍历开销不小,建议关掉:
zap.AddCallerDisable() - zerolog 的
With().Timestamp()是无锁的,但自定义字段若含指针或 map,需确保线程安全 - 无论用哪个库,轮转文件(
lumberjack)必须用os.O_APPEND打开,否则多进程/多实例仍可能覆盖
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











