- 概述
- Agent 功能介紹 + 整體結(jié)構(gòu) + 設(shè)計(jì)
- 插件機(jī)制詳解
- Trace Segment Span 詳解
- 異步 Trace 詳解
- 如何正確地編寫插件并防止內(nèi)存泄漏
- 擴(kuò)展:如何基于 Skywalking 打造全鏈路壓測(cè)
- 總結(jié)與參考
概述
在 APM 和全鏈路監(jiān)控領(lǐng)域,Skywalking 是非常有名的項(xiàng)目,我司使用的就是該方案來(lái)進(jìn)行應(yīng)用性能監(jiān)控和分布式鏈路跟蹤。而我本人最近的工作和 Skywalking 也高度相關(guān),因此,lz想以本文來(lái)作為這段時(shí)間,對(duì)關(guān)于 Skywalking 的知識(shí)點(diǎn)進(jìn)行總結(jié)和分享,包括插件機(jī)制的原理,核心領(lǐng)域模型的分析,異步 trace 可能存在的問(wèn)題,編寫復(fù)雜插件時(shí)如何避免采坑,如何基于 Skywalking 打造全鏈路壓測(cè)等等。如果不當(dāng),還請(qǐng)指出,不吝賜教。另外,本文只關(guān)注 Skywalking Java Agent,關(guān)于 Skywalking 其他的組件,不在本文探討之列。
Agent 功能介紹 + 整體結(jié)構(gòu) + 設(shè)計(jì)
Skywalking Java Agen 使用 Java premain 作為 Agent 的技術(shù)方案,關(guān)于 Java Agent,其實(shí)有 2 種,一種是以 premain 作為掛載方式(啟動(dòng)時(shí)掛載),另外一種是以 agentmain 作為掛載方式,在程序運(yùn)行期間隨時(shí)掛載,例如著名的 arthas 就是使用的該方案;agentmain 會(huì)更加靈活,但局限會(huì)比 premain 多,例如不能增減父類,不能增加接口,新增的方法只能是 private static/final 的,不能修改字段,類訪問(wèn)符不能變化。而 premian 則沒(méi)有這些限制。
另外,agentmain 的掛載方式,對(duì)性能是有影響的,他的工作原理是啟動(dòng)一個(gè)新的進(jìn)程,觸發(fā)ClassFileLoadHook 事件,然后修改正在運(yùn)行的字節(jié)碼,那如果這個(gè)類正在運(yùn)行怎么辦呢?JVM 會(huì)在安全點(diǎn)暫停所有線程,然后觸發(fā)我們編寫的 Agent 鉤子,并重新轉(zhuǎn)換字節(jié)碼。而在暫停所有現(xiàn)場(chǎng)的過(guò)程中,程序就會(huì)產(chǎn)生可能不可控的延遲。
另外說(shuō)一個(gè)題外話,關(guān)于 Redefine 和 Retransform 的區(qū)別,前者會(huì)覆蓋掉被修改的內(nèi)容,后者會(huì)保留被修改的內(nèi)容。Redefine 是 Java 1.5 引入的,Retransform 是 Java 1.6 引入的。Redefine 有很多缺陷,例如 Redefine 后的類不能恢復(fù),不能修改刪除 field 和 method,包括方法參數(shù),名稱和返回值。Jdk 1.6 的 Retransform 則解決了這些問(wèn)題。關(guān)于 Retransform 和 Redefine,可以參考 arthas 作者的一些文章介紹。
回到 Skywalking 上面,Skywalking 是在 premian 方法中類加載時(shí)修改字節(jié)碼的。使用 ByteBuddy 類庫(kù)(基于 ASM)實(shí)現(xiàn)字節(jié)碼插樁修改。入口類 SkyWalkingAgent#premain 。
Skywalking Agent 整體結(jié)構(gòu)基于微內(nèi)核的方式,即插件化,apm-agent-core 是核心代碼,負(fù)責(zé)啟動(dòng),加載配置,加載插件,修改字節(jié)碼,記錄調(diào)用數(shù)據(jù),發(fā)送到后端等等。而 apm-sdk-plugin 模塊則是各個(gè)中間件的插裝插件,比如 Jedis,Dubbo,RocketMQ,Kafka 等各種客戶端。
如果想要實(shí)現(xiàn)一個(gè)中間件的監(jiān)控,只需要遵守 Skywalking 的插件規(guī)范,編寫一個(gè) Maven 模塊就可以。Skywalking 內(nèi)核會(huì)自動(dòng)化的加載插件,并插樁字節(jié)碼。
Skywalking 的作者曾說(shuō):不管是 Linux,Istio 還是 SkyWalking ,都有一個(gè)很大的特點(diǎn):當(dāng)項(xiàng)目被「高度模塊化」之后,貢獻(xiàn)者就會(huì)開(kāi)始急劇的提高。
而模塊化,插件化,也是一個(gè)軟件不容易腐爛的重要特性。Skywalking 的就是遵循這個(gè)理念設(shè)計(jì)。
插件機(jī)制詳解
Skywalking 如何加載插件的呢? Skywalking 的插件在 maven 打包完成后,會(huì)自動(dòng)放在 plugins 目錄下,Skywalking 在啟動(dòng)時(shí),會(huì)使用自定義的 AgentClassLoader 進(jìn)行插件加載,該 ClassLoader 重寫了findclass 方法(并沒(méi)有破壞雙親委派模型)。啟動(dòng)時(shí),Skywalking 就會(huì)查找所有的 skywalking-plugin.def 文件,并使用默認(rèn)的 AgentClassLoader 加載這些文件里定義的插件元數(shù)據(jù)類,來(lái)映射目標(biāo) class 和攔截 class 的關(guān)系(代碼位置 PluginBootstrap#loadPlugins )。此時(shí)真正的攔截插件并不會(huì)加載,這些映射規(guī)則,則是插件開(kāi)發(fā)者自己定義的。
在 Skywalking 中,每個(gè)業(yè)務(wù) classLoader 實(shí)例,都會(huì)對(duì)應(yīng)一個(gè)新的 AgentClassLoader。哪些是業(yè)務(wù)ClassLoader呢?比如 sun.misc.Launcher$AppClassLoader,org.springframework.boot.loader.LaunchedURLClassLoader,sun.misc.Launcher$ExtClassLoader, sun.reflect.DelegatingClassLoader,業(yè)務(wù)自己創(chuàng)建的 ClassLoader 等等。
而 AgentClassLoader 的路徑則是 plugins 和 activations 目錄,AgentClassLoader 可以在這 2個(gè)路徑下查找 Class。
當(dāng)加載一個(gè)類時(shí),比如 Jedis,那么就會(huì)觸發(fā) javaAgent 的 Instrumentation 鉤子,Instrumentation 內(nèi)部則實(shí)現(xiàn)了一整套邏輯。Skywalking 會(huì)檢查是否有 Jedis 的插件(這個(gè)規(guī)則是Jedis 插件里的 skywalking-plugin.def 定義,此文件在啟動(dòng)時(shí)就加載了),如果有,就使用一個(gè)新的 AgentClassLoader (parent 是目標(biāo)類加載器)來(lái)加載攔截器,并將攔截器插入到調(diào)用方法的前面和后面(代碼位置 ClassEnhancePluginDefine#enhance)。
為什么要用一個(gè)新的 AgentClassLoader 呢?假設(shè)不用 AgentClassLoader,用默認(rèn)的 AgentClassLoader,這個(gè) AgentClassLoader 的 parent 是 JDK AppClassLoader,而如果 Jedis 是 一個(gè)自定義類加載器加載的,且插件里又訪問(wèn) Jedis 這個(gè)類,因?yàn)?AgentClassLoader 是無(wú)法訪問(wèn)到 Jedis 這個(gè)類文件的,因此只能向上查找,向上查找到 AppClassLoader,肯定是查不到的,因?yàn)?Jedis 是自定義類加載器加載的。
如下圖:

而如果我們使用一個(gè)新的 AgentClassLoader,并將其 parent 設(shè)置為 Jedis 的 ClassLoader,則可以解決這個(gè)問(wèn)題,如下圖:

插件分為 3 種:構(gòu)造器插件,靜態(tài)方法插件,實(shí)例方法插件。分別是 InstanceConstructorInterceptor 接口,StaticMethodsAroundInterceptor 接口, InstanceMethodsAroundInterceptor 接口。
我們隨便點(diǎn)開(kāi)一個(gè)插件,例如 HttpClient 插件:

該插件在攔截器代碼里訪問(wèn) apache http 的類。我們可以在其執(zhí)行execute方法時(shí),攔截到請(qǐng)求參數(shù),并進(jìn)行解析。根據(jù) Skywalking 的規(guī)范,設(shè)置各種標(biāo)簽和 Span。關(guān)于 Span ,下面會(huì)單獨(dú)詳解。
Trace Segment Span 詳解
Skywalking 是全鏈路追蹤和 APM 插件,我們這里先討論全鏈路跟蹤,暫時(shí)不討論 APM。自從 google 2010 發(fā)布 Dapper 論文以來(lái),各種全鏈路跟蹤插件如雨后春筍般的出現(xiàn)。Skywalking是其中優(yōu)秀的代表。
全鏈路跟蹤一般有幾個(gè)概念 Trace,Span。Trace 代表了一次調(diào)用所產(chǎn)生的鏈路,并且會(huì)有一個(gè)全局唯一的 ID,在 google的論文中,他是一組 span 的集合,Span 表示一個(gè)組件的調(diào)用信息,是整個(gè) Trace 中的一個(gè)節(jié)點(diǎn),他的 ID 在 trace 中是唯一的。
一般 Span 的結(jié)構(gòu)是這樣的 (偽代碼):
class Span {
int id; // 自身 Span 的 ID
int parentId; // 父 Span 的 ID
String name; // Span 的名稱
String traceId; // 全局 traceID
Date startTime; // span 的啟動(dòng)時(shí)間
Date endTime; // span 的執(zhí)行結(jié)束時(shí)間
}
Segment 是 Skywalking 代碼里的獨(dú)有概念,他表示的是一個(gè) JVM 里一個(gè)線程里的一次調(diào)用鏈路,通常會(huì)有多個(gè) Span。SKywalking Agent 代碼中是沒(méi)有 Trace 實(shí)體的,Trace 其實(shí)就是多個(gè) Segment 連接成的一個(gè)東西。
一個(gè) Segment 由多個(gè) Span 組成,當(dāng)一個(gè)線程一次調(diào)用運(yùn)行結(jié)束了,那么這個(gè) Segment 就結(jié)束了(非異步場(chǎng)景),SKywalking 就會(huì)把這個(gè)調(diào)用信息返回到后端統(tǒng)計(jì)服務(wù) OAP 中,此時(shí),就可以通過(guò) web 頁(yè)面進(jìn)行搜索查看了。
我們來(lái)看下代碼是怎么寫的,首先看 Segment,該類全稱是 TraceSegment
public class TraceSegment {
private String traceSegmentId;
private List<TraceSegmentRef> refs;
private List<AbstractTracingSpan> spans;
private DistributedTraceIds relatedGlobalTraces;
private final long createTime;
public TraceSegment() {
this.traceSegmentId = GlobalIdGenerator.generate();
this.spans = new LinkedList<>();
this.relatedGlobalTraces = new DistributedTraceIds();
this.relatedGlobalTraces.append(new NewDistributedTraceId());
this.createTime = System.currentTimeMillis();
}
}
traceSegmentId: 表示自身作為 Segment 的全局唯一 ID;
refs:每次有新的流量進(jìn)入 JVM,都會(huì)創(chuàng)建一個(gè)新的 Segment,如果他的前面還是有一個(gè) JVM 的話,那么就將前面這個(gè) JVM 的 Segment 保存到 refs 鏈表中(新版本已經(jīng)不是鏈表了,只是一個(gè)單對(duì)象,鏈表可能會(huì)導(dǎo)致內(nèi)存泄漏),這樣就將 Segment 串聯(lián)起來(lái)了。
spans:在 JVM 中運(yùn)行 Span 節(jié)點(diǎn),都會(huì)保存到 spans 中。
relatedGlobalTraces:第一個(gè)節(jié)點(diǎn)生成的唯一 ID,也就是 TraceID;注意,雖然構(gòu)造方法這里賦值了,但是后面會(huì)調(diào)用其 Set 方法,將其覆蓋。
Span 結(jié)構(gòu)是怎么樣的呢?Span 種類比較多,分為入口 Span(例如 Tomcat 入口,SpringMVC 入口),出口 Span(DB 客戶端,Jedis 客戶端,Http 客戶端),本地方法 Span(本地函數(shù));
SKywalking 抽象的 Span 代碼如下:
public abstract class AbstractTracingSpan implements AbstractSpan {
protected int spanId; // 自身 ID,從0開(kāi)始
protected int parentSpanId; // 父 span ID
protected List<TagValuePair> tags; // 執(zhí)行過(guò)程中,記錄的數(shù)據(jù)
protected String operationName; // 名字
protected volatile boolean isInAsyncMode = false; // 是否為異步模式
private volatile boolean isAsyncStopped = false; // 異步是否停止
protected final TracingContext owner; // 持有該 Span 的上下文
protected long startTime; // 開(kāi)始時(shí)間
protected long endTime; // 結(jié)束時(shí)間
protected boolean errorOccurred = false; // 是否發(fā)生了錯(cuò)誤
protected int componentId = 0; // Span 組件 ID
protected List<LogDataEntity> logs; // 日志
protected List<TraceSegmentRef> refs; // 父 Segment
}
可以看到,SKywalking 的 Span 設(shè)計(jì)和大部分設(shè)計(jì)是差不多的。我們注意到有個(gè) TracingContext,這是一個(gè)關(guān)鍵對(duì)象,用來(lái)維護(hù)一次調(diào)用過(guò)程中,所有 Span 的生命周期。
TracingContext 屬性:
public class TracingContext implements AbstractTracerContext {
private TraceSegment segment; // 當(dāng)前調(diào)用的 Segment
// 當(dāng)前調(diào)用的所有 Span,使用鏈表維護(hù),模擬棧的進(jìn)出
private LinkedList<AbstractSpan> activeSpanStack = new LinkedList<>();
private int spanIdGenerator; // id 生成器
private volatile int asyncSpanCounter; 異步計(jì)數(shù)器
private volatile boolean isRunningInAsyncMode; 是否為異步模式
private volatile ReentrantLock asyncFinishLock; 異步執(zhí)行鎖
private volatile boolean running; 是否結(jié)束
private final long createTime; 創(chuàng)建時(shí)間
}
此類的關(guān)鍵就是 activeSpanStack,其使用鏈表模擬了棧的進(jìn)出,為什么使用棧的結(jié)構(gòu)呢?使用棧結(jié)構(gòu)能夠更方便的管理 Span 的生命周期。在 SKywalking 中,一個(gè) Span 創(chuàng)建成功,就是入棧操作,該 Span 執(zhí)行結(jié)束,則是出棧操作。當(dāng)這個(gè)??樟耍硎具@個(gè) Segment 執(zhí)行結(jié)束了。
具體如下圖所示:

上圖中,顯示了 SKywalking 中如何管理 Span 的生命周期:當(dāng)?shù)谝粋€(gè) Span 創(chuàng)建時(shí),例如 Tomcat Span,則會(huì)放到棧底,當(dāng) Jedis Span 對(duì)外訪問(wèn)時(shí)(例如執(zhí)行 get 命令),則放在棧頂。當(dāng) Jedis 操作執(zhí)行結(jié)束時(shí),則會(huì)出棧,當(dāng) ThreadSpan 執(zhí)行 Run 方法結(jié)束時(shí),也會(huì)出棧,當(dāng)訪問(wèn) Tomcat 的請(qǐng)求執(zhí)行結(jié)束時(shí),則也會(huì)出棧,直至棧為空。當(dāng)棧為空,則會(huì)將這些 Span 發(fā)送到后端 OAP server 進(jìn)行保存。
然后我們總結(jié)下 trace Segment span 的關(guān)系:

大體上,就是這樣的一個(gè)關(guān)系。
異步 Trace 詳解
前面我們了解了 Span 和 Segment 的原理,其實(shí)還有一點(diǎn),SKywalking Agent 用來(lái)存儲(chǔ) Span 的容器是 ThreadLocal,便于在單個(gè)線程中,隨時(shí)取出 Span 對(duì)象。當(dāng)棧為空時(shí),則會(huì)刪除 ThreadLocal 對(duì)象,防止內(nèi)存泄漏。
那如果是異步 Trace,該怎么辦呢?SKywalking 提供了 capture 和 continued(snapshot),前者表示將當(dāng)前棧頂?shù)?Span 復(fù)制并返回一個(gè)快照,continued 表示將快照恢復(fù)為當(dāng)前棧頂 Span 的父 Span,以此來(lái)完成 Span 和 Span 之間的鏈接。
例如,當(dāng)我們使用異步線程執(zhí)行任務(wù)時(shí),SKywalking 在默認(rèn)情況下,是無(wú)法鏈接當(dāng)前線程的 Span 和異步線程的 Span 的,除非我們?cè)?Runnable 實(shí)現(xiàn)類使用 TraceCrossThread 類似的注解,表示這個(gè) Runnable 需要跨線程追蹤,那么,SKywalking 就會(huì)做出 capture 和 continued(snapshot) 操作,將主線程的 Span+Segment 復(fù)制到 Runnable 中,并將這 2 個(gè) Span 進(jìn)行鏈接。如下圖

上圖中,主線程復(fù)制當(dāng)前線程 Segment 和 Span 的基本信息,包括 Segment ID,Span ID,Name 等信息。然后在子線程中,進(jìn)行回放,回放的操作,就是將這個(gè) 快照 的信息,保存到 Span 的父 Span 中,標(biāo)記子線程的父 Span 就是這個(gè) Span。
還有一種場(chǎng)景的異步 Span,比如在 A 線程開(kāi)啟,在 B 線程關(guān)閉,我們需要記錄這個(gè) Span 的耗時(shí)。比方說(shuō),異步 HttpClient,我們?cè)谥骶€程開(kāi)啟了訪問(wèn),在異步線程得到結(jié)果,就復(fù)合剛剛我們說(shuō)的場(chǎng)景。
SKywalking 為我們提供了 prepareForAsync 和 asyncFinish 這兩個(gè)方法,當(dāng)我們?cè)?A 線程創(chuàng)建了一個(gè) Span,我們可以執(zhí)行 span.prepareForAsync 方法,表示這個(gè) span 開(kāi)始了訪問(wèn),即將進(jìn)入異步線程。當(dāng)在 B 線程得到結(jié)果后,執(zhí)行 span.asyncFinish 則表示,這個(gè) span 執(zhí)行結(jié)束了,那么, A 線程就可以將整個(gè) Segment標(biāo)記結(jié)束,并返回到 OAP server 中進(jìn)行統(tǒng)計(jì)。那么如何在 B 線程里得到這個(gè) Span 的實(shí)例,然后調(diào)用 asyncFinish 方法呢?實(shí)際上,是需要插件開(kāi)發(fā)者自己想辦法傳遞的,比如在被攔截對(duì)象的參數(shù)里、構(gòu)造函數(shù)里傳遞。
那么這 2 種異步模式的區(qū)別是什么呢?說(shuō)實(shí)話,我在剛剛看到這兩個(gè)的時(shí)候,腦子也有點(diǎn)迷糊,經(jīng)過(guò)總結(jié),發(fā)現(xiàn)兩者雖然看起來(lái)相似,當(dāng)誰(shuí)也代替不了誰(shuí)。
簡(jiǎn)單來(lái)說(shuō),prepareForAsync 和 asyncFinish 只是為了統(tǒng)計(jì)一個(gè) Span 跨越 2 個(gè)線程的場(chǎng)景,例如上面的提到 HttpAsyncClient 場(chǎng)景。在 A 線程創(chuàng)建,在 B 線程結(jié)束,我們需要在 B 線程拿到返回值和耗時(shí)。
而 capture 和 continued(snapshot) 的使用場(chǎng)景是為了連接 2 個(gè)線程的不同 Span。我們將主線程的最后一個(gè) Span 和子線程的第一個(gè) Span 相連接。
而兩者也是可以結(jié)合使用。如下圖:

以上,表示了一次 HttpAsyncClient 請(qǐng)求中,如何將 Span 進(jìn)行跨線程連接,并記錄返回值。

最終的效果如上。
如何正確地編寫插件防止內(nèi)存泄漏
在使用 SKywalking 的過(guò)程中,我也寫過(guò)一些公司內(nèi)部的插件,如果是同步調(diào)用的話,就比較簡(jiǎn)單,例如,在 before 方法中創(chuàng)建一個(gè) span(就是向棧中推入一個(gè) Span),在 after 方法中,執(zhí)行 stop span(就是從棧中彈出一個(gè) Span)。
當(dāng)編寫異步插件時(shí),需要考慮的情況就比較復(fù)雜。有幾個(gè)點(diǎn)需要注意:
當(dāng)我們執(zhí)行
capture和continued時(shí),棧頂一定要有 Span。這樣才能將這兩個(gè) Span 進(jìn)行鏈接。當(dāng)我們執(zhí)行
prepareForAsync異步時(shí),一定要在其他線程執(zhí)行asyncFinish,否則這個(gè) Segment 就會(huì)斷開(kāi),因?yàn)槿绻粓?zhí)行asyncFinish,這個(gè) Segment 就不會(huì) finish,也就不會(huì)發(fā)送到后端 OAP。另外,對(duì)一個(gè) Span 執(zhí)行完prepareForAsync后,一定不要忘記執(zhí)行這個(gè) span 的 stop 方法。一定要正確的調(diào)用
ContextManager.stopSpan(),否則,一定會(huì)出現(xiàn)內(nèi)存泄漏。假設(shè),Tomcat Span 是入口,在 Tomcat 插件的 after 方法里,執(zhí)行了stopSpan,但是棧卻沒(méi)有清空,那么ThreadLocal里的對(duì)象就不會(huì)清除,當(dāng)下次在這個(gè)線程里調(diào)用continued時(shí),continued會(huì)將其他線程的對(duì)象繼續(xù)添加到這個(gè)線程里的 Segment 列表里。導(dǎo)致內(nèi)存無(wú)限增大(新版本限制了鏈表的大小,但沒(méi)有從根本解決問(wèn)題)。
擴(kuò)展:如何基于 Skywalking 打造全鏈路壓測(cè)
SKywalking 是基于 java agent 技術(shù)打造的,而 java agent 又非常的適合開(kāi)發(fā)全鏈路壓測(cè)產(chǎn)品,那么,是否可以借助 SKywalking 的現(xiàn)有能力開(kāi)發(fā)出全鏈路壓測(cè)呢?答案是可以的。
全鏈路壓測(cè)的核心問(wèn)題是壓測(cè)的過(guò)程中不能有臟數(shù)據(jù),當(dāng)影子流量進(jìn)入容器,這些流量不能進(jìn)入正式的數(shù)據(jù)庫(kù)。通常的做法是,例如在執(zhí)行 SQL 的時(shí)候,判斷是否是影子流量,如果是,則更換 SQL 數(shù)據(jù)源,即不能在正式庫(kù)中執(zhí)行影子 SQL。
基于 SKywalking 的目前的實(shí)現(xiàn),我們只需要對(duì)一個(gè)類實(shí)現(xiàn)多個(gè)插件即可,并將這些插件進(jìn)行包裝,基于過(guò)濾器模式進(jìn)行串聯(lián),實(shí)現(xiàn)對(duì)一個(gè)類的 壓測(cè)增強(qiáng) 和 全鏈路Trace增強(qiáng)。
總結(jié)與參考
以上,就是本人這段時(shí)間,對(duì) SKywalking(8.1.0) 學(xué)習(xí)和使用的總結(jié)。SKywalking 版本升級(jí)的很快,現(xiàn)在已經(jīng)是 8.9.0 版本了,又有了很多功能的更新,大家可以參考的看看。
參考:
opentracing 規(guī)范 https://github.com/opentracing/specification/blob/master/specification.md#the-opentracing-data-model
Instrumentation API https://www.matools.com/api/java8
Arthas源碼分析--jad反編譯原理 https://hengyun.tech/arthas-jad/
Skywalking原理分析 http://www.bewindoweb.com/306.html
JVM 源碼分析之 javaagent 原理完全解讀 https://www.infoq.cn/article/javaagent-illustrated/
SkyWalking源碼分析https://www.processon.com/view/link/611fc4c85653bb6788db4039#map
SKywalking Java Agent 源碼地址 https://github.com/apache/skywalking-java