歡迎光臨
每天分享高質量文章

深度 | 從Go高性能日誌庫zap看如何實現高性能Go組件

導語:zap是uber開源的Go高性能日誌庫。本文作者深入分析了zap的架構設計和具體實現,揭示了zap高效的原因。並且對如何構建高性能Go語言庫給出自己的建議。


作者簡介:李子昂,美圖公司架構平臺系統研發工程師,從事長連接服務和分佈式儲存組件的研發和支持。


摘要

日誌在整個工程實踐中的重要性不言而喻,在選擇日誌組件的時候也有多方面的考量。詳細、正確和及時的反饋是必不可少的,但是整個性能表現是否也是必要考慮的點呢?美圖技術團隊在長期的實踐中發現有的日誌組件對於計算資源的消耗十分巨大,這將導致整個服務成本的居高不下。此文從設計原理深度分析了 zap 的設計與實現上的權衡,也希望整個的選擇、考量的過程能給其他的技術團隊在開發高性能的 Go 組件時帶來一定的借鑒意義。

前言

日誌作為整個代碼行為的記錄,是程式執行邏輯和異常最直接的反饋。對於整個系統來說,日誌是至關重要的組成部分。通過分析日誌我們不僅可以發現系統的問題,同時日誌中也蘊含了大量有價值可以被挖掘的信息,因此合理地記錄日誌是十分必要的。

我們部門的技術大牛做過精辟的總結:

拔高到哲學或方法論的角度來講,無論是人或專案質量的進步,還是異常情況的及時發現和排查,至關重要的一點就是詳細、正確、及時地反饋。而日誌和監控就是用來提供反饋的最強有力的手段。當然,最差的情況就是什麼都不管,等到用戶發現問題來反饋,不過這樣恐怕只能去人才市場了(這一段你可以當我在扯淡)。

我們的業務通常會記錄大量的 Debug 日誌,但在實際測試過程中,發現我們使用的日誌庫 seelog 性能存在嚴重的瓶頸,在我們的對比結果中發現:zap 表現非常突出,單執行緒 Qps 也是 logrus、seelog 的數倍。

在分析原始碼後 zap 設計與實現上的考量讓我感到受益頗多,在這裡我們主要分享一下以下幾個方面:

  1. zap 為何有這麼高的性能

  2. 對於我們自己的開發有什麼值得借鑒的地方

  3. 如何正確的使用 Go 開發高性能的組件

Why zap?

絕大多數的代碼中的寫日誌通常通過各式各樣的日誌庫來實現。日誌庫提供了豐富的功能,對於 Go 開發者來說大家常用的日誌組件通常會有以下幾種,下麵簡單的總結了常用的日誌組件的特點:

  • seelog: 最早的日誌組件之一,功能強大但是性能不佳,不過給社區後來的日誌庫在設計上提供了很多的啟發。

  • logrus: 代碼清晰簡單,同時提供結構化的日誌,性能較好。

  • zap: uber 開源的高性能日誌庫,面向高性能並且也確實做到了高性能。

Zap 代碼並不是很多,不到 5000 行,比 seelog 少多了( 8000 行左右), 但比logrus(不到 2000 行)要多很多。為什麼我們會選擇 zap 呢?在下文中將為大家闡述。

Zap 跟 logrus 以及目前主流的 Go 語言 log 類似,提倡採用結構化的日誌格式,而不是將所有訊息放到訊息體中,簡單來講,日誌有兩個概念:欄位和訊息。欄位用來結構化輸出錯誤相關的背景關係環境,而訊息簡明扼要的闡述錯誤本身。

比如,用戶不存在的錯誤訊息可以這麼打印:

log.Error("User does not exist", zap.Int("uid", uid))

上面 User does not exist 是訊息, 而 uid 是欄位。具體設計思想可以參考 logrus的文件 ,這裡不再贅述。

其實我們最初的實踐中並沒有意識到日誌框架的性能的重要性,直到開發後期進行系統的 benchmark 總是不盡人意,而且在不同的日誌級別下性能差距明顯。通過 go profiling 看到日誌組件對於計算資源的消耗十分巨大,因此決心將其替換為一個高性能的日誌框架,這也是選擇用 zap 的一個重要的考量的點。

目前我們使用 zap 已有2年多的時間,zap 很好地解決了日誌組件的低性能的問題。目前 zap 也從 beta 發佈到了 1.8版本,對於 zap 我們不僅僅看到它的高性能,更重要的是理解它的設計與工程實踐。日誌屬於 io 密集型的組件,這類組件如何做到高性能低成本,這也將直接影響到服務成本。

zap, how ?

zap 具體表現如何呢?拋開 zap 的設計我們不談,現在讓我們單純來看一個日誌庫究竟需要哪些元素:

  1. 首先要有輸入:輸入的資料應該被良好的組織且易於編碼,並且還要有高效的空間利用率,畢竟記憶體開闢回收是昂貴的。 無論是 formator 方式還是鍵值對 key-value 方式,本質上都是對於輸入資料的組織形式。 實踐中有格式的資料往往更有利於後續的分析與處理。 json 就是一種易用的日誌格式

  2. 其次日誌能夠有不同的級別:對於日誌來說,基本的的日誌級別: debug info warning error fatal 是必不可少的。對於某些場景,我們甚至期待類似於 assert 的 devPanic 級別。同時除了每條日誌的級別,還有日誌組件的級別,這可以用於屏蔽掉某些級別的日誌。

  3. 有了輸入和不同的級別,接下來就需要組織日誌的輸出流:你需要一個 encoder 幫你把格式化的,經過了過濾的日誌信息輸出。也就是說不論你的輸出是哪裡,是 stdout ,還是檔案,還是 NFS ,甚至是一個 tcp 連接。 Encoder 只負責高效的編碼資料,其他的事情交給其他人來做。

  4. 有了這些以後,我們剩下的需求就是設計一套易用的接口,來呼叫這些功能輸出日誌。 這就包含了 logger 物件和 config。

嗯,似乎我們已經知道我們要什麼了,日誌的組織和輸出是分開的邏輯,但是這不妨礙 zapcore 將這些設計組合成 zap 最核心的接口。

從上文中來看一個日誌庫的邏輯結構已經很清晰了。現在再來看下 zap ,通過 zap 打印一條結構化的日誌大致包含5個過程:

  1. 分配日誌 Entry: 創建整個結構體,此時雖然沒有傳參(fields)進來,但是 fields 引數其實創建了

  2. 檢查級別,添加core: 如果 logger 同時配置了 hook,則 hook 會在 core check 後把自己添加到 cores 中

  3. 根據選項添加 caller info 和 stack 信息: 只有大於等於級別的日誌才會創建checked entry

  4. Encoder 對 checked entry 進行編碼: 創建最終的 byte slice,將 fields 通過自己的編碼方式(append)編碼成標的串

  5. Write 編碼後的標的串,並對剩餘的 core 執行操作, hook也會在這時被呼叫

接下來對於我們最感興趣的幾個部分進行更加具體的分析:

  • logger: zap 的接口層,包含Log 物件、Level 物件、Field 物件、config 等基本物件

  • zapcore: zap 的核心邏輯,包含field 的管理、level 的判斷、encode 編碼日誌、輸出日誌

  • encoder: json 或者其它編碼方式的實現

  • utils: SubLog,Hook,SurgarLog/grpclogger/stdlogger

logger: 物件 vs 接口

zap 對外提供的是 logger 物件和 field 和 level。 這是 zap 對外提供的基本語意: logger 物件打印 logfield 則是 log 的組織方式,level 跟打印的級別相關。 這些元素的組合是鬆散的但是聯繫確實緊密的。

有趣的是,zap 並沒有定義接口。 大家可能也很容易聯想到 Go 自身的 log 就不是接口。 在 go-dev 很多人曾經討論過 Go 的接口,有人討論為啥不提供接口 Standardization around logging and related concerns ,甚至有人提出過草案 Go Logging Design Proposal - Ross Light,然而最終也難逃被 Abandon 的命運。

歸根到底,reddit 上的一條評論總結最為到位:

No one seems to be able to agree what such an interface should look like。

在 zap 的早期版本中並沒有提供 zapcore 這個庫。 zapcore提供了zap 最核心的設計的邏輯封裝:執行級別判斷,添加 field 和 core,進行級別判斷傳回 checked entry

logger 是物件不是接口,但是 zapcore 卻是接口,logger 依賴 core 接口實現功能,其實是 logger 定義了接口,而 core 提供了接口的實現。 core 作為接口體現的是 zap 核心邏輯的抽象和設計理念,因此只需要約定邏輯,而實現則是多種的也是可以替換的,甚至可以基於 core 進行自定義的開發,這大大增加了靈活性。

zap field: format vs field

對於 zap 來說,為了性能其實犧牲掉了一定的易用性。例如 log.Printf("%s", &s;) format 這種方式是最自然的 log 姿勢,然而對於帶有反射的 Go 是致命的: 反射太過耗時。

下麵讓我們先來看看反射和 cast 的性能對比,結果是驚人的。

通過 fmt.Sprintf() 來組合 string 是最慢的,這因為 fmt.Printf 函式族使用反射來判斷型別.

fmt.Sprintf("%s", "hello world")

相比之下 string 的 + 操作基就會快很多,因為 Go 中的 string 型別本質上是一個特殊的 []byte。 執行 + 會將後續的內容追加在 string 物件的後面:

_ = s + "hello world"

然而對於追求極致的 zap 而言還不夠,如果是 []byte 的 append 則還要比 + 快2倍以上。 儘管這裡其實不是準確的,因為分配 []byte 時,如果不特殊指定 capacity 是會按照 2 倍的容量預分配空間。append 追加的 slice 如果容量不足,依然會引發一次 copy, 而 我們可以通過預分配足夠大容量的 slice 來避免該問題。zap 預設分配 1k 大小的 byte slice。

buf = append(buf, []byte("hello world")...)

表格的最下麵是接口反射和直接轉換的性能對比,field 通過指明型別避免了反射,zap 又針對每種型別直接提供了轉換 []byte + append 的函式,這樣的組合效率是極其高的。明確的呼叫對應型別的函式避免運行時刻的反射,可以看到 規避反射 這種型別操作是貫穿在整個 zap 的邏輯中的。

zap 的 append 家族函式封裝了 strconv.AppendX 函式族,該函式用於將型別轉換為 []byte 並 append 到給定的 slice 上。

zap 高性能的秘訣

對於大部分人來說,標準庫提供了改寫最全的工具和函式。 但是標準庫 為了通用有時候其實做了一些性能上的犧牲 。 而 zap 在細節上的性能調優確實下足了功夫,我們可以借鑒這些調優的思路和經驗。

避免 GC: 物件復用

Go 是提供了 gc 的語言。 gc 就像雙刃劍,給你了快捷的同時又會增加系統的負擔。 儘管 Go 官方宣稱 gc 性能很好,但是仍然無法繞開 Stop-The-World 的難題,一旦記憶體中的碎片較多 gc 仍然會有明顯尖峰,這種尖峰對於重 io 的業務來說是致命的。 zap 每打印1條日誌,至少需要2次記憶體分配:

  • 創建 field 時分配記憶體。

  • 將組織好的日誌格式化成標的 []byte 時分配記憶體。

zap 通過 sync.Pool 提供的物件池,復用了大量可以復用的物件,避開了 gc 這個大麻煩。

Go 的 sync.Pool 實現上提供的是 runtime 級別的系結到 Processor 的物件池。 物件池是否高效依賴於這個池的競爭是否過多,對此我曾經做過一次對比,使用 channel 實現了一個最簡單的物件池,但是 benchmark 的結果卻不盡如人意,完全不如 sync.Pool 高效。 究其原因,其實也可以理解,因為使用 channel 實現的物件池在多個 Processor 之間會有強烈的併發。儘管使用 sync.Pool 涉及到一次接口的轉換,性能依然是非常可觀的。

zap 也是使用了sync.Pool 提供的標準物件池。自身的 runtime 包含了 P 的處理邏輯,每個 P 都有自己的池在調度時不會發生競爭。 這個比起代碼中的軟實現更加高效,是用戶代碼做不到的邏輯。

sync.Pool 是Go提供給大家的一種優化 gc 的方式好方式儘管 Go 的 gc 已經有了長足的進步但是仍然不能夠繞開 gc 的 STW,因此合理的使用 pool 有助於提高代碼的性能,防止過多碎片化的記憶體分配與回收。

之前我們對於 pool 物件的討論中,最痛苦的一點就是是否應該包暴露 Free 函式。 最終的結論是如同 C/C++,資源的申請者應該決定何時釋放。 zap 的物件池管理也深諳此道。

  • buffer 實現了 io.Writer

  • Put 時並不執行 Reset

  • buffer 物件包含其物件池,因此可以在任何時刻將自己釋放(放回物件池)

內建的 Encoder: 避免反射

反射是 Go 提供給我們的另一個雙刃劍,方便但是不夠高效。 對於 zap ,規避反射貫穿在整個代碼中。 對於我們來說,創建json 物件只需要簡單的呼叫系統庫即可:

b, err := json.Marshal(&obj;)

對於 zap 這還不夠。標準庫中的 json.Marshaler 提供的是基於型別反射的拼接方式,代價是高昂的:

func (e *encodeState) marshal(v interface{}, opts encOpts) (err error) {
...
  e.reflectValue(reflect.ValueOf(v), opts) //reflect 根據 type 進行 marshal
...
}

反射的整體性能並不夠高,因此通過 Go 的反射可能導致額外的性能開銷。 zap 選擇了自己實現 json Encoder。 通過明確的型別呼叫,直接拼接字串,最小化性能開銷。

zap 的 json Encoder 設計的高效且較為易用,完全可以替換在代碼中。 另一方面,這也是 Go 長期以來缺乏泛型的一個痛點 。對於一些性能要求高的操作,如果標準庫偏向於易用性。那麼我們完全可以繞開標準庫,通過自己的實現,規避掉額外性能開銷。 同樣,上文提到的 field 也是這個原因。 通過一個完整的自建型別系統,zap 提供了從組合日誌到編碼日誌的整體邏輯,整個過程中都是可以。

ps. 據說 Go 在2.0 中就會加入泛型啦, 很期待

避免競態

zap 的高效還體現在對於併發的控制上。 zap 選擇了 寫時複製機制。 zap 把每條日誌都抽象成了 entry。 對於 entry 還分為2種不同型別:

  • Entry : 包含原始的信息 但是不包含 field

  • CheckedEntry: 經過級別檢查後的生成 CheckedEntry,包含了 Entry 和 Core。

CheckedEntry 的引入解決了組織日誌,編碼日誌的竟態問題。只有經過判斷的物件才會進入後續的邏輯,所有的操作 寫時觸發複製 ,沒有必要的操作不執行預分配。將操作與物件組織在一起,不進行資源的競爭,避免多餘的竟態條件。

對於及高性能的追求者來說,預先分配的 field 儘管有 pool 加持仍然是多餘的,因此 zap 提供了更高性能的接口,可以避免掉 field 的分配:

if ent := log.Check(zap.DebugLevel, "foo"); ent != nil {
  ent.Write(zap.String("foo", "bar"))
}

通過這一步判斷,如果該級別日誌不需要打印,那麼連 field 都不必生成。 避免一切不必要的開銷,zap 確實做到了而且做得很好。

多樣的功能與簡單的設計理念

level handler:

level handler 是 zap 提供的一種 level 的處理方式,通過 http 請求動態改變日誌組件級別。

對於日誌組件的動態修改,seelog 最早就有提供類似功能,基於 xml 檔案修改捕獲新的級別。 但是 xml 檔案顯然不夠 golang

zap 的解決方案是 http 請求。http 是大家廣泛應用的協議,zap 定義了 level handler 實現了 http.Handler 接口

Go 自身的 http 服務實現起來非常的簡潔:

http.HandleFunc("/handle/level", zapLevelHandler)
if err := http.ListenAndServe(addr, nil); err != nil {
   panic(err)
}

簡單幾行代碼就能實現 http 請求控制日誌級別的能力。 通過 GET獲取當前級別,PUT 設置新的級別。

zap 的 surgar log 和易用 config 接口封裝

我們的庫往往希望提供事無巨細的控制能力。但是對於簡單的使用者就不夠友好,繁雜的配置往往容易使人第一次使用即失去耐心。同時,一個全新的 log 接口設計也容易讓長期使用 format 方式打印日誌的人產生疑問。在工作中發現較多的用戶有這樣的需求: 你的這個庫怎麼用?

顯然只有 godoc 還不夠。

zap 的 Config 非常的繁瑣也非常強大,可以控制打印 log 的所有細節,因此對於我們開發者是友好的,有利於二次封裝。但是對於初學者則是噩夢。因此 zap 提供了一整套的易用配置,大部分的姿勢都可以通過一句代碼生成需要的配置。

func NewDevelopmentEncoderConfig() zapcore.EncoderConfig
func NewProductionEncoderConfig() zapcore.EncoderConfig
type SamplingConfig

同樣,對於不想付出代價學習使用 field 寫格式化 log 的用戶,zap 提供了 sugar log。 sugarlog 字面含義就是加糖。 給 zap 加點糖sugar log 提供了 formatter 接口,可以通過 format的方式來打印日誌。sugar 的實現封裝了 zap log,這樣既滿足了使用 printf 格式串的兼容性需求,同時也提供了更多的選擇,對於不那麼追求極致性能的場景提供了易用的方式。

sugar := log.Sugar()
sugar.Debugf("hello, world %s", "foo")

zap logger 提供的 utils

zap 還在 logger 這層提供了豐富的工具包,這讓整個 zap 庫更加的易用:

  • grpc logger:封裝 zap logger 可以直接提供給 grpc 使用,對於大多數的 Go 分佈式程式,grpc 都是預設的 rpc 方案,grpc 提供了 SetLogger 的接口。 zap 提供了對這個接口的封裝。

  • hook:作為 zap。Core 的實現,zap 提供了 hook。 使用方實現 hook 然後註冊到 logger,zap在合適的時機將日誌進行後續的處理,例如寫 kafka,統計日誌錯誤率 等等。

  • std Logger: zap 提供了將標準庫提供的 logger 物件重定向到 zap logger 中的能力,也提供了封裝 zap 作為標準庫 logger 輸出的能力。 整體上十分易用。

  • sublog: 通過創建 系結了 field 的子logger,實現了更加易用的功能。

zap 的好幫手: RollingWriter

zap 本身提供的是設置 writer 的接口,為此我實現了一套 io.Writer,通過rolling writer 實現了 log rotate 的功能。

rollingWriter 是一個 Go ioWriter 用於按照需求自動滾動檔案。 目的在於內置的實現 logrotate 的功能而且更加高效和易用。

具體可以見

https://github.com/arthurkiller/rollingWrite

總結

zap 在整體設計上有非常多精細的考量,不僅僅是在高性能上面的出色表現,更多的意義是其設計和工程實踐上。此處總結下 zap 的代碼之道:

  • 合理的代碼組織結構,結構清晰的抽象關係

  • 寫實複製,避免加鎖

  • 物件記憶體池,避免頻繁創建銷毀物件

  • 避免使用 fmt json/encode 使用字符編碼方式對日誌信息編碼,適用byte slice 的形式對日誌內容進行拼接編碼操作

其實 zap 帶給我們的遠不止這些,在這裡建議有興趣的朋友一定要抽時間看一下 zap 的原始碼,確實有很多細節需要我們細細體味。

相關閱讀:


高可用架構

改變互聯網的構建方式

長按二維碼 關註「高可用架構」公眾號


赞(0)

分享創造快樂

© 2021 知識星球   网站地图