一文看懂分佈式鏈路監控系統
本文通過阿里的 Eagleeye(鷹眼)和開源的 Skywalking,從數據模型、數據埋點以及數據存儲三個方面介紹分佈式鏈路監控系統的實現細節,其中將重點介紹 Skywalking 字節碼增強的實現方案。
背景
傳統的大型單體系統隨着業務體量的增大已經很難滿足市場對技術的需求,通過對將整塊業務系統拆分爲多個互聯依賴的子系統並針對子系統進行獨立優化,能夠有效提升整個系統的吞吐量。在進行系統拆分之後,完整的業務事務邏輯所對應的功能會部署在多個子系統上,此時用戶的一次點擊請求會觸發若干子系統之間的相互功能調用,如何分析一次用戶請求所觸發的多次跨系統的調用過程、如何定位存在響應問題的調用鏈路等等問題是鏈路追蹤技術所要解決的問題。
舉一個網絡搜索的示例,來說明這樣一個鏈路監控系統需要解決的一些挑戰。當用戶在搜索引擎中輸入一個關鍵詞後,一個前端服務可能會將這次查詢分發給數百個查詢服務,每個查詢服務在其自己的索引中進行搜索。該查詢還可以被髮送到許多其他子系統,這些子系統可以處理敏感詞彙、檢查拼寫、用戶畫像分析或尋找特定領域的結果,包括圖像、視頻、新聞等。所有這些服務的結果有選擇地組合在一起,最終展示在搜索結果頁面中,我們將這個模型稱爲一次完整的搜索過程。
在這樣一次搜索過程中,總共可能需要數千臺機器和許多不同的服務來處理一個通用搜索查詢。此外,在網絡搜索場景中,用戶的體驗和延遲緊密相關,一次搜索延時可能是由於任何子系統的性能不佳造成的。開發人員僅考慮延遲可能知道整個系統存在問題,但卻無法猜測哪個服務有問題,也無法猜測其行爲不良的原因。首先,開發人員可能無法準確知道正在使用哪些服務,隨時都可能加入新服務和修改部分服務,以增加用戶可見的功能,並改進性能和安全性等其他方面;其次,開發人員不可能是龐大系統中每個內部微服務的專家,每一個微服務可能有不同團隊構建和維護;另外,服務和機器可以由許多不同的客戶端同時共享,因此性能問題可能是由於另一個應用的行爲引起。
Dapper 簡介
在分佈式鏈路追蹤方面,Google 早在 2010 年針對其內部的分佈式鏈路跟蹤系統 Dapper[1],發表了相關論文對分佈式鏈路跟蹤技術進行了介紹(強烈推薦閱讀)。其中提出了兩個基本要求。第一,擁有廣泛的覆蓋面。針對龐大的分佈式系統,其中每個服務都需要被監控系統覆蓋,即使是整個系統的一小部分沒有被監控到,該鏈路追蹤系統也可能是不可靠的。第二,提供持續的監控服務。對於鏈路監控系統,需要 7*24 小時持續保障業務系統的健康運行,保證任何時刻都可以及時發現系統出現的問題,並且通常情況下很多問題是難以復現的。根據這兩個基本要求,分佈式鏈路監控系統的有如下幾個設計目標:
- 應用級透明
鏈路監控組件應該以基礎通用組件的方式提供給用戶,以提高穩定性,應用開發者不需要關心它們。對於 Java 語言來說,方法可以說是調用的最小單位,想要實現對調用鏈的監控埋點勢必對方法進行增強。Java 中對方法增強的方式有很多,比如直接硬編碼、動態代理、字節碼增強等等。應用級透明其實是一個比較相對的概念,透明度越高意味着難度越大,對於不同的場景可以採用不同的方式。
- 低開銷
低開銷是鏈路監控系統最重要的關注點,分佈式系統對於資源和性能的要求本身就很苛刻,因此監控組件必須對原服務的影響足夠小,將對業務主鏈路的影響降到最低。鏈路監控組件對於資源的消耗主除了體現在增強方法的消耗上,其次還有網絡傳輸和數據存儲的消耗,因爲對於鏈路監控系統來說,想要監控一次請求勢必會產生出請求本身外的額外數據,並且在請求過程中,這些額外的數據不僅會暫時保存在內存中,在分佈式場景中還會伴隨着該請求從上游服務傳輸至下游服務,這就要求產生的額外數據儘可能地少,並且在伴隨請求進行網絡傳輸的時候只保留少量必要的數據。
- 擴展性和開放性
無論是何種軟件系統,可擴展性和開放性都是衡量其質量優劣的重要標準。對於鏈路監控系統這樣的基礎服務系統來說,上游業務系統對於鏈路監控系統來說是透明的,在一個規模較大的企業中,一個基礎服務系統往往會承載成千上萬個上游業務系統。每個業務系統由不同的團隊和開發人員負責,雖然使用的框架和中間件在同一個企業中有大致的規範和要求,但是在各方面還是存在差異的。因此作爲一個基礎設施,鏈路監控系統需要具有非常好的可擴展性,除了對企業中常用中間件和框架的支撐外,還要能夠方便開發人員針對特殊的業務場景進行定製化的開發。
數據模型
OpenTracing 規範
Dapper 將請求按照三個維度劃分爲 Trace、Segment、Span 三種模型,該模型已經形成了 OpenTracing[2] 規範。OpenTracing 是爲了描述分佈式系統中事務的語義,而與特定下游跟蹤或監控系統的具體實現細節無關,因此描述這些事務不應受到任何特定後端數據展示或者處理的影響。大的概念就不多介紹了,重點看一下 Trace、Segment、Span 這三種模型到底是什麼。
- Trace
表示一整條調用鏈,包括跨進程、跨線程的所有 Segment 的集合。
- Segment
表示一個進程(JVM)或線程內的所有操作的集合,即包含若干個 Span。
- Span
表示一個具體的操作。Span 在不同的實現裏可能有不同的劃分方式,這裏介紹一個比較容易理解的定義方式:
1、Entry Span:入棧 Span。Segment 的入口,一個 Segment 有且僅有一個 Entry Span,比如 HTTP 或者 RPC 的入口,或者 MQ 消費端的入口等。
2、Local Span:通常用於記錄一個本地方法的調用。
3、Exit Span:出棧 Span。Segment 的出口,一個 Segment 可以有若干個 Exit Span,比如 HTTP 或者 RPC 的出口,MQ 生產端,或者 DB、Cache 的調用等。
按照上面的模型定義,一次用戶請求的調用鏈路圖如下所示:
唯一 id
每個請求有唯一的 id 還是很必要的,那麼在海量的請求下如何保證 id 的唯一性並且能夠包含請求的信息?Eagleeye 的 traceId 設計如下:
根據這個 id,我們可以知道這個請求在 2022-10-18 10:10:40 發出,被 11.15.148.83 機器上進程號爲 14031 的 Nginx(對應標識位 e)接收到。其中的四位原子遞增數從 0-9999,目的是爲了防止單機併發造成 traceId 碰撞。
關係描述
將請求劃分爲 Trace、Segment、Span 三個層次的模型後,如何描述他們之間的關係?
從【OpenTracing 規範】一節的調用鏈路圖中可以看出,Trace、Segment 可以作爲整個調用鏈路中的邏輯結構,而 Span 纔是真正串聯起整個鏈路的單元,系統可以通過若干個 Span 串聯起整個調用鏈路。
在 Java 中,方法是以入棧、出棧的形式進行調用,那麼系統在記錄 Span 的時候就可以通過模擬出棧、入棧的動作來記錄 Span 的調用順序,不難發現最終一個鏈路中的所有 Span 呈現樹形關係,那麼如何描述這棵 Span 樹?Eagleeye 中的設計很巧妙,EagleEye 設計了 RpcId 來區別同一個調用鏈下多個網絡調用的順序和嵌套層次。如下圖所示:
RpcId 用 0.X1.X2.X3.....Xi 來表示,根節點的 RpcId 固定從 0 開始,id 的位數("." 的數量)表示了 Span 在這棵樹中的層級,Id 最後一位表示了 Span 在這一層級中的順序。那麼給定同一個 Trace 中的所有 RpcId,便可以很容易還原出一個完成的調用鏈:
- 0
- 0.1
- 0.1.1
- 0.1.2
- 0.1.2.1
- 0.2
- 0.2.1
- 0.3
- 0.3.1
- 0.3.1.1
- 0.3.2
跨進程傳輸
再進一步,在整個調用鏈的收集過程中,不可能將整個 Trace 信息隨着請求攜帶到下個應用中,爲了將跨進程傳輸的 trace 信息減少到最小,每個應用(Segment)中的數據一定是分段收集的,這樣在 Eagleeye 的實現下跨 Segment 的過程只需要攜帶 traceId 和 rpcid 兩個簡短的信息即可。在服務端收集數據時,數據自然也是分段到達服務端的,但由於種種原因分段數據可能存在亂序和丟失的情況:
如上圖所示,收集到一個 Trace 的數據後,通過 rpcid 即可還原出一棵調用樹,當出現某個 Segment 數據缺失時,可以用第一個子節點替代。
數據埋點
如何進行方法增強(埋點)是分佈式鏈路追系統的關鍵因素,在 Dapper 提出的要求中可以看出,方法增強同時要滿足應用級透明和低開銷這兩個要求。之前我們提到應用級透明其實是一個比較相對的概念,透明度越高意味着難度越大,對於不同的場景可以採用不同的方式。本文我們介紹阿里的 Eagleye 和開源的 SkyWalking 來比較兩種埋點方式的優劣。
編碼
阿里 Eagleye 的埋點方式是直接編的碼方式,通過中間件預留的擴展點實現。但是按照我們通常的理解來說,編碼對於 Dapper 提出的擴展性和開放性似乎並不友好,那爲什 Eagleye 麼要採用這樣的方式?個人認爲有以下幾點:
1、阿里有中間件的使用規範,不是想用什麼就用什麼,因此對於埋點的覆蓋範圍是有限的;
2、阿里有給力的中間件團隊專門負責中間件的維護,中間件的埋點對於上層應用來說也是應用級透明的,對於埋點的覆蓋是全面的;
3、阿里應用有接入 Eagleye 監控系統的要求,因此對於可插拔的訴求並沒有非常強烈。
從上面幾點來說,編碼方式的埋點完全可以滿足 Eagleye 的需要,並且直接編碼的方式在維護、性能消耗方面也是非常有優勢的。
字節碼增強
相比於 Eagleye,SkyWalking 這樣開源的分佈式鏈路監控系統,在開源環境下就沒有這麼好做了。開源環境下面臨的問題其實和阿里集團內部的環境正好相反:
1、開源環境下每個開發者使用的中間件可能都不一樣,想用什麼就用什麼,因此對於埋點的覆蓋範圍幾乎是無限的;
2、開源環境下,各種中間件都由不同組織或個人進行維護,甚至開發者還可以進行二次開發,不可能說服他們在代碼中加入鏈路監控的埋點;
3、開源環境下,並不一定要接入鏈路監控體系,大多數個人開發者由於資源有限或其他原因沒有接入鏈路監控系統的需求。
從上面幾點來說,編碼方式的埋點肯定是無法滿足 SkyWalking 的需求的。針對這樣的情況,Skywalking 採用如下的開發模式:
Skywalking 提供了核心的字節碼增強能力和相關的擴展接口,對於系統中使用到的中間件可以使用官方或社區提供的插件打包後植入應用進行埋點,如果沒有的話甚至可以自己開發插件實現埋點。Skywalking 採用字節碼增強的方式進行埋點,下面簡單介紹字節碼增強的相關知識和 Skywalking 的相關實現。
兩種方式
對 Java 應用實現字節碼增強的方式有 Attach 和 Javaagent 兩種,本文做一個簡單的介紹。
Attach
Attach 是一種相對動態的方式,在阿爾薩斯(Arthas)這樣的診斷系統中廣泛使用,利用 JVM 提供的 Attach API 可以實現一個 JVM 對另一個運行中的 JVM 的通信。用一個具體的場景舉例:我們要實現 Attach JVM 對一個運行中 JVM 的監控。如下圖所示:
1、Attach JVM 利用 Attach API 獲取目標 JVM 的實例,底層會通過 socketFile 建立兩個 JVM 間的通信;
2、Attach JVM 指定目標 JVM 需要掛載的 agent.jar 包,掛載成功後會執行 agent 包中的 agentmain 方法,此時就可以對目標 JVM 中類的字節碼進行修改;
3、Attach JVM 通過 Socket 向目標 JVM 發送命令,目標 JVM 收到後會進行響應,以達到監控的目的。
雖然 Attach 可以靈活地對正在運行中的 JVM 進行字節碼修改,但在修改時也會受到一些限制,比如不能增減父類、不能增加接口、不能調整字段等。
Javaagent
Javaagent 大家應該相對熟悉,他的啓動方式是在啓動命令中加入 javaagent 參數,指定需要掛載的 agent:
java -javaagent:/path/agent.jar=key1=value1,key2=value2 -jar myJar.jar
Javaagent 在 IDE 的 Debug 模式、鏈路監控系統等場景中廣泛使用。它的核心是在目標 JVM 執行 main 方法前執行 agent 的 premain 方法,以插入前置邏輯:
1、目標 JVM 通過 javaagent 參數啓動後找到指定的 agent,執行 agent 的 premain 方法;
2、agent 中通過 JVM 暴露的接口添加一個 Transformer,顧名思義它可以 Transform 字節碼;
3、目標 JVM 在類加載的時候會觸發 JVM 內置的事件,回調 Transformer 以實現字節碼的增強。
和 Attach 方式相比,Javaagent 只能在 main 方法之前執行。但是在修改字節碼時較爲靈活,甚至可以修改 JDK 的核心類庫。
字節碼增強類庫
Java 提供了很多字節碼增強類庫,比如大家耳熟能詳的 cglib、Javassist,原生的 Jdk Proxy 還有底層的 ASM 等。在 2014 年,一款名爲 Byte Buddy[3] 的字節碼增強類庫橫空出世,並在 2015 年獲得 Duke's Choice award。Byte Buddy 兼顧高性能、易用、功能強大 3 個方面,下面是摘自其官網的一張常見字節碼增強類庫性能比較圖(單位: 納秒):
上圖中的對比項我們可以大致分爲兩個方面:生成快速代碼(方法調用、父類方法調用)和快速生成代碼(簡單類創建、接口實現、類型擴展),我們理所應當要優先選擇前者。從數據可以看出 Byte Buddy 在納秒級的精度下,在方法調用和父類方法調用上和基線基本沒有差距,而位於其後的是 cglib。
Byte Buddy 和 cglib 有較爲出色的性能得益於它們底層都是基於 ASM 構建,如果將 ASM 也加入對比那麼它的性能一定是最高的。但是用過 ASM 的同學雖然不一定能感受到它的高性能,但一定能感受到它噩夢般的開發體驗:
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("begin of sayhello().");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
Skywalking 案例分析
介紹了這麼多,下面結合 Skywalking 中使用 Byte Buddy 的案例和大家一起體驗下字節碼增強的開發過程,其中只簡單介紹相關主流程代碼,各種細節就不介紹了。
插件模型
Skywalking 爲開發者提供了簡單易用的插件接口,對於開發者來說不需要知道怎麼增強方法的字節碼,只需要關心以下幾點:
- 要增強哪個類的哪個方法?
Skywalking 提供了 ClassMatch,支持各種類、方法的匹配方式。包括類名、前綴、正則、註解等方式的匹配,除此之外還提供了與、或、非邏輯鏈接,以支持用戶通過各種方式精確定位到一個具體的方法。我們看一個插件中的代碼:
這段邏輯表示需要增強不帶 annotation1 註解,並且帶有 annotaion2 註解或 annotaion3 註解的方法的字節碼。ClassMatch 通過 Builder 模式提供用戶流式編程的方式,最終 Skywalking 會將用戶提供的一串 ClassMatch 構建出一個內部使用的類匹配邏輯。
- 需要添加 / 修改什麼邏輯?
知道了需要增強哪個類的哪個方法,那下一步就是如何增強。Java 中的方法可以分爲靜態方法、實例方法和構造方法三類方法,Skywalking 對於這三種方法的增強邏輯爲用戶提供了不同的擴展點:
以實例方法爲例,Skywalking 提供瞭如下實例方法攔截器:
public interface InstanceMethodsAroundInterceptor {
// 方法執行前置擴展點
void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
MethodInterceptResult result) throws Throwable;
// 方法執行後置擴展點
Object afterMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
Object ret) throws Throwable;
// 方法拋出異常時擴展點
void handleMethodException(EnhancedInstance objInst, Method method, Object[] allArguments,
Class<?>[] argumentsTypes, Throwable t);
}
開發者通過實現該接口即可對一個實例方法進行邏輯擴展(字節碼增強)。方法參數列表中的第一個類型爲 EnhancedInstance 的參數其實就是當前對象(this),Skywalking 中所有實例方法或構造方法被增強的類都會實現 EnhancedInstance 接口。
假設我們有一個 Controller,裏面只有一個 sayHello 方法返回 "Hello",經過 Skywalking 增強後,反編譯一下它被增強後的字節碼文件:
可以看到:
1、Skywalking 在其中插入了一個名爲_$EnhancedClassField_ws 的字段,開發者在某些場合可以合理利用該字段存儲一些信息。比如存儲 Spring MVC 中 Controller 的跟路徑,或者 Jedis、HttpClient 鏈接中對端信息等。
2、原來的 syHello 方法名被修改了但仍保存下來,並且新生成了一個增強後的 sayHello 方法,靜態代碼塊裏將經過字節碼增強後的 sayHello 方法存入緩存字段。
增強的前置條件是什麼?
在某些時候,並不是只要引入了對應插件就一定會對相關的代碼進行字節碼增強。比如我們想對 Spring MVC 的 Controller 進行埋點,我們使用的是 Spring 4.x 版本,但是插件卻是 5.x 版本的,如果直接對源碼進行增強可能會因爲版本的差別帶來意料之外的問題。Skywalking 提供了一種 witness 機制,簡單來說就是當我們的代碼中存在指定的類或方式時,當前插件纔會進行字節碼增強。比如 Spring 4.x 版本中需要 witness 這兩個類:
如果粒度不夠,還可以對方法進行 witness。比如 Elastic Search 6.x 版本中 witness 了這個方法:
意思就是 SearchHits 類中必須有名爲 getTotalHits、參數列表爲空並且返回 long 的方法。
除了上面的擴展點外,Skywalking 還支持對 jdk 核心類庫的字節碼增強,比如對 Callable 和 Runnable 進行增強已支持異步模式下的埋點透傳。這就需要和 BootstrapClassLoader 打交道了,Skywalking 幫我們完成了這些複雜的邏輯。Skywalking Agent 部分整體的模型如下圖所示:
左側 SPI 部分是 Skywalking 暴露的插件規範接口,開發者根據這些接口實現插件。右側 Core 部分負責加載插件並且利用 Byte Buddy 提供的字節碼增強邏輯對應用中指定類和方法的字節碼進行增強。
主流程源碼
介紹了 Skywalking 的插件模型後,下面從 Javaagent 的入口 premain 開始介紹下主要的流程:
上面的流程主要做了兩件事:
1、從指定的目錄加載所有插件到內存中;
2、構建 Byte Buddy 核心的 AgentBuilder 插樁到 JVM 的 Instrumentation API 上,包括需要增強哪些類以及核心的增強邏輯 Transformer。
private static class Transformer implements AgentBuilder.Transformer {
private PluginFinder pluginFinder;
Transformer(PluginFinder pluginFinder) {
this.pluginFinder = pluginFinder;
}
/**
* 這個方法在類加載的過程中會由JVM調用(Byte Buddy做了封裝)
* @param builder 原始類的字節碼構建器
* @param typeDescription 類描述信息
* @param classLoader 這個類的類加載器
* @param module jdk9中模塊信息
* @return 修改後的類的字節碼構建器
*/
@Override
public DynamicType.Builder<?> transform(final DynamicType.Builder<?> builder,
final TypeDescription typeDescription,
final ClassLoader classLoader,
final JavaModule module) {
LoadedLibraryCollector.registerURLClassLoader(classLoader);
// 根據類信息找到針對這個類進行字節碼增強的插件,可能有多個
List<AbstractClassEnhancePluginDefine> pluginDefines = pluginFinder.find(typeDescription);
if (pluginDefines.size() > 0) {
DynamicType.Builder<?> newBuilder = builder;
EnhanceContext context = new EnhanceContext();
for (AbstractClassEnhancePluginDefine define : pluginDefines) {
// 調用插件的define方法得到新的字節碼
DynamicType.Builder<?> possibleNewBuilder = define.define(
typeDescription, newBuilder, classLoader, context);
if (possibleNewBuilder != null) {
newBuilder = possibleNewBuilder;
}
}
// 返回增強後的字節碼給JVM,完成字節碼增強
return newBuilder;
}
return builder;
}
}
JVM 在類加載的時候會觸發 JVM 內置事件,回調 Transformer 傳入原始類的字節碼、類加載器等信息,從而實現對字節碼的增強。其中的 AbstractClassEnhancePluginDefine 就是一個插件的抽象。
public abstract class AbstractClassEnhancePluginDefine {
public DynamicType.Builder<?> define(TypeDescription typeDescription, DynamicType.Builder<?> builder,
ClassLoader classLoader, EnhanceContext context) throws PluginException {
// witness機制
WitnessFinder finder = WitnessFinder.INSTANCE;
//通過類加載器找witness類,沒有就直接返回,不進行字節碼的改造
String[] witnessClasses = witnessClasses();
if (witnessClasses != null) {
for (String witnessClass : witnessClasses) {
if (!finder.exist(witnessClass, classLoader)) {
return null;
}
}
}
//通過類加載器找witness方法,沒有就直接返回,不進行字節碼的改造
List<WitnessMethod> witnessMethods = witnessMethods();
if (!CollectionUtil.isEmpty(witnessMethods)) {
for (WitnessMethod witnessMethod : witnessMethods) {
if (!finder.exist(witnessMethod, classLoader)) {
return null;
}
}
}
// enhance開始修改字節碼
DynamicType.Builder<?> newClassBuilder = this.enhance(typeDescription, builder, classLoader, context);
// 修改完成,返回新的字節碼
context.initializationStageCompleted();
return newClassBuilder;
}
protected DynamicType.Builder<?> enhance(TypeDescription typeDescription, DynamicType.Builder<?> newClassBuilder,
ClassLoader classLoader, EnhanceContext context) throws PluginException {
// 增強靜態方法
newClassBuilder = this.enhanceClass(typeDescription, newClassBuilder, classLoader);
// 增強實例方法& 構造方法
newClassBuilder = this.enhanceInstance(typeDescription, newClassBuilder, classLoader, context);
return newClassBuilder;
}
}
通過 witness 機制檢測滿足條件後,對靜態方法、實例方法和構造方法進行字節碼增強。我們以實例方法和構造方法爲例:
public abstract class ClassEnhancePluginDefine extends AbstractClassEnhancePluginDefine {
protected DynamicType.Builder<?> enhanceInstance(TypeDescription typeDescription,
DynamicType.Builder<?> newClassBuilder, ClassLoader classLoader,
EnhanceContext context) throws PluginException {
// 獲取插件定義的構造方法攔截點ConstructorInterceptPoint
ConstructorInterceptPoint[] constructorInterceptPoints = getConstructorsInterceptPoints();
// 獲取插件定義的實例方法攔截點InstanceMethodsInterceptPoint
InstanceMethodsInterceptPoint[] instanceMethodsInterceptPoints = getInstanceMethodsInterceptPoints();
String enhanceOriginClassName = typeDescription.getTypeName();
// 非空校驗
boolean existedConstructorInterceptPoint = false;
if (constructorInterceptPoints != null && constructorInterceptPoints.length > 0) {
existedConstructorInterceptPoint = true;
}
boolean existedMethodsInterceptPoints = false;
if (instanceMethodsInterceptPoints != null && instanceMethodsInterceptPoints.length > 0) {
existedMethodsInterceptPoints = true;
}
if (!existedConstructorInterceptPoint && !existedMethodsInterceptPoints) {
return newClassBuilder;
}
// 這裏就是之前提到的讓類實現EnhancedInstance接口,並添加_$EnhancedClassField_ws字段
if (!typeDescription.isAssignableTo(EnhancedInstance.class)) {
if (!context.isObjectExtended()) {
// Object類型、private volatie修飾符、提供方法進行訪問
newClassBuilder = newClassBuilder.defineField(
"_$EnhancedClassField_ws", Object.class, ACC_PRIVATE | ACC_VOLATILE)
.implement(EnhancedInstance.class)
.intercept(FieldAccessor.ofField("_$EnhancedClassField_ws"));
context.extendObjectCompleted();
}
}
// 構造方法增強
if (existedConstructorInterceptPoint) {
for (ConstructorInterceptPoint constructorInterceptPoint : constructorInterceptPoints) {
// jdk核心類
if (isBootstrapInstrumentation()) {
newClassBuilder = newClassBuilder.constructor(constructorInterceptPoint.getConstructorMatcher())
.intercept(SuperMethodCall.INSTANCE.andThen(MethodDelegation.withDefaultConfiguration()
.to(BootstrapInstrumentBoost
.forInternalDelegateClass(constructorInterceptPoint
// 非jdk核心類 .getConstructorInterceptor()))));
} else {
// 找到對應的構造方法,並通過插件自定義的InstanceConstructorInterceptor進行增強
newClassBuilder = newClassBuilder.constructor(constructorInterceptPoint.getConstructorMatcher())
.intercept(SuperMethodCall.INSTANCE.andThen(MethodDelegation.withDefaultConfiguration()
.to(new ConstructorInter(constructorInterceptPoint
.getConstructorInterceptor(), classLoader))));
}
}
}
// 實例方法增強
if (existedMethodsInterceptPoints) {
for (InstanceMethodsInterceptPoint instanceMethodsInterceptPoint : instanceMethodsInterceptPoints) {
// 找到插件自定義的實例方法攔截器InstanceMethodsAroundInterceptor
String interceptor = instanceMethodsInterceptPoint.getMethodsInterceptor();
// 這裏在插件自定義的匹配條件上加了一個【不爲靜態方法】的條件
ElementMatcher.Junction<MethodDescription> junction = not(isStatic()).and(instanceMethodsInterceptPoint.getMethodsMatcher());
// 需要重寫入參
if (instanceMethodsInterceptPoint.isOverrideArgs()) {
// jdk核心類
if (isBootstrapInstrumentation()) {
newClassBuilder = newClassBuilder.method(junction)
.intercept(MethodDelegation.withDefaultConfiguration()
.withBinders(Morph.Binder.install(OverrideCallable.class))
.to(BootstrapInstrumentBoost.forInternalDelegateClass(interceptor)));
// 非jdk核心類
} else {
newClassBuilder = newClassBuilder.method(junction)
.intercept(MethodDelegation.withDefaultConfiguration()
.withBinders(Morph.Binder.install(OverrideCallable.class))
.to(new InstMethodsInterWithOverrideArgs(interceptor, classLoader)));
}
// 不需要重寫入參
} else {
// jdk核心類
if (isBootstrapInstrumentation()) {
newClassBuilder = newClassBuilder.method(junction)
.intercept(MethodDelegation.withDefaultConfiguration()
.to(BootstrapInstrumentBoost.forInternalDelegateClass(interceptor)));
// 非jdk核心類
} else {
// 找到對應的實例方法,並通過插件自定義的InstanceMethodsAroundInterceptor進行增強
newClassBuilder = newClassBuilder.method(junction)
.intercept(MethodDelegation.withDefaultConfiguration()
.to(new InstMethodsInter(interceptor, classLoader)));
}
}
}
}
return newClassBuilder;
}
}
根據是否要重寫入參、是否是核心類走到不同的邏輯分支,大致的增強邏輯大差不差,就是根據用戶自定義的插件找到需要增強的方法和增強邏輯,利用 Byte Buddy 類庫進行增強。
用戶通過方法攔截器實現增強邏輯,但是它是面向用戶的,並不能直接用來進行字節碼增強,Skywalking 加了一箇中間層來連接用戶邏輯和 Byte Buddy 類庫。上述代碼中的 XXXInter 便是中間層,比如針對實例方法的 InstMethodsInter:
InstMethodsInter 封裝用戶自定義的邏輯,並且對接 ByteBuddy 的核心類庫,當執行到被字節碼增強的方法時會執行 InstMethodsInter 的 intercept 方法(可以和上面反編譯被增強後類的字節碼文件進行對比):
public class InstMethodsInter {
private static final ILog LOGGER = LogManager.getLogger(InstMethodsInter.class);
// 用戶在插件中定義的實例方法攔截器
private InstanceMethodsAroundInterceptor interceptor;
public InstMethodsInter(String instanceMethodsAroundInterceptorClassName, ClassLoader classLoader) {
try {
// 加載用戶在插件中定義的實例方法攔截器
interceptor = InterceptorInstanceLoader.load(instanceMethodsAroundInterceptorClassName, classLoader);
} catch (Throwable t) {
throw new PluginException("Can't create InstanceMethodsAroundInterceptor.", t);
}
}
/**
* 當執行被增強方法時,會執行該intercept方法
*
* @param obj 實例對象(this)
* @param allArguments 方法入參
* @param method 參數描述
* @param zuper 原方法調用的句柄
* @param method 被增強後的方法的引用
* @return 方法返回值
*/
@RuntimeType
public Object intercept(@This Object obj, @AllArguments Object[] allArguments, @SuperCall Callable<?> zuper,
@Origin Method method) throws Throwable {
EnhancedInstance targetObject = (EnhancedInstance) obj;
MethodInterceptResult result = new MethodInterceptResult();
try {
// 攔截器前置邏輯
interceptor.beforeMethod(targetObject, method, allArguments, method.getParameterTypes(), result);
} catch (Throwable t) {
LOGGER.error(t, "class[{}] before method[{}] intercept failure", obj.getClass(), method.getName());
}
Object ret = null;
try {
// 是否中斷方法執行
if (!result.isContinue()) {
ret = result._ret();
} else {
// 執行原方法
ret = zuper.call();
// 爲什麼不能走method.invoke?因爲method已經是被增強後方法,調用就死循環了!
// 可以回到之前的字節碼文件查看原因,看一下該intercept執行的時機
}
} catch (Throwable t) {
try {
// 攔截器異常時邏輯
interceptor.handleMethodException(targetObject, method, allArguments, method.getParameterTypes(), t);
} catch (Throwable t2) {
LOGGER.error(t2, "class[{}] handle method[{}] exception failure", obj.getClass(), method.getName());
}
throw t;
} finally {
try {
// 攔截器後置邏輯
ret = interceptor.afterMethod(targetObject, method, allArguments, method.getParameterTypes(), ret);
} catch (Throwable t) {
LOGGER.error(t, "class[{}] after method[{}] intercept failure", obj.getClass(), method.getName());
}
}
return ret;
}
}
上述邏輯其實就是下圖中紅框中的邏輯:
Byte Buddy 提供了聲明式方式,通過幾個註解就可以實現字節碼增強邏輯。
數據收集
下一步就是將收集到的 Trace 數據發送到服務端。爲了將對主鏈路的影響降到最小,一般都採用先存本地、再異步採集的方式。Skywalking 和 Eagleeye 的實現有所不同,我們分別介紹:
存儲
Eagleeye
鷹眼採用併發環形隊列存儲 Trace 數據,如下圖所示:
環形隊列在很多日誌框架的異步寫入過程中很常見,其中主要包括讀指針 take,指向隊列中的最後一條數據;寫指針 put,指向隊列中下一個數據將存放的位置,並且支持原子讀、寫數據。take 和 put 指針朝一個時鐘方向移動,當生產數據的速度超過消費速度時,會出現 put 指針 “追上”take 指針的情況(套圈),此時根據不同的策略可以丟棄即將寫入的數據或將老數據覆蓋。
Skywalking
Skywalking 在實現上有所區別,採用分區的 QueueBuffer 存儲 Trace 數據,多個消費線程通過 Driver 平均分配到各個 QueueBuffer 上進行數據消費:
QueueBuffer 有兩種實現,除了基於 JDK 的阻塞隊列外,還有一種普通數組 + 原子下標的方式。Skywalking 對於這兩種實現有不同的使用場景:基於 JDK 阻塞隊列的實現用在服務端,而普通數組 + 原子下標的方式用在 Agent 端,因爲後者更加輕量,性能更高。對於後者這裏介紹一下其中比較有趣的地方。
有趣的原子下標
普通的 Oject 數組是無法支持併發的,但只要保證每個線程獲取下標的過程是原子的,即可保證數組的線程安全。這需要保證:
1、多線程獲取的下標是依次遞增的,從 0 開始到數組容量 - 1;
2、當某個線程獲取的下標超過數組容量,需要從 0 開始重新獲取。
這其實並不難實現,通過一個原子數和取模操作一行代碼就能完成上面的兩個功能。但我們看 Skywalking 是如何實現這個功能的:
// 提供原子下標的類
public class AtomicRangeInteger {
// JDK提供的原子數組
private AtomicIntegerArray values;
// 固定值15
private static final int VALUE_OFFSET = 15;
// 數組開始下標,固定爲0
private int startValue;
// 數組最後一個元素的下標,固定爲數組的最大長度-1
private int endValue;
public AtomicRangeInteger(int startValue, int maxValue) {
// 創建一個長度爲31的原子數組
this.values = new AtomicIntegerArray(31);
// 將第15位設置爲初始值0
this.values.set(VALUE_OFFSET, startValue);
this.startValue = startValue;
this.endValue = maxValue - 1;
}
// 核心方法,獲取數組的下一個下標
public final int getAndIncrement() {
int next;
do {
// 原子遞增
next = this.values.incrementAndGet(VALUE_OFFSET);
// 如果超過了數組範圍,CAS重製到0
if (next > endValue && this.values.compareAndSet(VALUE_OFFSET, next, startValue)) {
return endValue;
}
} while (next > endValue);
return next - 1;
}
}
Skywalking 用了一個長度固定爲 31 的 JDK 原子數組的固定第 15 位進行相關原子操作,JDK8 中的原子數組利用 Unsafe 通過偏移量直接對數組中的元素進行內存操作,那爲什麼要這麼做呢?我們先將其稱爲 V1 版本,再來看看 V2 版本,這是 Skywalking 早期版本使用的代碼:
public class AtomicRangeInteger {
private AtomicInteger value;
private int startValue;
private int endValue;
public AtomicRangeInteger(int startValue, int maxValue) {
this.value = new AtomicInteger(startValue);
this.startValue = startValue;
this.endValue = maxValue - 1;
}
public final int getAndIncrement() {
int current;
int next;
do {
// 獲取當前下標
current = this.value.get();
// 如果超過最大範圍則從0開始
next = current >= this.endValue ? this.startValue : current + 1;
// CAS更新下標,失敗則循環重試
} while (!this.value.compareAndSet(current, next));
return current;
}
}
肉眼可見這段 V2 版本的代碼邏輯不如 V1 版本,因爲在 V2 中獲取當前值和 CAS 更新這兩個步驟是分開的,並不具備原子性,因此併發衝突的可能性更高,從而導致循環次數增加;而使用 JDK 提供的 incrementAndGet 方法效率更高。再看下 V3 版本:
public class AtomicRangeInteger extends Number implements Serializable {
// 用原子整型替代V1版本的原子數組
private AtomicInteger value;
private int startValue;
private int endValue;
public AtomicRangeInteger(int startValue, int maxValue) {
this.value = new AtomicInteger(startValue);
this.startValue = startValue;
this.endValue = maxValue - 1;
}
public final int getAndIncrement() {
int next;
do {
next = this.value.incrementAndGet();
if (next > endValue && this.value.compareAndSet(next, startValue)) {
return endValue;
}
}
while (next > endValue);
return next - 1;
}
}
這個版本唯一的區別就是使用 AtomicInteger 代替原來的 AtomicIntegerArray 的第 15 位。還有最後一個最簡單的 V4 版本,通過一個原子數和取模操作完成:
public class AtomicRangeInteger {
private AtomicLong value;
private int mask;
public AtomicRangeInteger(int startValue, int maxValue) {
this.value = new AtomicLong(startValue);
this.mask = maxValue - 1;
}
public final int getAndIncrement() {
return (int)(value.incrementAndGet() % mask);
}
}
通過 Benchmark 壓測數據來看看這幾個版本的性能有什麼差別,固定 128 線程,3 輪預熱、5 輪正式,每輪 10s。
- Skywalking 官方數據(數組大小 100):
- 自己在 mac 上測試的數據(數組大小 100):
- 自己在 mac 上測試的數據(數組大小 128):
Skywalking 官方顯示通過原子數組的固定第 15 位操作的 V1 版本表現最好,而在我自己本機環境測試中 V3 版本通過原子整數代替的方式和 V1 版本有高有低,而原子數取模的性能是最高的。個人猜測 Skywalking 通過原子數組的固定第 15 位操作是爲了進行緩存填充,測試結果和環境有比較大的關係;而不使用原子數取模的原因是原子數的大小會無限遞增。
傳輸
最後一步就是數據的傳輸,如下圖所示:
Skywalking 提供了 GRPC 和 Kafka 兩種數據傳輸方式,而鷹眼則先將數據存入本地日誌中,再通過 agent 將數據採集到服務端。和 Skywalking 相比,用戶可以直接在機器上查看 trace 日誌,而 Skywalking 提供了日誌插件以提供可插拔的本地 trace 存儲功能。
從整體上來看,Skywalking 採取了埋點和中間件代碼分離的方式,在某種意義上實現了應用級透明,但是在後期維護的過程中中間件版本的升級需要配合插件版本的升級,在維護方面帶來了一些問題。而 Eagleeye 編碼方式的埋點由中間件團隊維護,對於上層的應用也是透明的,更加適合阿里集團內部的環境。
參考鏈接:
[1]https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/papers/dapper-2010-1.pdf
[2]https://github.com/opentracing-contrib/opentracing-specification-zh/blob/master/specification.md
[3]https://bytebuddy.net/#/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/3ONVrA2_UmM9qbOPdGOrxA