最简方案是用io.multiwriter组合os.stdout和日志文件句柄:先os.openfile(带o_create|o_append|o_wronly及0644权限),再io.multiwriter(file, os.stdout),最后log.setoutput。

用 io.MultiWriter 同时写文件和控制台最简方案
标准库 log 本身不支持多输出,log.SetOutput 只能设一个 io.Writer,覆盖前值;必须靠 io.MultiWriter 把多个写入器“缝”成一个。
核心就三步:
- 用
os.OpenFile打开日志文件,务必带os.O_CREATE | os.O_APPEND | os.O_WRONLY,否则每次启动都清空 - 把
os.Stdout和文件句柄一起传给io.MultiWriter,它返回一个新的io.Writer - 把这个组合写入器传给
log.SetOutput,之后所有log.Println都会同步落盘+打屏
示例代码里常见错误是忘记 defer file.Close(),或用错 flag(比如误传 0666 而非 0644),导致 Linux 下权限不足、日志静默丢失。
io.MultiWriter 的写入顺序和错误传播规则
io.MultiWriter 不是原子操作,它按参数顺序逐个调用每个子 Writer 的 Write 方法,一旦某个 Write 返回 error,立刻停止后续写入,且**不回滚已成功的写入**。
这意味着:
- 如果磁盘满,
file.Write失败,但os.Stdout.Write已成功——你看到终端有日志,文件却只写了一半 - 把慢或易挂的 Writer(如 HTTP 日志服务)放最后,至少保证本地文件先落盘
- 别对
io.MultiWriter直接调WriteString,它没实现该方法;统一用Write([]byte),避免某些自定义Writer行为异常 - 检查返回的
error:它只代表第一个失败的写入,不代表全部失败,也不代表前面的写入一定完整
动态启用/禁用输出目标时别手拼 io.MultiWriter
手动写 mw = io.MultiWriter(mw, newWriter) 容易漏赋值、重复包装、逻辑混乱,且每次调用都新建 wrapper,性能差、难维护。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
正确做法是先收集活跃的 io.Writer 到切片,再一次性展开:
- 用
map[io.Writer]bool或[]io.Writer管理开关状态,比用map[int]bool更直观 - 遍历筛选出 active 的 writer,
append进切片 - 切片为空时,显式设
log.SetOutput(io.Discard),防止nil导致 panic - 最后调
io.MultiWriter(ws...),Go 会自动展开切片
这种写法可读性强,增删目标只需改切片逻辑,不用动组合语句。
什么时候不该硬扛 io.MultiWriter
当需要按日志级别分流(比如 error 写文件、info 发 Kafka、debug 输出到 stderr),或者要求“全成功才提交”,io.MultiWriter 就不合适了。
它没有级别判断、不支持异步、不提供事务语义。此时应换结构化日志库:
-
zap的zapcore.NewTee可组合多个Core,每个绑定不同WriteSyncer和LevelEnabler -
zerolog提供LevelWriter类型,可为不同级别指定不同io.Writer - 自己写
LevelWriter也行,但必须处理并发安全、行边界截断、error 透传这三点,容易踩坑
真正容易被忽略的是关闭时机和权限——文件句柄不关,程序退出后日志可能丢失;目录无写权限,open /var/log/app/app.log: permission denied 会让整个日志系统静默失效,连报错都看不到。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










