全面解析 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 編碼器,例如 easyjson、ffjson、go-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 好友!













