在高并发的Golang服务里,每来一个请求就同步写一条日志,表面看没什么,但一旦磁盘IO变慢或者日志量暴涨,业务Goroutine就会被卡在写文件上。用Goroutine配合channel把日志生产和消费拆开,是解决这个问题最直接的办法。下面先看整体结构,再逐步把代码补全。

为什么需要异步日志
标准库里的log包默认是同步写入的,调用log.Println时会直接往目标io.Writer里写。如果writer是文件,就会触发系统调用,在负载高的时候这个调用可能耗时几毫秒甚至更久。业务代码本来该快速返回响应,结果却排队等磁盘,整体延迟上升得非常明显。
另一个问题是,当多个Goroutine同时写同一个文件而没有加锁或统一出口时,日志内容会互相穿插,甚至出现半行日志。把写动作收拢到一个后台Goroutine,不仅能削峰填谷,还能天然保证写入顺序和完整性。这就是异步日志的核心价值:解耦业务与IO,控制写入并发。
基于channel和Goroutine的基础实现
最简单的模型是:业务侧把日志字符串发到带缓冲的channel,一个专门的consumer Goroutine从channel读出来并写到文件。channel的缓冲大小决定了内存里能暂存多少条日志,避免瞬时高峰直接压垮接收方。
下面这段代码展示了最小可用版本。注意我们用了sync.WaitGroup来跟踪consumer是否结束,这样程序退出前可以等它把channel里剩下的日志写完。
package main
import (
"log"
"os"
"sync"
"time"
)
type AsyncLogger struct {
ch chan string
wg sync.WaitGroup
file *os.File
}
func NewAsyncLogger(path string, bufSize int) (*AsyncLogger, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
a := &AsyncLogger{
ch: make(chan string, bufSize),
file: f,
}
a.wg.Add(1)
go a.consume()
return a, nil
}
func (a *AsyncLogger) consume() {
defer a.wg.Done()
for msg := range a.ch {
// 简单写入,生产环境可加批量缓冲
a.file.WriteString(msg + "n")
}
a.file.Close()
}
func (a *AsyncLogger) Log(msg string) {
// 非阻塞发送,缓冲满时业务可自行决定丢弃或阻塞
a.ch <- msg
}
func (a *AsyncLogger) Close() {
close(a.ch)
a.wg.Wait()
}
func main() {
logger, err := NewAsyncLogger("app.log", 1024)
if err != nil {
log.Fatal(err)
}
defer logger.Close()
for i := 0; i < 5; i++ {
logger.Log("hello async log " + string(rune('A'+i)))
}
time.Sleep(time.Millisecond * 100)
}
上面的Log方法直接往channel里塞,如果缓冲满了就会阻塞调用方。在Web接口里这可能不是好主意,所以可以改成select加default来丢弃日志,或者把缓冲设大一些。consumer里是一条一条写,后面我们会讲批量写来减少系统调用。
这种写法已经能解决多协程竞争文件的问题,因为只有一个Goroutine在真正调WriteString。不过当日志量非常大,频繁的系统调用还是贵,下一步就是攒批。
批量落盘提升性能
每次写一条都进内核,上下文切换成本太高。更好的做法是consumer每次从channel收一批,凑够N条或者超过T时间再一次性写入。下面把consume改成带定时器和批处理的版本。
func (a *AsyncLogger) consumeBatch() {
defer a.wg.Done()
batch := make([]string, 0, 64)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
for _, m := range batch {
a.file.WriteString(m + "n")
}
batch = batch[:0]
}
for {
select {
case msg, ok := <-a.ch:
if !ok {
flush()
a.file.Close()
return
}
batch = append(batch, msg)
if len(batch) >= 64 {
flush()
}
case <-ticker.C:
flush()
}
}
}
这里用了time.Ticker做超时落盘,避免低峰期日志在内存里睡很久。批量上限设64条,可按单条日志大小调整。注意flush里没有做原子写保护,但因为仍只有一个consumer协程,所以不存在并发写冲突。
如果担心进程被kill导致最后一批没刷盘,可以在Close里除了close(channel)外,再给consumer一点时间。我们的模型里close之后consumer会走完剩余channel数据并flush,然后返回,wg.Wait保证主程序退出前这些数据已落盘。
优雅关闭与防丢日志
很多初学者在main结束前直接退出了,channel没关,consumer还在阻塞收数据,结果缓冲里的日志全丢。正确方式就是暴露Close,在里面close(a.ch)并wg.Wait。任何注册到os.Signal的退出逻辑里也要调一次Close。
func setupSignal(logger *AsyncLogger) {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
logger.Close()
os.Exit(0)
}()
}
这段程序捕获中断信号后主动关闭日志器,确保consumer把积压日志写完。如果业务侧用defer logger.Close()也行,但信号退出时defer不一定来得及跑,所以信号里再调一次更稳。
还有一个细节:当channel满且业务选择丢弃日志时,最好用计数器记一下丢了多少条,方便后期监控。否则异步日志看起来正常,其实默默丢数据,排查问题反而更麻烦。
与标准库log的集成
如果想继续用标准库log.Logger的接口,只需把它的输出设成我们自己的writer。这个writer的Write方法把内容发到channel即可,这样原有代码几乎不用改。
type chanWriter struct {
ch chan string
}
func (w *chanWriter) Write(p []byte) (int, error) {
w.ch <- string(p)
return len(p), nil
}
func NewLoggerWithAsync(path string) (*log.Logger, *AsyncLogger, error) {
al, err := NewAsyncLogger(path, 2048)
if err != nil {
return nil, nil, err
}
cw := &chanWriter{ch: al.ch}
lg := log.New(cw, "", log.LstdFlags)
return lg, al, nil
}
这样上层调lg.Print就会走异步通道。要注意标准库会在每行末尾自己加换行,我们在consumer里就不要重复加,或者统一约定好格式。
整体看下来,Golang用Goroutine加channel做异步日志并不复杂,关键是想清楚缓冲大小、批量策略、关闭时机三件事。只要consumer唯一、退出有等待、写入有批量,性能和安全性就都能兼顾。