golang的日志库zap

日志作为整个代码行为的记录,是程序执行逻辑和异常最直接的反馈。对于整个系统来说,日志是至关重要的组成部分。通过分析日志我们不仅可以发现系统的问题,同时日志中也蕴含了大量有价值可以被挖掘的信息,因此合理地记录日志是十分必要的。

1. golang log libs

目前golang主流的 log库有

zap 跟 logrus 以及目前主流的 go 语言 log 类似,提倡采用结构化的日志格式,而不是将所有消息放到消息体中,简单来讲,日志有两个概念:字段和消息。字段用来结构化输出错误相关的上下文环境,而消息简明扼要的阐述错误本身。

1.1 log库对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package main

import (
"encoding/json"
"fmt"
"log"
"math/rand"
"time"

"github.com/golang/glog"
"github.com/sirupsen/logrus"
"go.uber.org/zap"
)

type dummy struct {
Foo string `json:"foo"`
Bar string `json:"bar"`
}

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)

func RandString(n int) string {
b := make([]byte, n)
// A rand.Int63() generates 63 random bits, enough for letterIdxMax letters!
for i, cache, remain := n-1, rand.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = rand.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}

func dummyData() interface{} {
return dummy{
Foo: RandString(12),
Bar: RandString(16),
}
}

func main() {

// logrus
var x int64 = 0
t := time.Now()
for i := 0; i < 10000; i++ {
logrus.WithField("Dummy", dummyData()).Infoln("this is a dummy log")
}
x += time.Since(t).Nanoseconds()

// zap
zlogger, _ := zap.NewProduction()
sugar := zlogger.Sugar()
var y int64 = 0
t = time.Now()
for i := 0; i < 10000; i++ {
sugar.Infow("this is a dummy log", "Dummy", dummyData())
}
y += time.Since(t).Nanoseconds()

// stdlog
var z int64 = 0
t = time.Now()
for i := 0; i < 10000; i++ {
dummyStr, _ := json.Marshal(dummyData())
log.Printf("this is a dummy log: %s\n", string(dummyStr))
}
z += time.Since(t).Nanoseconds()

// glog
var w int64 = 0
t = time.Now()
for i := 0; i < 10000; i++ {
glog.Info("\nthis is a dummy log: ", dummyData())
}
w += time.Since(t).Nanoseconds()

// print
fmt.Println("=====================")
fmt.Printf("Logrus: %5d ns per request \n", x/10000)
fmt.Printf("Zap: %5d ns per request \n", y/10000)
fmt.Printf("StdLog: %5d ns per request \n", z/10000)
fmt.Printf("Glog: %5d ns per request \n", w/10000)
}


/*
=====================
Logrus: 19305 ns per request
Zap: 1095 ns per request
StdLog: 7137 ns per request
Glog: 12070 ns per request
*/

2. zap 使用

zap 是一个由 Uber 开源的、为性能而生的 结构化 日志库。它在提供极高速度的同时,也让日志变得更易于机器解析和查询。

在你的日常工作中,可能经常使用 fmt.Println 或标准库 log。但在大型、高并发的生产环境中,它们存在两个致命问题:

  1. 性能黑洞:标准库的日志记录大量使用 fmt.Sprintf 和反射(reflection),这在每秒需要处理成千上万次请求的系统中,会成为显著的性能瓶颈,白白消耗大量 CPU 资源。
  2. 日志孤岛:传统的文本日志对人类友好,但对机器极不友好。当系统出现问题,你需要在海量(可能是 GB 甚至 TB 级别)的日志文件中搜索特定信息时,grep 命令会变得缓慢且低效。你无法轻松地进行聚合分析,比如“统计过去一小时内,由用户 ID 123 触发的错误总数”。

zap 完美地解决了这两个问题

  • 极致性能:它通过避免反射、使用对象池 (sync.Pool) 等技术,最大限度地减少了内存分配和计算开销,是目前 Go 生态中最快的日志库之一。
  • 结构化日志:它默认输出 JSON 格式的日志,每个日志条目都是一个键值对集合。这使得日志可以被 Elasticsearch、Splunk、Datadog 等日志聚合平台轻松地索引、搜索和可视化,让故障排查和数据分析的效率提升一个数量级。

学习 zap,就是学习如何构建一个现代化的、可观测的(Observability)高性能系统。

2.1 工作原理

  1. Logger & SugaredLogger (用户接口)
    • Logger:性能最高的API。它要求你明确提供强类型的字段(Field),例如 zap.String("key", "value"), zap.Int("userID", 123)。这是 zap 性能的保证。
    • SugaredLogger:语法糖版本,性能略低于 Logger,但API更友好,类似于 fmt.Printf 的风格,如 sugar.Infof("Failed to fetch user: %s", err)。适合从传统 log 库迁移或非性能瓶颈的场景。
  2. zapcore.Core (核心处理器)
    这是 zap 的心脏,它告诉 Logger 一个日志条目应该如何被处理。一个 Core 需要三个要素:
    • Encoder:编码器。决定日志的格式。zap 内置了 jsonEncoder (输出 JSON) 和 consoleEncoder (输出对人友好的彩色文本)。你也可以自定义。
    • WriteSyncer:写入器。决定日志要被写到哪里。可以是标准输出 (os.Stdout)、文件,甚至可以同时写入多个地方。
    • LevelEnabler:级别控制器。决定哪个级别的日志应该被记录(例如,只记录 InfoLevel 及以上级别的日志)。
  3. Field (结构化字段)
    这是 zap 区别于传统日志库的根本。每一个 Field 都是一个预先定义好类型的键值对。由于类型已知,zap 在编码时无需使用反射,从而实现了零内存分配和极高的效率。

2.2 注意事项

  • 优先使用 Logger:在对性能有要求的代码路径中(比如请求处理的核心逻辑),坚持使用 zap.Logger 而不是 zap.SugaredLogger。把 SugaredLogger 当作便利性的权衡。

  • 善用 With 创建上下文 Logger:当处理一个请求时,可以创建一个带有共同字段(如 request_id, user_id)的子 logger。这样后续的日志就不用重复添加这些字段了。

    1
    2
    3
    4
    5
    6
    7
    8
    // 在中间件或请求开始时
    contextualLogger := logger.With(
    zap.String("requestID", "xyz-123"),
    zap.String("userID", "u-456"),
    )
    contextualLogger.Info("Processing started")
    // ... 在其他函数中使用 contextualLogger

  • 处理 error 类型:记录 error 时,使用 zap.Error(err) 而不是 zap.String("error", err.Error())zap.Error 字段会对实现了特定接口的错误(如 zap.StackTracer)进行特殊处理,自动附加堆栈信息,非常便于调试。

  • 整个项目都使用 sugar.Infof("user %s logged in", user.ID),然后奇怪为什么性能提升不明显。

    Infof, Warnf 等方法内部仍然有类似 fmt.Sprintf 的开销,这违背了 zap 的设计初衷。

  • zap.Any 中使用复杂类型。zap.Any 会触发反射,带来性能开销。此外,如果该 struct 没有实现 json.Marshaler 接口,输出的日志可能会非常混乱或不符合预期。

2.3 使用代码

  • sugar模式 (牺牲性能为代价,增强可用性)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()

url := "https://www.liuvv.com"
sugar := logger.Sugar()
sugar.Infow("failed to fetch URL",
"url", url,
"attempt", 3,
"backoff", time.Second,
)

sugar.Infof("Failed to fetch URL: %s", url)
}


// 注意 infow 和 infof 的调用区别

/*
{"level":"info","ts":1566623998.1506088,"caller":"log/main.go:15","msg":"failed to fetch URL","url":"https://www.liuvv.com","attempt":3,"backoff":1}

{"level":"info","ts":1566623998.15073,"caller":"log/main.go:20","msg":"Failed to fetch URL: https://www.liuvv.com"}
*/
  • logger 模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()

url := "https://www.liuvv.com"
logger.Info("failed to fetch URL",
zap.String("url", url),
zap.Int("attempt", 3),
zap.Duration("backoff", time.Second),
)

//logger.Infow() //没有此函数
//logger.Infof() //没有此函数
}


/*
{"level":"info","ts":1566624270.4984472,"caller":"log/main.go:14","msg":"failed to fetch URL","url":"https://www.liuvv.com","attempt":3,"backoff":1}
*/

2.4 输出到文件里

1
2
3
4
5
6
7
func NewLogger() (*zap.Logger, error) {
cfg := zap.NewProductionConfig()
cfg.OutputPaths = []string{
"/var/log/myproject/myproject.log",
}
return cfg.Build()
}

2.5 输入到滚动文件里

Lumberjack用于将日志写入滚动文件。zap 不支持文件归档,如果要支持文件按大小或者时间归档,需要使用lumberjack,lumberjack也是zap官方推荐的。https://github.com/natefinch/lumberjack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
// lumberjack.Logger is already safe for concurrent use, so we don't need to
// lock it.
hook := &lumberjack.Logger{
Filename: "/tmp/foo.log", // 日志文件路径
MaxSize: 500, // 每个日志文件保存的最大尺寸 单位:M
MaxBackups: 3, // 日志文件最多保存多少个备份
MaxAge: 28, // 文件最多保存多少天
Compress: true, // 是否压缩
}
core := zapcore.NewCore(
zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), // 编码器配置
zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(hook)), // 打印到控制台和文件
zap.InfoLevel, // 日志级别
)

logger := zap.New(core)
logger.Info("failed to fetch URL",
zap.String("url", "https://www.liuvv.com"),
zap.Int("attempt", 3),
zap.Duration("backoff", time.Second),
)
}

3. 提问问题

3.1 NewDevelopment 和 NewProduction 区别

特性zap.NewDevelopment()zap.NewProduction()设计目的
日志格式 (Encoder)consoleEncoder (彩色、多行、对人友好)jsonEncoder (单行 JSON,对机器友好)开发时需要快速扫视,生产环境需要日志聚合平台能轻松解析。
默认日志级别DebugLevel (非常详细)InfoLevel (更收敛)开发时希望看到所有信息,生产环境只关心正常流程和错误,避免日志泛滥。
调用者信息 (Caller)启用 (file:line)启用 (file:line)无论何种环境,知道日志来源都非常重要。
堆栈信息 (Stacktrace)WarnLevel 及以上级别自动附加ErrorLevel 及以上级别自动附加开发时,连一个警告(Warn)都希望看到完整的调用栈;生产环境,只有真正的错误(Error)才值得记录昂贵的堆栈信息。
时间格式ISO8601 (例如 2025-08-14T15:04:05.000Z0700)Unix 时间戳 (例如 1755193445.000)人眼对 ISO8601 更友好,而时间戳对机器来说处理效率更高、存储更紧凑。
日志采样 (Sampling)禁用启用这是 Production 配置的一个关键特性。它可以在极高的日志吞吐量下,防止日志风暴。例如,它会确保每秒内相同的、重复的日志只记录前 100 条,之后每 100 条中再记录 1 条。这能极大地保护你的系统和日志平台不被冲垮。

3.2 怎么带有 trace_id

通过中间件注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package main

import (
"context"
"net/http"
"time"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)

// 定义一个 context key,避免键冲突
type loggerKey struct{}

// LoggingMiddleware 创建一个 Gin 中间件
func LoggingMiddleware(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 获取或生成 trace_id
traceID := c.GetHeader("X-Trace-ID")
if traceID == "" {
traceID = uuid.New().String()
}

// 2. 使用 .With() 创建一个带有上下文信息的 logger
contextualLogger := logger.With(
zap.String("trace_id", traceID),
zap.String("path", c.Request.URL.Path),
)

// 3. 将新的 logger 存入 context,以供后续处理函数使用
// 注意:Gin 使用 c.Set() 模拟 context 存储,标准库 http 则用 context.WithValue
ctx := context.WithValue(c.Request.Context(), loggerKey{}, contextualLogger)
c.Request = c.Request.WithContext(ctx)

// 为了方便,也存入 Gin 的 context
c.Set("logger", contextualLogger)

start := time.Now()

// 处理请求
c.Next()

// 请求处理完毕后,记录带有耗时的日志
duration := time.Since(start)
contextualLogger.Info("Request handled", zap.Duration("duration", duration))
}
}

// HelloHandler 是我们的业务处理函数
func HelloHandler(c *gin.Context) {
// 从 Gin context 中获取 logger
// 在真实的、更复杂的应用中,你可能会从 c.Request.Context() 中获取
l, exists := c.Get("logger")
if !exists {
// Fallback to a global logger if something went wrong
// 这是一个健壮性措施
zap.L().Error("Logger not found in context")
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}

logger := l.(*zap.Logger) // 类型断言

// 使用这个 logger,它会自动包含 trace_id
logger.Info("Now handling business logic inside HelloHandler")

c.JSON(http.StatusOK, gin.H{
"message": "hello, world!",
})
}

func main() {
// 使用生产配置初始化一个全局 logger
logger, _ := zap.NewProduction()
defer logger.Sync()

// 设置为 zap 的全局 logger
zap.ReplaceGlobals(logger)

r := gin.New()

// 注册我们的日志中间件
r.Use(LoggingMiddleware(logger))

r.GET("/hello", HelloHandler)

r.Run(":8080")
}

4. 参考资料