直接用log.Printf会阻塞主线程,因其默认同步写入os.Stderr/Stdout,磁盘慢或输出卡住时业务逻辑被拖慢;标准库无内置异步能力,需换zap/zerolog等库或自研带背压控制的异步方案。

为什么直接用 log.Printf 会阻塞主线程
Go 标准库的 log.Printf 默认写入 os.Stderr 或 os.Stdout,底层调用 Write 是同步阻塞的。当磁盘慢、日志量大或输出目标(如文件、网络)卡住时,log.Printf 会拖慢业务逻辑,尤其在高并发 HTTP handler 或定时任务中容易暴露问题。
这不是日志“没异步”,而是你没把它真正推到后台——标准库本身不提供内置异步能力,必须自己封装或换库。
- 别指望
log.SetOutput换个io.Writer就自动异步:只要那个Writer.Write方法是同步的,照样卡 -
log.SetFlags(log.LstdFlags | log.Lshortfile)这类设置不影响阻塞行为,只影响格式 - 用
go log.Printf(...)手动起 goroutine?风险极高:日志顺序乱、panic 时 goroutine 可能漏打、缓冲无控、OOM 风险
用 zerolog + sync.Pool 实现轻量级非阻塞日志
zerolog 是目前 Go 社区最主流的零分配、高性能结构化日志库,它原生支持异步模式,且不依赖外部队列组件。
关键不是“开了 async 就万事大吉”,而是要配对使用 zerolog.NewConsoleWriter 和 zerolog.Async(),并注意 buffer 控制:
立即学习“go语言免费学习笔记(深入)”;
- 启用异步:用
zerolog.New(zerolog.Async().Writer),它内部用带缓冲 channel + 单 goroutine 消费,保证顺序和可控性 - 避免内存泄漏:channel 缓冲区大小必须显式设(默认 100),高吞吐场景建议
zerolog.AsyncWithSize(1000) - Writer 必须是线程安全的:比如
os.Stderr本身是 safe 的,但自定义 file writer 要加sync.Mutex - 示例初始化:
logger := zerolog.New(zerolog.AsyncWithSize(500).Writer).With().Timestamp().Logger()
自研简易异步 logger:什么时候该自己写
如果你项目不允许引入第三方依赖,或者只需要极简文本日志(无结构、无 level 分离),可以手写一个带背压控制的异步 wrapper。
核心是两个要素:有界 channel + 消费 goroutine + 写失败降级策略:
- channel 容量设为 1024 或根据 QPS 估算(比如每秒 1k 日志,留 3 秒缓冲 = 3000)
- 消费 goroutine 必须 recover panic,否则日志 goroutine 崩溃后 channel 堵死,后续所有日志全丢
- 写失败时不能直接 panic 或忽略,应 fallback 到同步直写 + 打 warning(避免雪崩)
- 不要用
fmt.Sprintf在生产者 goroutine 拼接字符串——这会把格式化开销留在业务路径上;建议把原始参数传过去,在消费者侧格式化
常见踩坑点:异步日志关机时丢失、panic 时不输出
异步日志最大的隐性成本是生命周期管理:goroutine 不会自动等你优雅退出,panic 也不会触发 flush。
必须显式处理:
- 进程退出前调用
logger.Sync()(zerolog)或 close channel + wait goroutine(自研),否则最后几百条日志永远卡在 buffer 里 - HTTP server 关闭时,要在
srv.Shutdown后加日志 flush,而不是 defer 在 handler 里 - 全局 panic hook(
recover)里记录日志,必须用同步方式(如直接os.Stderr.WriteString),因为此时异步 channel 可能已关闭或阻塞 - 测试时用
time.Sleep(10 * time.Millisecond)等 flush 完全不可靠;要用sync.WaitGroup或 channel signal 确认消费完成
异步不是加个 goroutine 就完事,它把问题从“慢”转移到了“时序控制”和“资源清理”上——这两处最容易在线上静默丢日志。


















