全面解析 Elastic APM Go Agent:效能優化的祕密武器!

前陣子,我們在 Elastic APM 中引入了對 Go 應用程式效能監控的 Beta 支援。

而在這篇部落格文章中,我們將深入探討該代理程式的一些細節。我們將涵蓋的部分內容與即將推出的「正式版」(GA)代理程式的代碼分支相關,該版本採用了修訂後的接收協議和數據模型。

您可以在這裡閱讀更多關於新接收協議的資訊:Increasing Memory Efficiency with an Improved Elastic APM Internal Intake Protocol

在深入探討內部運作之前,讓我們先簡單聊聊這個代理程式的功能。

推薦閱讀:Elastic APM 是什麼?效能監控、基本架構、事件類型、應用場景全解析!

APM Agent 的一天

從最高層次來看,代理程式的目的在於測量並回報您的 Go 應用程式的響應時間效能,那麼,它是如何做到的呢?

為了測量應用程式的響應時間,您可以使用代理程式的 API 來為應用程式代碼進行檢測。

在每次您的應用程式/服務接收到的請求或所發起的操作中,記錄一筆交易(transaction)。一個交易描述了該操作所花費的整體時間,以及一些相關細節(稱為其「上下文」),以及結果(比如 HTTP 狀態碼)。在交易內,您還可以記錄更細粒度的細節,這些被稱為 span。

以上描述的即是代理程式的追蹤 API(很快將變為分布式追蹤 API)。

Elastic APM 還涵蓋了異常/錯誤報告;未來的版本將包括應用程式的指標功能以及更多特性。

該代理程式會將這些資料回報至 Elastic APM Server,並處理所有細節,包括取樣、緩衝、編碼、壓縮、連線管理等。只需對應用程式代碼進行檢測,並將代理程式指向 APM Server,資料就會被索引至 Elasticsearch,隨時可在 APM 的 UI 中進行視覺化展示!

最小化負擔

Go 開發者對於效能通常有著極高的期望,因此我們非常關注檢測帶來的額外負擔。

幸運的是,大部分工作已經有現成的工具處理!

Go 工具鏈內建了一些出色的性能分析工具,例如:pprof 和內建的基準測試功能。此外,標準庫在許多地方都經過精心設計,提供了選項來最小化內存分配的額外負擔。

我們需要做的,就是根據我們的特定限制,將這些元素整合起來。

那麼,這些限制是什麼?我們在設計所有代理程式時的一貫目標,就是避免對應用程式代碼造成任何干擾。

這可能以多種形式呈現,但特別重要的是,我們必須避免讓應用程式代碼被任意時間的阻塞,或者占用大量資源——特別是 CPU 和內存。

為了避免干擾您的應用程式,我們確保代理程式將大部分工作移至主代碼路徑之外。從實踐的角度來看,這意味着檢測代碼會收集有關交易的一些數據,然後將這些數據交由後台的 goroutine 處理,最終傳輸到 APM Server。

聽起來很簡單,但實際上這其中包含了一些細微的差異。

設計應對失敗

當背景 goroutine 無法跟上從檢測收集數據的輸入速率時會發生什麼?

阻塞應用程式不是一個可行的選項,因為這會影響原本要監控的效能。我們可以選擇丟棄數據,但這將影響通過 UI 報告的統計結果。

目前,Go agent 使用一個帶緩衝的 channel 將檢測到的應用程式代碼數據傳遞給背景 goroutine,而背景 goroutine 會儘可能快地從 channel 中取出條目進行進一步處理。

如果 channel 滿了,檢測代碼就會丟棄數據。每次發生這類數據丟棄事件時,計數器會自動增加,而這些統計數據會定期發送到伺服器。

目前,我們尚未利用這些數據,但未來我們將利用這些資訊來建議更改取樣配置。

那麼,如果 APM Server 無法訪問會發生什麼?無論是由於錯誤還是偶然事故,失敗總是會發生,因此我們必須針對這些情況設計系統,以確保其可靠性。

當 APM Server 無法連接時,我們會對數據進行緩衝,緩衝數據意味著會消耗內存資源,從而減少了我們正在監控的應用程式的資源可用量。

因此,我們會對所存儲的數據量設置限制。

在有限的緩衝空間內,我們需要決定哪些數據應保留,哪些數據應丟棄,何時進行這些操作。 我們必須考慮丟棄數據對統計結果的影響,以及如何高效地完成這一切。

對於 Go agent,我們實現了一個v環形緩衝區 來存儲經過編碼的事件。

每個編碼事件由一種類型和一段字節序列構成。當緩衝區滿時,該緩衝區中最舊的條目會被淘汰。

每個條目前有一個包含類型和條目大小(以字節為單位)的標頭。

通過在緩衝區內存儲事件類型,我們可以在統計數據中計入由於緩衝區淘汰而丟棄的事件。

設計加速

Go agent 的額外負擔已經降到幾乎最低。

以 net/http 檢測為例,在一台普通的開發者筆記型電腦上測得的時間負擔約為 1.5 微秒。整體上的效能調整關鍵歸結於一點:最小化分配。我們來看看幾項主要的優化措施。

第一項是使用對象池,特別是 sync.Pool

每當一個 transaction 或 span 開始時,我們會從 sync.Pool 中獲取對象。當 agent 完成事件的編碼——或者事件被丟棄時——就會將它歸還到對象池內。

透過這種方式,我們不僅可以避免分配操作,還可以保留相關的記憶體對象(例如,重置 slice 的長度,但保留其容量)。

需要注意的是,這一點必須融入 API 的設計中:為了啟用對象池,我們要求調用者在結束 transaction 或 span 後不得再引用它們。

下一個主要的優化是我們如何實現 JSON 編碼。

Go 標準庫提供了一個非常友好且易用的 JSON 編碼與解碼工具包:encoding/json

Elastic APM Go agent 的初始實現就使用了這個工具包(我們始終相信應先讓程式能運行,繼而讓它正確,最後才追求速度),但很快發現編碼是導致資源消耗的主要原因之一。

這有幾個原因:encoding/json 嚴重依賴反射(reflection),其 API 的部分設計還會強制執行分配操作(理論上 encoder/json.Encoder.Encode 可以避免分配,但實際執行中並未如此)。

市場上已存在多種替代的 JSON 編碼器,例如 easyjsonffjsongo-codec 等。

然而,它們的性能都無法完全滿足我們的特定限制,但我們從中汲取了靈感。

最終,我們實現了一種零拷貝、零分配的 JSON 編碼器,這並未依賴 sync.Pool,而是直接編碼到一個可重用的緩衝區中。與上述工具包類似,我們的實現依賴於通過解析結構體定義來生成代碼。

$ go test -benchmem -bench=. -run=NONE github.com/elastic/apm-agent-go/model
goos: linux
goarch: amd64
pkg: github.com/elastic/apm-agent-go/model
BenchmarkMarshalTransactionFastJSON-8 1000000 1418 ns/op 585.70 MB/s 0 B/op 0 allocs/op
BenchmarkMarshalTransactionStdlib-8 200000 9303 ns/op 127.91 MB/s 1088 B/op 23 allocs/op
PASS
ok github.com/elastic/apm-agent-go/model 3.403

目前,fastjson 編碼器位於 Go agent 的內部套件目錄中,但我們計劃在不久的將來將其移至一個獨立的儲存庫,以便其他人也能使用它。

持續基準測試

在 Elastic,我們堅持「飲用自己的香檳」:我們使用 Elastic Stack 來分析幾乎所有的工作,包括存儲構建結果和基準測試數據以進行分析。

針對 Go agent,我們創建了 gobench,一個用於將 go test -bench 輸出結果存儲到 Elasticsearch 的工具,使得繪製基準測試數據的時間圖表變得簡單,並能輕鬆識別出有問題的更改。

本文翻譯自:Inside the Elastic APM Go Agent

想要了解更多 Elastic 資訊,歡迎加入歐立威 Line 好友!

Related Posts