zap 高性能設計與實現
概述
zap 是 Uber 開源的 Go 高性能日誌庫,性能遠超於標準庫和其他開源日誌庫。zap 使用簡單,支持多種格式結構化日誌、可以設置不同的日誌級別,並且能夠在堆棧跟蹤中記錄調用者信息。
爲什麼要使用 zap
基準測試
官方給出了 3
種數據複雜度類型的基準測試結果,從測試結果表格中可以看到,除了 zerolog
庫之外,zap
的性能遠超其他開源庫。
爲什麼我們不直接分析 zerolog
庫的實現呢?根本的原因在於 zerolog
是專門爲 JSON
日誌格式設計 (也就是隻支持 JSON 日誌格式), 在某些需要其他格式日誌信息的場景下,無法兼容或者需要二次開發,相比之下,zap
在功能易用性和性能方面有更強的優勢。
1 條消息和 10 個字段
包含 10 個字段的消息
只記錄 1 條消息
示例
SugaredLogger
當性能不是第一考慮要素時,可以使用 SugaredLogger
, 支持鍵值對形式的日誌信息,需要注意的是,這裏說的 鍵值對形式
並不是指類似 map[any]any
這種數據結構, 而是可以根據業務場景,直接將自定義的多個參數以 鍵,值
的形式傳入 SugaredLogger
的相關日誌方法,這極大提高了 API 的靈活性,比如同一類型的日誌消息, 我們可以使用固定的 鍵
,但是使用不同數據結構中的 值
,這樣形成兩兩組合,達到 不同的業務場景通過相同的結構最後寫入不同的日誌數據 (多態性)。
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
個鍵值對,分別是:
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),
)
}
輸出日誌除了 時間戳
字段外,其他字段和剛纔的日誌沒有任何區別。通過代碼我們可以看到: Logger
比 SugaredLogger
在應用層面多了一層約束, 每個日誌字段必須調用 zap
中的類型方法來聲明其類型,例如上面例子中的 zap.String
, zap.Int
, zap.Duration
。
代碼分析
我們來分析下 zap
的源代碼,着重研究其高性能背後的技術實現,筆者選擇的版本爲 v1.24.0
。
對象複用
zap
組件內部對位於hot path
上面的對象使用對象池管理進行復用。
下面以 Buffer 緩衝區
對象複用爲例,分析一下內部代碼,其他幾個對象實現都是類似的,具體到後面用到時再分析。
緩衝區對象池
日誌庫很重要的一部分就是處理輸入的日誌字符串,zap
在內部採用了 []byte 緩衝區 + 對象池
的管理模式,對象池沿用了標準庫中的 sync.Pool
, 但是 []byte 緩衝區
並沒有使用標準庫中的 bytes.Buffer
, 而是自己獨立實現了一套,主要原因有兩個:
-
bytes.Buffer
僅支持 {byte
,[]byte
,rune
,string
} 4 種數據類型的直接寫入,不滿足zap
日誌信息多種數據類型的寫入場景 -
獨立實現的支持多種數據類型的
緩衝區
可以配合zap
內置的編碼器
進一步提升性能
核心代碼
// 默認的緩衝區大小爲 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
字段得出該對象所表示的具體類型,然後在對應的數據字段取值。
例如 Type
是 Int64Type
, 編碼器
就會獲取 Integer
字段的值,Type
是 StringType
, 編碼器
就會獲取 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
內置了兩種編碼器實現: JSON
和 Console (文本)
編碼器,下面分析下 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
),會經過如下流程:
-
檢測寫入日誌等級和配置等級的匹配度 (例如配置等級爲
WarnLevel
, 那麼DebugLevel
和InfoLevel
兩個等級的日誌就不需要寫入) -
如果寫入日誌通過等級檢測,將日誌數據封裝成一個
Entry
對象 -
通過
Core.Check
中間件方法檢測Entry
對象,通過檢測後生成CheckedEntry
對象 -
根據日誌等級給
CheckedEntry
對象設置對應的鉤子函數 -
獲取調用堆棧相關信息寫入
CheckedEntry.Stack
字段 -
獲取調用方相關信息寫入
CheckedEntry.Caller
字段 -
調用
CheckedEntry
對象的Write
方法寫入日誌數據 -
日誌重複寫入檢測
-
調用註冊的所有
zapcore.Core
接口,完成 日誌的寫入工作 (編碼、寫入、緩衝區刷出) -
如果 日誌的寫入工作 過程中出現錯誤,附加記錄一條錯誤日誌
-
調用鉤子函數
-
將
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 高性能實現細節
-
通過內建的數據類型
zapcore.Field
和內建的日誌編碼器 (Encoder
接口),避免標準庫的序列化方法使用反射帶來的性能損耗 -
通過內建的數據類型
zapcore.Field
, 避免使用interface{}
帶來的開銷 (拆裝箱、對象逃逸到堆上) -
通過內建的
[]byte
緩衝池配合zapcore.Field
進一步提升日誌數據的寫入性能 -
獲取調用堆棧方法優化 (使用
runtime.Callers
而非runtime.Stack
) -
寫時複製機制 (多個日誌共享一個
Logger
對象,在屬性變更時複製一個新的對象,詳情見Logger.clone
方法及其調用方) -
按需分配機制 (
Check
方法檢查可寫後,再通過CheckedEntry.Write
方法寫入日誌數據,可以保證zapcore.Field
日誌對象內存按需分配) -
對象複用避免
GC
(位於hot path
上面的對象全部使用對象池管理模式進行復用) -
避免數據競態,雖然有對象池管理複用,但是對象的獲取都需要經過各種條件過濾,有效緩解了底層
sync.Pool
內部的數據競態問題 -
重複檢測,每個日誌保證只寫入一次,提升性能並且避免應用層的錯誤使用導致的 Bug
小結
本文着重分析了 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