直接替换gin.DefaultWriter会导致日志丢失,因其被gin.Logger()初始化时缓存引用,且裸*os.File并发写不安全;必须在gin.New()/Default()前设置,并用io.MultiWriter或lumberjack等线程安全封装。

gin.DefaultWriter 直接替换会导致日志丢失
很多人一上来就写 gin.DefaultWriter = fileWriter,以为这样就能把日志写进文件。但实际运行后发现:部分请求没日志、重启后日志断层、甚至 gin.Logger() 中间件完全不输出。根本原因是 gin.DefaultWriter 是一个全局变量,而 Gin 的 gin.Logger() 在初始化时会缓存它的一份引用;后续即使你改了变量值,已注册的中间件仍用旧的 Writer。更危险的是,多个 goroutine 并发写同一个 *os.File 时若没加锁或没用线程安全封装,会出现内容错乱或截断。
- 必须在调用
gin.New()或gin.Default()之前完成 Writer 设置 - 不要直接赋值
os.File,要用io.MultiWriter包裹控制台 + 文件,或用带缓冲/轮转能力的 writer(如lumberjack.Logger) - 如果用了
gin.LoggerWithConfig,它的Output字段优先级高于DefaultWriter,此时改DefaultWriter完全无效
用 lumberjack 实现按大小+天数双策略滚动切分
lumberjack 是目前 Gin 生产环境最稳妥的日志轮转方案,它原生支持文件大小限制、保留份数、最长保存时间三个维度,且所有操作原子、线程安全。它不是“按日期命名”,而是“按需切割 + 自动清理”,更适合高并发微服务本地部署场景。
- 设置
MaxSize: 100(单位 MB),单个日志文件超限时自动切新文件 - 设
MaxBackups: 7,最多保留 7 个历史文件,超出的自动删除 - 设
MaxAge: 7 * 24 * time.Hour,文件存在超 7 天也自动清理,和MaxBackups是“或”关系 - 务必关闭
Compress: false(默认 false),Gin 日志是纯文本,压缩后无法用tail -f实时查看 - 路径必须确保父目录存在,
lumberjack不会自动创建多级目录,需提前os.MkdirAll("logs", 0755)
gin.LoggerWithConfig 配合 zap 实现结构化双输出
仅靠 Gin 默认日志格式太简陋,没法打字段、没法区分业务模块。推荐用 zap 替代默认 logger,再通过 gin.LoggerWithConfig 把 zap 的 SugaredLogger 封装成 Gin 能识别的 io.Writer。这样既能保留 Gin 请求日志的字段(方法、路径、状态码、耗时),又能输出 JSON 结构,方便本地用 jq 快速过滤。
- 用
zapcore.AddSync(&lumberjack.Logger{...})构建 file sink,再用zapcore.AddSync(os.Stdout)构建 console sink - 组合两个 sink 时,注意
zapcore.NewTee比io.MultiWriter更可靠——它能保证每个写入操作都同步到所有目标,不会因某个 sink 阻塞而卡住整个请求 -
gin.LoggerWithConfig的Output必须设为zap.SugaredLogger.Desugar().Core()包装后的 writer,不能直接传SugaredLogger - 别忘了关掉默认颜色:
gin.DisableConsoleColor(),否则写进文件的 ANSI 字符会变成\u001b[36m这类乱码
本地调试时保留终端实时输出但禁用颜色
开发阶段一边改代码一边看终端日志是最高效的,但直接输出到文件又看不到实时流。这时候最容易踩的坑是:用 io.MultiWriter(os.Stdout, file) 后,终端里全是颜色字符,ssh 连上去满屏乱码,或者日志文件里混着 \033 序列,grep 失效。
- 正确做法是:判断当前输出是否为 TTY,只对
os.Stdout做颜色开关,不对文件做任何处理 - 用
isTerminal := stdout.Fd() == 0 && isatty.IsTerminal(stdout.Fd())(需引入github.com/mattn/go-isatty) - 如果非 TTY(比如重定向到文件、CI 环境、systemd journal),就强制用
gin.DisableConsoleColor(),并确保gin.LoggerWithConfig的Formatter不含颜色逻辑 - 别依赖
os.Getenv("TERM")判断,Docker 容器里这个环境变量经常为空或不准
本地日志滚动切分真正难的不是配参数,而是让多路输出(终端+文件)、多策略(大小+时间)、多格式(文本+JSON)在不丢日志、不阻塞请求、不污染内容的前提下稳定共存。每加一层封装,就要多验一次 writer 是否线程安全、是否 flush 及时、是否在 panic 时仍能落盘——这些细节在压测或突发流量下才会暴露。











