全面解析 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,我們實現了一個環形緩衝區來存儲經過編碼的事件。
每個編碼事件由一種類型和一段字節序列構成。
當緩衝區滿時,該緩衝區中最舊的條目會被淘汰。
每個條目前有一個包含類型和條目大小(以字節為單位)的標頭。
通過在緩衝區內存儲事件類型,我們可以在統計數據中計入由於緩衝區淘汰而丟棄的事件。
設計加速
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 的工具,使得繪製基準測試數據的時間圖表變得簡單,並能輕鬆識別出有問題的更改。

我們將在未來的部落格文章中更詳細地介紹 agent 的基準測試。
目前,歡迎嘗試使用 gobench 工具,有疑問都可以聯繫我們。
本文翻譯自:Inside the Elastic APM Go Agent
想要了解更多 Elastic 資訊,歡迎加入歐立威 Line 好友!