在开发高并发服务时,日志记录是排查问题、监控系统状态的重要手段。Go语言凭借其轻量级的goroutine和channel机制,在并发编程上表现出色。但当多个协程同时写日志时,如果处理不当,很容易出现日志错乱、文件锁竞争甚至性能下降的问题。
为什么不能直接用fmt写日志?
有些开发者图省事,直接用fmt.Fprintf往文件里写日志。但在并发场景下,多个goroutine同时写同一个文件,会导致日志内容交错。比如用户A的请求日志和用户B的日志混在一起,查问题时根本分不清谁是谁。
使用标准库log搭配互斥锁
最简单的解决方案是加锁。Go标准库log支持自定义输出目标,配合sync.Mutex就能保证线程安全。
package main
import (
"log"
"os"
"sync"
)
var (
logger = log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile)
mu sync.Mutex
)
func writeLog(msg string) {
mu.Lock()
defer mu.Unlock()
logger.Println(msg)
}
这种方式简单可靠,适合中小型项目。但锁会带来性能开销,特别是在高频写入场景下,所有goroutine都要排队等锁,反而成了瓶颈。
用channel实现异步日志
更优雅的做法是把日志写入变成异步操作。启动一个专门的“日志协程”,其他协程通过channel发送日志消息。
package main
import (
"bufio"
"log"
"os"
)
var logChan = make(chan string, 1000)
func init() {
file, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
writer := bufio.NewWriter(file)
go func() {
for msg := range logChan {
writer.WriteString(msg + "\n")
writer.Flush() // 实际项目中可考虑批量刷新
}
}()
}
func logAsync(msg string) {
select {
case logChan <- msg:
default:
// 防止channel满时阻塞
}
}
这种模式下,业务协程只需把日志发到channel就立刻返回,真正写磁盘由后台协程完成。即使磁盘IO慢,也不会拖垮主流程。
推荐使用成熟日志库zap
对于线上服务,建议直接使用uber开源的zap库。它专为高性能设计,支持结构化日志,并内置了并发安全的写入机制。
package main
import (
"go.uber.org/zap"
)
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()
// 多个goroutine可安全调用
for i := 0; i < 10; i++ {
go func(id int) {
logger.Info("处理请求", zap.Int("user_id", id))
}(i)
}
}
zap的性能远超标准库,格式也更规范。像字节跳动、腾讯这些大厂的Go服务都在用它记日志。
实际场景中的小技巧
假设你在做一个抢红包系统,每秒有上万次请求。每个请求都要记录“用户X领取了Y元”。如果每个请求都同步写文件,磁盘很快就会成为瓶颈。这时候用异步channel或zap这类高性能库,就能轻松扛住压力。
另外记得给日志加时间戳和唯一请求ID。不然几十万行日志堆在一起,根本没法定位具体某次请求的完整链路。