因为log.Printf默认同步阻塞写入,goroutine会卡在write系统调用上;正确做法是用带缓冲channel解耦生产消费,前置序列化、控制缓冲大小、实现背压与安全退出。

goroutine 写日志为什么不能直接用 log.Printf 丢进去?
因为 log.Printf 默认写到 os.Stderr 或 os.Stdout,是同步阻塞的——如果磁盘慢、日志量大、或输出被重定向到网络日志服务(如 syslog),goroutine 会卡在 write 系统调用上,导致堆积、内存暴涨甚至 OOM。你不是在“异步写”,只是“换个 goroutine 堵着写”。
用 chan + goroutine 构建简单日志队列
核心思路:把日志内容塞进带缓冲的 channel,后台 goroutine 持续消费并调用真实写入逻辑。这样调用方几乎不阻塞(只要 channel 有空位)。
示例关键片段:
type LogEntry struct {
Level string
Message string
Time time.Time
}
<p>var logChan = make(chan LogEntry, 1000) // 缓冲区大小需权衡:太小易丢日志,太大吃内存</p><div class="aritcle_card flexRow">
<div class="artcardd flexRow">
<a class="aritcle_card_img" href="/xiazai/gongju/2525" title="Go语言(Golang)1.26.0"><img
src="https://img.php.cn/upload/manual/001/589/237/6a6adeed24a4a355.png" alt="Go语言(Golang)1.26.0" onerror="this.onerror='';this.src='/static/lhimages/moren/morentu.png'" ></a>
<div class="aritcle_card_info flexColumn">
<a href="/xiazai/gongju/2525" title="Go语言(Golang)1.26.0">Go语言(Golang)1.26.0</a>
<p>Go语言(Golang)1.26.0版本官方下载,版本号 1.26.0,适合旧项目维护、兼容性测试和指定版本开发环境搭建。</p>
</div>
<a href="/xiazai/gongju/2525" title="Go语言(Golang)1.26.0" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span> </a>
</div>
</div><p><span>立即学习</span>“<a href="https://pan.quark.cn/s/00968c3c2c15" style="text-decoration: underline !important; color: blue; font-weight: bolder;" rel="nofollow" target="_blank">go语言免费学习笔记(深入)</a>”;</p><p>func init() {
go func() {
for entry := range logChan {
// 这里才是真正的同步写入点(比如写文件、发 HTTP)
fmt.Printf("[%s] %s: %s\n", entry.Time.Format("15:04:05"), entry.Level, entry.Message)
}
}()
}</p><p>func AsyncLog(level, msg string) {
select {
case logChan <- LogEntry{Level: level, Message: msg, Time: time.Now()}:
// 成功入队
default:
// channel 满了,丢弃或降级处理(比如打到 stderr)
fmt.Fprintf(os.Stderr, "[DROP] %s: %s\n", level, msg)
}
}-
make(chan LogEntry, 1000)的缓冲大小要结合峰值 QPS 和单条日志耗时预估;1000 是常见起点,非银弹 -
select {... default: ...}防止调用方死锁,必须有兜底逻辑 - 不要在
LogEntry中存指针或闭包(比如fmt.Sprintf结果应提前算好),避免 goroutine 消费前对象被 GC 或修改
为什么别自己造轮子?优先考虑 zap 或 zerolog 的异步模式
这两个主流库都内置了异步选项,但实现细节差异大:
-
zap的zapcore.NewCore+zapcore.NewSampler不等于异步;真异步得配zap.AddCallerSkip(1)+zap.WrapCore手动套 goroutine,或者用社区封装如uber-go/zap的AsyncWriteSyncer -
zerolog更轻量,默认就是无锁、无分配设计;开启异步只需zerolog.New(os.Stdout).With().Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stdout, Async: true})—— 注意:Async: true是它自己的 goroutine 封装,底层仍是chan+ 消费循环 - 它们都做了你容易忽略的事:日志字段序列化前置、避免 panic 泄露、OOM 保护(如采样丢弃)、信号量控制并发写入数
生产环境绕不开的三个坑
哪怕用了 zap 或自建 channel,这些点一漏就出事:
- 日志时间戳必须在入队前生成(
time.Now()),否则消费时取会偏差几毫秒到几秒,排查问题时序错乱 - channel 关闭后未消费完的日志会永远卡住——务必在程序退出前调用
close(logChan)并等待 goroutine 退出(用sync.WaitGroup或context控制生命周期) - 多 goroutine 同时写同一个文件句柄(如
os.File)会乱序,除非加锁或每个日志行以\n结尾且文件打开时带O_APPEND标志(Linux 下 append 是原子的)
异步日志真正的复杂点不在“怎么启 goroutine”,而在“怎么不丢、不错、不崩、不拖垮主流程”。缓冲区、背压策略、退出清理、时间一致性,一个都不能少。

















