zap 高性能設計與實現

概述

zap 是 Uber 開源的 Go 高性能日誌庫,性能遠超於標準庫和其他開源日誌庫。zap 使用簡單,支持多種格式結構化日誌、可以設置不同的日誌級別,並且能夠在堆棧跟蹤中記錄調用者信息。

爲什麼要使用 zap

jiaaYQ

基準測試

官方給出了 3 種數據複雜度類型的基準測試結果,從測試結果表格中可以看到,除了 zerolog 庫之外,zap 的性能遠超其他開源庫。

爲什麼我們不直接分析 zerolog 庫的實現呢?根本的原因在於 zerolog 是專門爲 JSON 日誌格式設計 (也就是隻支持 JSON 日誌格式), 在某些需要其他格式日誌信息的場景下,無法兼容或者需要二次開發,相比之下,zap 在功能易用性和性能方面有更強的優勢。

1 條消息和 10 個字段

包含 10 個字段的消息

只記錄 1 條消息

示例

SugaredLogger

當性能不是第一考慮要素時,可以使用 SugaredLogger, 支持鍵值對形式的日誌信息,需要注意的是,這裏說的 鍵值對形式 並不是指類似 map[any]any 這種數據結構, 而是可以根據業務場景,直接將自定義的多個參數以 鍵,值 的形式傳入 SugaredLogger 的相關日誌方法,這極大提高了 API 的靈活性,比如同一類型的日誌消息, 我們可以使用固定的 ,但是使用不同數據結構中的 ,這樣形成兩兩組合,達到 不同的業務場景通過相同的結構最後寫入不同的日誌數據 (多態性)

yDHUVB

package main

import (
 "go.uber.org/zap"
 "time"
)

func main() {
 logger, _ := zap.NewProduction()
 defer logger.Sync() // 刷回緩衝區
 sugar := logger.Sugar()

 url := "https://www.example.com"
 sugar.Infow("failed to fetch URL",
  "url", url,
  "attempt", 3,
  "backoff", time.Second,
 )
 sugar.Infof("Failed to fetch URL: %s", url)
}
$ go run main.go

# 輸出如下
{"level":"info","ts":1680601481.089163,"caller":"go-high-performance/main.go:14","msg":"failed to fetch URL","url":"https://www.example.com","attempt":3,"backoff":1}
{"level":"info","ts":1680601481.0892165,"caller":"go-high-performance/main.go:20","msg":"Failed to fetch URL: https://www.example.com"}

將輸出 JSON 字符串格式化:

{
  "level": "info",
  "ts": 1680601481.089163,
  "caller": "go-high-performance/main.go:14",
  "msg": "failed to fetch URL",
  "url": "https://www.example.com",
  "attempt": 3,
  "backoff": 1
}

{
  "level": "info",
  "ts": 1680601481.0892165,
  "caller": "go-high-performance/main.go:20",
  "msg": "Failed to fetch URL: https://www.example.com"
}

默認情況下, SugaredLogger 的日誌信息結構除了開發者定義的鍵值外,還會附帶 3 個鍵值對,分別是:

sgnhnW

Logger

當性能是第一考慮要素時,請使用 Logger, 它比 SugaredLogger 性能更高且內分配更少,作爲性能代價,Logger 只支持結構化日誌記錄

package main

import (
 "go.uber.org/zap"
 "time"
)

func main() {
 logger, _ := zap.NewProduction()
 defer logger.Sync()

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

輸出日誌除了 時間戳 字段外,其他字段和剛纔的日誌沒有任何區別。通過代碼我們可以看到: LoggerSugaredLogger 在應用層面多了一層約束, 每個日誌字段必須調用 zap 中的類型方法來聲明其類型,例如上面例子中的 zap.String, zap.Int, zap.Duration

代碼分析

我們來分析下 zap 的源代碼,着重研究其高性能背後的技術實現,筆者選擇的版本爲 v1.24.0

對象複用

zap 組件內部對位於 hot path 上面的對象使用對象池管理進行復用。

下面以 Buffer 緩衝區 對象複用爲例,分析一下內部代碼,其他幾個對象實現都是類似的,具體到後面用到時再分析。

緩衝區對象池

日誌庫很重要的一部分就是處理輸入的日誌字符串,zap 在內部採用了 []byte 緩衝區 + 對象池 的管理模式,對象池沿用了標準庫中的 sync.Pool, 但是 []byte 緩衝區 並沒有使用標準庫中的 bytes.Buffer, 而是自己獨立實現了一套,主要原因有兩個:

核心代碼

// 默認的緩衝區大小爲 1 KB
const _size = 1024

// 緩衝區對象
type Buffer struct {
 // 底層切片
 bs   []byte

 // 對象池的引用
 // 這個字段主要是爲了 Free 方法的語義化
 // 緩衝區的歸還操作可以直接調用 buf.Free,而不需要間接調用 pool.Put(buf)
 pool Pool
}

// 重置緩衝區
// 直接複用了原有的數據區域,實現零分配
func (b *Buffer) Reset() {
    b.bs = b.bs[:0]
}

// 歸還緩衝區到對象池
func (b *Buffer) Free() {
    b.pool.put(b)
}

// 獲取對象池
func NewPool() Pool {
 return Pool{p: &sync.Pool{
  New: func() interface{} {
   // 初始化緩衝區時預分配容量,提升性能
   return &Buffer{bs: make([]byte, 0, _size)}
  },
 }}
}

緩衝區 的默認大小爲 1KB, 這也提醒我們要合理控制單條日誌的數據量大小,避免緩衝區底層的 []byte 數據結構發生擴容帶來的性能損耗。

自定義數據類型

zap 將數據類型全部映射爲自定義常量:

type FieldType uint8

const (
 UnknownType FieldType = iota
 ...

 BoolType
 Float64Type
 Int64Type
 StringType

 ...
)

字段對象

Field 對象表示日誌信息中的單個字段,字段包裝 方法通過 Type 字段將具體的數據包裝爲 Field 對象,編碼器可以通過 Type 字段得出該對象所表示的具體類型,然後在對應的數據字段取值。

例如 TypeInt64Type, 編碼器 就會獲取 Integer 字段的值,TypeStringType, 編碼器 就會獲取 String 字段的值。

type Field struct {
 // 字段名稱
 Key       string
 // 字段類型
 Type      FieldType
 // 存儲所有數值類型數據
 Integer   int64
 // 存儲字符串類型數據
 String    string
 // 存儲所有除 數值/字符串 之外的其他類型數據
 Interface interface{}
}

Field 通過 1 個類型字段加 3 個數據字段的組合,使編碼器對象巧妙地實現了多態性 (根據不同的類型獲取不同的值),最重要的是,完全規避了反射。

字段對象包裝

調用 zap.Int64, zap.String 等方法時,會根據參數的數據類型,返回一個包裝好的 Field 對象。

// 將類型爲 int64 的參數包裝爲 Field 對象
func Int64(key string, val int64) Field {
 return Field{Key: key, Type: zapcore.Int64Type, Integer: val}
}

// 將類型爲 string 的參數包裝爲 Field 對象
func String(key string, val string) Field {
    return Field{Key: key, Type: zapcore.StringType, String: val}
}

當然了,爲了提升開發效率 (摸魚),我們可以直接調用 zap.Any 方法自動轉換數據類型:

func Any(key string, value interface{}) Field {
 switch val := value.(type) {
    ...

 case bool:
     return Bool(key, val)
 case float64:
  return Float64(key, val)
 case int64:
  return Int64(key, val)
 case string:
  return String(key, val)

    ...
}

自定義編碼器

標準庫中的 json.Marshal 方法內部是基於 反射 實現的,極大地降低了效率。

func Marshal(v any) ([]byte, error) {
 e := newEncodeState()

 err := e.marshal(v, encOpts{escapeHTML: true})

    ...
}

func (e *encodeState) marshal(v any, opts encOpts) (err error) {
    ...

 e.reflectValue(reflect.ValueOf(v), opts)

 ...
}

zap 沒有調用標準庫的方法,而是實現了一套自定義的 編碼器,完全避免了使用 反射 帶來的性能損耗。

編碼器接口

ObjectEncoder 接口表示基礎數據和複合數據的編碼器。

type ObjectEncoder interface {
 // 複合數據格式化
 AddArray(key string, marshaler ArrayMarshaler) error
 AddObject(key string, marshaler ObjectMarshaler) error

 // 基礎數據格式化方法
 AddBool(key string, value bool)
 AddInt64(key string, value int64)
 AddString(key, value string)

 ...
}

Encoder 接口表示不同類型的日誌格式編碼,其中內嵌了一個 ObjectEncoder 接口用於格式化基礎數據和複合數據 (主要是數組和對象)。

type Encoder interface {
 ObjectEncoder

 // 返回當前對象的深拷貝
 Clone() Encoder

 // 編碼日誌消息,將格式化後的日誌數據直接寫入緩衝區中
 EncodeEntry(Entry, []Field) (*buffer.Buffer, error)
}

編碼器接口實現

zap 內置了兩種編碼器實現: JSONConsole (文本) 編碼器,下面分析下 JSON 編碼器的具體實現。

JSON 編碼器結構體

jsonEncoder 表示 JSON 編碼器,實現了 Encoder 編碼器接口。

type jsonEncoder struct {
 // 編碼器配置對象
 *EncoderConfig

 // 編碼後的 JSON 字符存儲緩衝區
 buf            *buffer.Buffer
 // 編碼後的 JSON 字符存是否在冒號和逗號後面加空格
 spaced         bool
 // 對象嵌套層數
    openNamespaces int
 // 通過反射編碼基礎數據類型
 reflectBuf *buffer.Buffer
 // 反射編碼器
 reflectEnc ReflectedEncoder
}

JSON 編碼器結對象池

zap 採用了對象池來管理編碼器對象,因爲其位於 hot path, 對象池可以避免對象創建和回收帶來的性能損耗,提升性能。

// JSON 編碼器對象池
var _jsonPool = sync.Pool{New: func() interface{} {
 return &jsonEncoder{}
}}

// 申請對象
func getJSONEncoder() *jsonEncoder {
 return _jsonPool.Get().(*jsonEncoder)
}

// 歸還對象
func putJSONEncoder(enc *jsonEncoder) {
    ...
 _jsonPool.Put(enc)
}

JSON 編碼器深拷貝

func (enc *jsonEncoder) Clone() Encoder {
 clone := enc.clone()
 // 複製緩衝區內容
 clone.buf.Write(enc.buf.Bytes())
 return clone
}

// 深拷貝內部實現
func (enc *jsonEncoder) clone() *jsonEncoder {
 // 從對象池中獲取一個編碼器對象
 clone := getJSONEncoder()

 ...

 // 從對象池中獲取一個 buffer 對象
 clone.buf = bufferpool.Get()
 return clone
}

JSON 編碼日誌

EncodeEntry 方法將日誌信息編碼爲 JSON 後寫入字符串緩衝對象然後返回 (字符串緩衝對象也是通過對象池管理的,規避了內存分配), 方法內部實現中,編碼器根據不同的字段類型和數據信息,調用對應的的編碼方法生成字段編碼,最後採用最樸素的 字符串拼接 生成日誌編碼字符串,規避了反射

func (enc *jsonEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, error) {
 // 深拷貝一個當前的對象,這樣對拷貝對象的操作不會影響當前對象
 final := enc.clone()

 // 開始寫入字符串到緩衝區,第一個爲字符爲 {
 final.buf.AppendByte('{')

 // 首先寫入日誌的基本字段
 if final.LevelKey != "" && final.EncodeLevel != nil {
  // 寫入日誌級別
  ...
 }
 if final.TimeKey != "" {
  // 寫入時間戳
  ...
 }
 if ent.LoggerName != "" && final.NameKey != "" {
  // 寫入日誌名稱
  ...
 }

 if final.MessageKey != "" {
  // 寫入日誌 msg
  ...
 }

 // 然後寫入日誌的自定義字段
 addFields(final, fields)

 // 根據字段對象的嵌套層數補充對應的 } 字符
 final.closeOpenNamespaces()

 if ent.Stack != "" && final.StacktraceKey != "" {
  // 寫入堆棧信息
  final.AddString(final.StacktraceKey, ent.Stack)
 }

 // 補充和第一個 { 字符對應的 } 字符
 final.buf.AppendByte('}')
 // 日誌結尾寫入換行符
 final.buf.AppendString(final.LineEnding)

 // 將字符串緩衝區數據賦值給返回值變量
 ret := final.buf

 // 將拷貝的編碼器對象歸還到對象池中
 putJSONEncoder(final)

 return ret, nil
}

字段編碼

addFields 函數將參數 編碼器 作用於具體的字段 (日誌調用方自定義的數據字段)。

func addFields(enc ObjectEncoder, fields []Field) {
 for i := range fields {
  fields[i].AddTo(enc)
 }
}

AddTo 方法調用 編碼器 對應的方法格式化字段。

func (f Field) AddTo(enc ObjectEncoder) {
 var err error

 switch f.Type {

 case BoolType:
  enc.AddBool(f.Key, f.Integer == 1)

 case Int64Type:
  enc.AddInt64(f.Key, f.Integer)

 case StringType:
  enc.AddString(f.Key, f.String)

    ...
 }

 ...
}

下面以 AddBool 方法調用來說明調用過程,其他方法流程是類似的。

func (enc *jsonEncoder) AddBool(key string, val bool) {
 enc.addKey(key)
 enc.AppendBool(val)
}

func (enc *jsonEncoder) AppendBool(val bool) {
    enc.addElementSeparator()
 // 寫入緩衝區
    enc.buf.AppendBool(val)
}

func (b *Buffer) AppendBool(v bool) {
 // 調用標準庫方法
    b.bs = strconv.AppendBool(b.bs, v)
}

func AppendBool(dst []byte, b bool) []byte {
    if b {
        return append(dst, "true"...)
    }
    return append(dst, "false"...)
}

接口

日誌接口

Core 表示基礎日誌接口。

type Core interface {
 // 日誌等級檢測接口
 LevelEnabler

 // 寫入日誌自定義字段
 With([]Field) Core

 // 檢測日誌是否應該被記錄
 // 如果日誌需要記錄,就將參數 Entry 添加到返回值 CheckedEntry
 // 該方法可以理解爲中間件
 //   支持單個檢測模式, 例如 ioCore.Check
 //   也支持多個檢測模式, 例如 multiCore.Check
 Check(Entry, *CheckedEntry) *CheckedEntry

 // 寫入日誌 (先寫入緩衝區,然後刷出)
 Write(Entry, []Field) error
 // 刷出緩衝區的日誌信息 (Stdout, File, Memory, MessageQueue ...)
 Sync() error
}

日誌寫入接口

WriteSyncer 接口在標準庫的 io.Writer 接口的基礎上包裝了一層,增加了一個 Sync 方法表示將緩衝區數據刷出並寫入,標準庫中的 Stderr, Stdout, File 都已經實現了該接口。

type WriteSyncer interface {
 io.Writer
 Sync() error
}

調用方可以使用 zapcore.AddSync 方法設置寫入接口,例如:

zapcore.AddSync(os.Stdout)

multiWriteSyncer 對象是一個 WriteSyncer 接口切片,表示將緩衝區數據刷出並寫入到多個接口,同時它自身也實現了 WriteSyncer 接口 (非常巧妙的設計)。

type multiWriteSyncer []WriteSyncer

調用方可以使用 zapcore.NewMultiWriteSyncer 方法設置多個輸出接口,例如:

zapcore.NewMultiWriteSyncer(os.Stdout, os.Stderr)

在創建方法的內部實現中,如果參數只有一個元素,直接返回,如果參數切片有多個元素,返回包裝後的 multiWriteSyncer 對象 (再一次體現了接口設計的擴展性和生命力) 。

func NewMultiWriteSyncer(ws ...WriteSyncer) WriteSyncer {
 if len(ws) == 1 {
  return ws[0]
 }
 return multiWriteSyncer(ws)
}

multiWriteSyncer 對象的 Write 方法和 Sync 方法實現機制類似 (相當於裝飾器模式),直接遍歷切片中的 WriteSyncer 接口元素,然後調用對應的方法即可。

func (ws multiWriteSyncer) Write(p []byte) (int, error) {
 for _, w := range ws {
        n, err := w.Write(p)
 }
}

func (ws multiWriteSyncer) Sync() error {
 for _, w := range ws {
  err = multierr.Append(err, w.Sync())
 }
}

日誌等級檢測

LevelEnabler 表示日誌等級檢測接口。

type LevelEnabler interface {
 Enabled(Level) bool
}

常規的實現是直接比較日誌等級大小,例如 WarnLevel > DebugLevel, InfoLevel < ErrorLevel。

日誌事件鉤子

CheckWriteHook 表示日誌寫入完成後要執行的鉤子函數接口。

type CheckWriteHook interface {
 // 當日志信息完成後調用函數
 OnWrite(*CheckedEntry, []Field)
}

4 種類型事件鉤子

CheckWriteAction 表示自定義的事件類型,按照事件的嚴重程度從低到高排序。

type CheckWriteAction uint8

const (
 // 不執行任何操作
 WriteThenNoop CheckWriteAction = iota
 // 調用 runtime.Goexit 方法
 WriteThenGoexit
 // 拋出一個 panic
 WriteThenPanic
 // 致命錯誤,調用 os.Exit(1) 結束程序
 WriteThenFatal
)
func (a CheckWriteAction) OnWrite(ce *CheckedEntry, _ []Field) {
 switch a {
 case WriteThenGoexit:
  runtime.Goexit()
 case WriteThenPanic:
  panic(ce.Message)
 case WriteThenFatal:
  exit.With(1)
 }
}

日誌相關對象

日誌對象

Logger 表示基礎日誌對象,SugaredLogger 對象就是在這個基礎上面封裝了一層。

type Logger struct {
 // 內嵌一個最小化日誌 Core 接口
 core zapcore.Core

 // 是否爲開發模式
 development bool
 // 是否輸出調用方堆棧
 addCaller   bool
 // 寫入日誌錯誤事件鉤子函數
 // 默認爲 WriteThenFatal
 onFatal     zapcore.CheckWriteHook

 // 日誌名稱
 name        string
 // 日誌寫入接口實現
 errorOutput zapcore.WriteSyncer

 // 輸出堆棧信息的日誌等級
 addStack zapcore.LevelEnabler

 // 跳過堆棧信息的層數
 callerSkip int

 // 時間接口,主要用於獲取時間戳和設置定時器
 clock zapcore.Clock
}

屬性設置

Logger 對象的字段屬性通過 FUNCTIONAL OPTIONS 模式設置,可以在創建對象時通過傳遞可變參數設置,也可以在對象創建完成後調用 Logger.WithOptions 方法修改。

type Option interface {
 apply(*Logger)
}

type optionFunc func(*Logger)

func (f optionFunc) apply(log *Logger) {
 f(log)
}

Logger.WithOptions 方法會拷貝當前的日誌對象,並將可變參數選項作用到拷貝的新對象上面,實現寫時複製機制

func (log *Logger) WithOptions(opts ...Option) *Logger {
 c := log.clone()
 for _, opt := range opts {
  opt.apply(c)
 }
 return c
}

日誌等級

Level 表示自定義的日誌等級類型,從低到高排序。

type Level int8

const (
 DebugLevel Level = iota - 1

 InfoLevel   // 0
 WarnLevel   // 1
 ErrorLevel  // 2
 DPanicLevel // 3
 PanicLevel  // 4
 FatalLevel  // 5
)

Level 實現了 LevelEnabler 接口。

func (l Level) Enabled(lvl Level) bool {
 // 直接比較兩個日誌等級的大小
 return lvl >= l
}

各種日誌級別寫入方法

Logger 對象將可用的日誌等級封裝爲對應的方法,方便應用層直接調用。

func (log *Logger) Debug(msg string, fields ...Field) {
 ...
}

func (log *Logger) Info(msg string, fields ...Field) {
 ...
}

func (log *Logger) Warn(msg string, fields ...Field) {
    ...
}

...

日誌元數據對象

Entry 對象表示日誌的元數據,對象只有日誌級別、時間戳、消息等幾個基礎字段,不包含調用方自定義的數據字段。

type Entry struct {
 // 日誌等級
 Level      Level
 // 日誌時間戳
 Time       time.Time
 // 日誌名稱
 LoggerName string
 // 日誌消息
 Message    string
 // 調用方信息
 Caller     EntryCaller
 // 堆棧信息
 Stack      string
}

Entry 對象並沒有使用對象池模式管理,如果使用了對象池的話,性能還能提升一些,當然,官方的考慮可能是幾乎不存在重複的日誌元數據對象, 對象的每個字段也不存在複用的場景,對象池帶來的性能提升不如代碼可讀性重要 (畢竟,每次從對象池申請對象或歸還對象時要重置每個字段)。

通過檢測的日誌數據對象

CheckedEntry 對象表示通過日誌等級檢測的日誌數據對象,其中內嵌了一個 Entry 對象,在日誌寫入完成後,應該及時將對象歸還到對象池中。

type CheckedEntry struct {
 // 日誌元數據
 Entry

 // 日誌寫入接口
 ErrorOutput WriteSyncer
 // 標識日誌是否已經輸出寫入,避免多次寫入
 dirty       bool
 // {日誌寫入後事件} 鉤子函數
 after       CheckWriteHook
 //
 cores       []Core
}

日誌對象池

zap 採用了對象池來管理 CheckedEntry 對象,和 編碼器 對象一樣位於 hot path, 對象池可以避免對象創建和回收帶來的性能損耗,提升性能。

var (
 _cePool = sync.Pool{New: func() interface{} {
    ...
 }}
)

func getCheckedEntry() *CheckedEntry {
    ...
}

func putCheckedEntry(ce *CheckedEntry) {
    ...
}

日誌寫入流程

當調用寫入日誌方法時 (例如 logger.Info),會經過如下流程:

  1. 檢測寫入日誌等級和配置等級的匹配度 (例如配置等級爲 WarnLevel, 那麼 DebugLevelInfoLevel 兩個等級的日誌就不需要寫入)

  2. 如果寫入日誌通過等級檢測,將日誌數據封裝成一個 Entry 對象

  3. 通過 Core.Check 中間件方法檢測 Entry 對象,通過檢測後生成 CheckedEntry 對象

  4. 根據日誌等級給 CheckedEntry 對象設置對應的鉤子函數

  5. 獲取調用堆棧相關信息寫入 CheckedEntry.Stack 字段

  6. 獲取調用方相關信息寫入 CheckedEntry.Caller 字段

  7. 調用 CheckedEntry 對象的 Write 方法寫入日誌數據

  8. 日誌重複寫入檢測

  9. 調用註冊的所有 zapcore.Core 接口,完成 日誌的寫入工作 (編碼、寫入、緩衝區刷出)

  10. 如果 日誌的寫入工作 過程中出現錯誤,附加記錄一條錯誤日誌

  11. 調用鉤子函數

  12. CheckedEntry 對象歸還到對象池中,日誌寫入結束

這裏以 logger.Info 方法爲例,分析一下日誌的寫入流程。

func (log *Logger) Info(msg string, fields ...Field) {
 if ce := log.check(InfoLevel, msg); ce != nil {
  ce.Write(fields...)
 }
}

Logger.check 方法

check 方法會根據日誌等級檢測日誌是否需要寫入,如果需要寫入,返回通過檢測的日誌數據對象 CheckedEntry

func (log *Logger) check(lvl zapcore.Level, msg string) *zapcore.CheckedEntry {
 // 跳過 2 個調用堆棧:
 //   1. Logger.check (也就是當前方法)
 //   2. 當前方法的調用方: (Logger.Info, Logger.Fatal 等)
 const callerSkipOffset = 2

 // 檢測寫入日誌等級和配置等級的匹配度
 // 當前日誌等級低於配置等級,直接返回
 if lvl < zapcore.DPanicLevel && !log.core.Enabled(lvl) {
  return nil
 }

 // 創建一個日誌元數據對象
 ent := zapcore.Entry{
  LoggerName: log.name,
  Time:       log.clock.Now(),
  Level:      lvl,
  Message:    msg,
 }

 // 調用 Core 接口方法檢測日誌
 ce := log.core.Check(ent, nil)
 // 如果返回值不爲 nil, 說明當前的日誌需要寫入
 willWrite := ce != nil

 // 設置 {日誌寫入後事件} 鉤子函數
 switch ent.Level {
 case zapcore.PanicLevel:
  // 添加 panic 鉤子
  ce = ce.After(ent, zapcore.WriteThenPanic)
 case zapcore.FatalLevel:
  onFatal := log.onFatal

  // 將 onFatal 默認值設置爲 WriteThenFatal (避免調用運行時錯誤)
  if onFatal == nil || onFatal == zapcore.WriteThenNoop {
   onFatal = zapcore.WriteThenFatal
  }

  // 添加 Fatal 鉤子
  ce = ce.After(ent, onFatal)
 case zapcore.DPanicLevel:
  if log.development {
   // 如果是開發模式,添加 panic 鉤子
   ce = ce.After(ent, zapcore.WriteThenPanic)
  }
 }

 // 日誌不需要寫入,直接返回
 if !willWrite {
  return ce
 }

 // 將負責寫入的對象賦值到通過檢測的日誌
 //    PS: ErrorOutput 這個命名感覺不太合理 ?
 ce.ErrorOutput = log.errorOutput

 addStack := log.addStack.Enabled(ce.Level)
 if !log.addCaller && !addStack {
   // 如果日誌配置爲不輸出調用方堆棧
   // 並且
   // 當前日誌級別無法匹配啓用堆棧的級別
   //    默認啓用堆棧級別爲 FatalLevel+1, 也就是不啓用
   //    可以創建日誌對象時通過 zap.AddStacktrace 方法修改默認級別

   // 直接返回當前通過檢測的日誌對象即可
  return ce
 }

 // 開始獲取調用堆棧信息
 stackDepth := stacktraceFirst   // 默認僅輸出調用堆棧的第一層
 if addStack {
  // 輸出調用堆棧的所有層
  stackDepth = stacktraceFull
 }

 // 獲取調用堆棧信息
 stack := captureStacktrace(log.callerSkip+callerSkipOffset, stackDepth)
 defer stack.Free()

 if stack.Count() == 0 {
  // 沒有獲取到任何堆棧信息
  //    出現這個問題是因爲日誌初始化設置了過高的 {跳過堆棧層數} 參數
  //    可以檢查 zap.AddCallerSkip 方法的參數值
  if log.addCaller {
   // 如果日誌配置爲輸出調用方堆棧
   // 顯然應用層的配置衝突了,寫入一條錯誤日誌
   fmt.Fprintf(log.errorOutput, "%v Logger.check error: failed to get caller\n", ent.Time.UTC())
   log.errorOutput.Sync()
  }
  // 直接返回
  return ce
 }

 // 跳過調用當前方法的棧幀 (第一層)
 frame, more := stack.Next()

 if log.addCaller {
  // 如果日誌配置爲輸出調用方堆棧
  // 初始化調用方堆棧對象
  ce.Caller = zapcore.EntryCaller{
   Defined:  frame.PC != 0,
   PC:       frame.PC,
   File:     frame.File,
   Line:     frame.Line,
   Function: frame.Function,
  }
 }

 if addStack {
  // 如果當前日誌級別匹配啓用堆棧的級別
  // 申請一個 buffer 緩衝區用於寫入調用堆棧數據
  buffer := bufferpool.Get()
  defer buffer.Free()

  stackfmt := newStackFormatter(buffer)

  // 將第一層堆棧信息格式化寫入緩衝區
  stackfmt.FormatFrame(frame)
  if more {
   // 將第一層外剩餘堆棧信息格式化寫入緩衝區
   stackfmt.FormatStack(stack)
  }
  // 將緩衝區堆棧數據賦值到日誌的堆棧數據字段
  ce.Stack = buffer.String()
 }

 return ce
}

CheckedEntry.Write 方法

CheckedEntry.Write 方法負責日誌的具體寫入工作,需要注意的一點是,方法內部會執行日誌寫入髒檢測 (也就是同一條日誌多次寫入), 什麼場景下會觸發這種情況呢?例如調用方執行了類似下面的代碼:

if ce := logger.Check(zap.DebugLevel, "debugging"); ce != nil {
    ce.Write(...) // 正常寫入
    ce.Write(...) // 不會寫入
    ce.Write(...) // 不會寫入
}

當然實際場景中,都是直接調用包裝好的 Logger 對象的寫入方法,可以完全避免這個問題。

func (ce *CheckedEntry) Write(fields ...Field) {
 // 日誌重複寫入檢測
 if ce.dirty {
  if ce.ErrorOutput != nil {
   // 如果日誌已經寫入
   // 寫一條錯誤日誌,直接返回
   fmt.Fprintf(ce.ErrorOutput, "%v Unsafe CheckedEntry re-use near Entry %+v.\n", ce.Time, ce.Entry)
   // 日誌緩衝區刷出並寫入
   ce.ErrorOutput.Sync()
  }
  return
 }

 // 標識日誌已經寫入
 ce.dirty = true

 var err error
 // 因爲日誌可能有多個寫入接口 (Stdout, File, Memory, MessageQueue ...)
 // 遍歷, 逐個接口寫入
 // 每個實現了寫入接口的對象,需要在 Write 方法寫入前完成日誌數據的編碼
 // 例如: ioCore.Write 方法在寫入前將日誌編碼爲內置的 `buffer.Buffer` 對象
 // 這裏順便提一下, multierr 也是 uber 開源的一個庫,主要用於包裝 error,類似 errorGroup
 for i := range ce.cores {
  err = multierr.Append(err, ce.cores[i].Write(ce.Entry, fields))
 }
 if err != nil && ce.ErrorOutput != nil {
  // 如果有接口寫入時發生錯誤
  // 寫一條錯誤日誌
  fmt.Fprintf(ce.ErrorOutput, "%v write error: %v\n", ce.Time, err)
  // 日誌緩衝區刷出並寫入
  ce.ErrorOutput.Sync()
 }

 hook := ce.after
 if hook != nil {
  // 如果設置了日誌事件鉤子
  // 則執行對應的鉤子函數
  hook.OnWrite(ce, fields)
 }

 // 日誌寫入完成後,將 CheckedEntry 歸還到對象池
 putCheckedEntry(ce)
}

調用堆棧對象池

zap 採用了對象池來管理調用堆棧對象,因爲其位於 hot path, 對象池可以避免對象創建和回收帶來的性能損耗,提升性能。

type stacktrace struct {
    ...
}

var _stacktracePool = sync.Pool{
 New: func() interface{} {
  return &stacktrace{
   ...
  }
 },
}

captureStacktrace 函數獲取調用的堆棧信息,可以通過參數指定需要跳過的堆棧層數。

func captureStacktrace(skip int, depth stacktraceDepth) *stacktrace {
 stack := _stacktracePool.Get().(*stacktrace)

 // 在參數基礎上再跳過 2 層:
 //    1. 當前函數
 //    2. runtime.Callers
 // 注意這裏調用的是 runtime.Callers 方法,而非 runtime.Stack (之前的文章講到過: 兩者的性能差距很大)
 //    因爲後者會觸發 STW, 這也是一個高性能的技巧
 numFrames := runtime.Callers(
  skip+2,
  stack.pcs,
 )

 if depth == stacktraceFull {
  // 獲取調用堆棧的所有層
  ...
 } else {
  // 僅獲取調用堆棧的第一層
  ...
 }

 stack.frames = runtime.CallersFrames(stack.pcs)
 return stack
}

zap 組件結構

zap 高性能實現細節

小結

本文着重分析了 zap 日誌庫高性能背後的實現原理,其中大部分優化技術點細節都在 高性能 Tips[1] 一文中提到過。 作爲開發者最重要的是,有了基礎的優化理論之後,可以像 Uber 的工程師一樣,根據不同的業務場景開發出匹配的高性能組件。

從開發者的角度看,大多數高性能組件背後的技術本質除了代碼優化之外,開發效率的降低往往同樣是不可避免的代價 (這似乎又回到了靜態語言和動態語言的感覺)。

BTW, Uber 這家公司爲 Go 生態貢獻了不少高質量的組件庫,感興趣的讀者可以查看 Uber-Go Github 官方頁面 [2]。

鏈接

[1] 高性能 Tips: https://dbwu.tech/posts/golang_performance_tips/

[2] Uber-Go Github 官方頁面: https://github.com/uber-go

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/K6yDG0Bpll_GKaZclqKoSA