本篇文章基於上一篇,只針對數據采集做介紹,會提供一個SDK的實現和使用,會做實現方案的介紹,具體詳細介紹下面邊框加粗的部分:
一、數據采集
接着拿上一篇里的例子來說,把例子里的圖貼過來:

圖1
簡單回顧下上圖,一次API調用,完成上面各個業務服務的調用,然后聚合所有服務的信息,然后Redis_02的調用發生瓶頸,繼而影響到E、D、C三個服務,現在需要直觀的展示這條鏈路上的瓶頸點,於是需要一個鏈路系統,展示成如下圖的效果:

圖2
要想展示成上圖中的效果,則必須要進行數據的采集和上報,那么這就牽扯到兩個概念,Span和Tracer,抽象成數據庫的設計層面,可以理解成Tracer對Span等於一對多的關系,而一個Span可能包含多個子Span,一個Tracer表示一次調用所經過的整個系統鏈路,里面包含N多Span,每個Span表示一次事件的觸發(也就是調用),那么就用圖2來解釋下這種關系:

圖3
所以上報數據最關鍵的地方就是要做到如下幾點:
①在調用之處(比如例子中API調用開始的地方),創建Tracer,生成唯一Trace ID;
②在需要追蹤的地方(比如例子中發生服務調用的地方),創建Span,指定Trace ID,並生成唯一Span ID,然后按需建立父子關系,追蹤結束時(比如例子中調用完成時)釋放Span(即置為finished,此時計時已完成);
③跨系統追蹤時做好協議約定,每次跨系統調用時可以在協議頭傳輸發起調用系統的TraceID,以便鏈路可以做到跨系統順利傳輸。
④最終主鏈路執行完畢(例子中就是指API調用結束)時,推送此鏈路產生的所有Span到鏈路系統,鏈路系統負責落庫、數據分析和展示。
以上便是鏈路追蹤業務SDK需要參與做到的事情。
Tracer是個虛擬概念,負責聚合Span使用,實際上報的數據全是Span,下面來看下Span的結構定義(JSON):
{
"spanId": 123456,
"traceId": 1234,
"parentId": 123455,
"title": "getSomeThing",
"project": "project.tree.group.project_name",
"startTime": 1555731560000,
"endTime": 1555731570000,
"tags": {
"component": "rpc",
"span.kind": "client"
}
}
這是一個span的基本結構定義,startTime和endTime可以推算出本次Span耗時(交給鏈路系統前端時可以用來展示時間軸的長短),title表示的是Span本身的描述,一般是一個method的名字,project是當前所處項目的全稱,項目的全稱可以交給鏈路系統前端用來搜索出該項目的所有鏈路信息。spanId、traceId、parentId結合上面的圖理解即可,tags表示的是一些描述信息,這里有一些標准化的東西:標准的Span tag 和 log field
二、數據采集基於Java語言的實現
一般基於io.opentracing標准實現上報SDK,下面來逐步實現一個最簡單的數據收集器,首先在項目中引入io.opentracing的jar包,然后追加兩個基本類SimpleTracer和SimpleSpan,這里只貼出關鍵代碼。
SimpleTracer定義:
// 追蹤器,實現Tracer接口
public class SimpleTracer implements Tracer {
private final List finishedSpans = new ArrayList<>(); //存放鏈路中已執行完成的span(finished span)
private String project; //項目名稱
private Boolean sampled; //是否上報(由采樣率算法生成該值)
public SimpleTracer(boolean sampled, String project) {
this.project = project;
this.sampled = sampled;
}
public SimpleTracer(String uri, String project) {
this.project = project;
this.sampled = PushUtils.sampled(uri); //本次追蹤是否上報
}
@Override
public SpanBuilder buildSpan(String operationName) {
return new SpanBuilder(operationName); //創建span一般交給Tracer去做,這里由其內部類SpanBuilder觸發創建
}
//上報span,這個方法一般在一次鏈路完成時調用,負責將finishedSpans里的數據上報給追蹤系統
public synchronized void pushSpans() {
if (sampled != null && sampled) {
List finished = this.finishedSpans;
if (finished.size() > 0) {
finished.stream().filter(SimpleSpan::sampled).forEach(span -> PushHandler.getHandler().pushSpan(span)); //實際負責推送的方法
this.reset(); //每發生一次推送,則清理一次已完成span集合
}
}
}
// Tracer對象內部類SpanBuilder,實現了標准里的Tracer.SpanBuilder接口,用來負責創建span
public final class SpanBuilder implements Tracer.SpanBuilder {
private final String title; //操作名,也就是span的title
private long startMicros; //初始化開始時間
private List references = new ArrayList<>(); //父子關系
private Map<String, Object> initialTags = new HashMap<>(); //tag描述信息初始化
//創建span用的title傳入
SpanBuilder(String title) {
this.title = title;
}
@Override
public SpanBuilder asChildOf(SpanContext parent) { //傳入父子關系
return addReference(References.CHILD_OF, parent);
}
@Override
public SpanBuilder addReference(String referenceType, SpanContext referencedContext) {
if (referencedContext != null) {
//添加父子關系,其實這里就是初始化了Span里的Reference對象,這個對象會在創建Span對象時作為參數傳進去,然后具體關系的確立,是在Span對象內(具體Span類的代碼段會展示)
this.references.add(new SimpleSpan.Reference((SimpleSpan.SimpleSpanContext) referencedContext, referenceType));
}
return this;
}
@Override
public SimpleSpan start() {
return startManual();
}
@Override
public SimpleSpan startManual() { //創建並開始一個span
if (this.startMicros == 0) {
this.startMicros = SimpleSpan.nowMicros(); //就是在這里初始化startTime的
}
//這里觸發SimpleSpan的構造方法,之前的references會被傳入,此外初始化的tag信息、title、開始時間等也會被傳入參與初始化
return new SimpleSpan(SimpleTracer.this, title, startMicros, initialTags, references);
}
}
}
上面放了SimpleTracer的代碼片段,關鍵信息已標注,這個類的作用就是幫助創建span,上面還有一個比較重要的方法,也就是sampled方法,該方法用來生成這次鏈路是否上報(也就是采樣率,實際的追蹤系統不可能每次的請求都上報,對於一些QPS較高的系統,會帶來額外大量的存儲數據,因此需要一個上報率),下面來簡單看下上報率的實現:
public class PushUtils {
public static final Random random = new Random();
private static final Map<String, Long> requestMap = Maps.newConcurrentMap();
public static boolean sampled(String uri) {
if (Strings.isNullOrEmpty(uri)) {
return false;
}
Long start = requestMap.get(uri);
Long end = System.currentTimeMillis();
if (start == null) {
requestMap.put(uri, end);
return true;
}
if ((end - start) >= 60000) { //距離上次上報已經超過1min了
requestMap.put(uri, end);
return true;
} else { // 沒超過1min,則按照1/1000的概率上報
if (random.nextInt(999) == 0) {
requestMap.put(uri, end);
return true;
}
}
return false;
}
}
這種是比較適中的做法,如果1min內沒有上報一次,則必定上報,如果1min內連續上報多次,則按照千分之一的概率上報,這樣既保證了低QPS的系統可以有相對較多的鏈路數據,也可以保證高QPS的系統可以有相對較少的鏈路數據。
下面來看下SimpleSpan的關鍵代碼段:
// 鏈路Span,實現標准里的Span接口
public class SimpleSpan implements Span {
private final SimpleTracer simpleTracer; //鏈路追蹤對象(一次追蹤建議生成一個鏈路對象,盡量不要用單例,會有同步鎖影響並發效率)
private final long parentId; // 父span該值為0
private final long startTime; // 計時開始開始時間戳
private final Map<String, Object> tags; //一些擴展信息
private final List references; // 關系,外部傳入
private final List errors = new ArrayList<>();
private SimpleSpanContext context; // spanContext,內部包含traceId、span自身id
private boolean finished; // 當前span是否結束標識
private long endTime; // 計時結束時間戳
private boolean sampled; // 是否為抽樣數據,取決於父節點,依次嫡傳下來給其子節點
private String project; // 追蹤目標的項目名
private String title; //方法名
SimpleSpan(SimpleTracer tracer, String title, long startTime, Map<String, Object> initialTags, List refs) {
this.simpleTracer = tracer; // 這里傳入的tracer是針對本次跟蹤過程唯一對象,負責收集已完成的span
this.title = title;
this.startTime = startTime;
this.project = tracer.getProject();
this.sampled = tracer.isSampled(); //是否上報,該字段根據具體的采樣率方法生成
if (initialTags == null) {
this.tags = new HashMap<>();
} else {
this.tags = new HashMap<>(initialTags);
}
if (refs == null) { //span對象由tracer對象創建,創建時會把父子關系傳入
this.references = Collections.emptyList();
} else {
this.references = new ArrayList<>(refs);
}
SimpleSpanContext parent = findPreferredParentRef(this.references); //查看是否存在父span
if (parent == null) { //通常父span為空的情況,都是鏈路開始的地方,這里會生成traceId
// 當前鏈路還不存在父span,則本次span就置為父span,下面會生成traceId和當前父span的spanId
this.context = new SimpleSpanContext(nextId(), nextId(), new HashMap<>());
this.parentId = 0; //父span的parentId是0
} else {
// 當前鏈路已經存在父span了,那么子span的parentId置為當前父span的id,表示當前span是屬於這個父span的子span,同時traceId也延用父span的(表示屬於同一鏈路)
this.context = new SimpleSpanContext(parent.traceId, nextId(), mergeBaggages(this.references));
this.parentId = parent.spanId;
}
}
@Nullable
private static SimpleSpanContext findPreferredParentRef(List references) {
if (references.isEmpty()) {
return null;
}
for (Reference reference : references) {
if (References.CHILD_OF.equals(reference.getReferenceType())) { //現有的reference中存在父子關系(簡單理解,這個關系就是BuildSpan的時候傳入的)
return reference.getContext(); //返回父span的context信息(包含traceId和它的spanId)
}
}
return references.get(0).getContext();
}
@Override
public synchronized void finish(long endTime) {
finishedCheck("當前span處於完成態");
this.endTime = endTime;
this.simpleTracer.appendFinishedSpan(this); //span完成時放進鏈路對象的finishedSpans集合里
this.finished = true;
}
// SimpleSpan的內部類SimpleSpanContext,存放當前Span的id、鏈路id,實現了標准里的SpanContext接口
public static final class SimpleSpanContext implements SpanContext {
private final long traceId; //鏈路id
private final Map<String, String> baggage;
private final long spanId; //spanId
public SimpleSpanContext(long traceId, long spanId, Map<String, String> baggage) {
this.baggage = baggage;
this.traceId = traceId;
this.spanId = spanId;
}
}
public static final class Reference { //用於建立Span間關系的內部類
private final SimpleSpanContext context; //存放了某一個Span的context(用於跟當前span建立關系時使用)
private final String referenceType; //關系類型,目前有兩種:child_of和follows_from,第一種代表當前span是上面context里span的子span,第二個則表示同級順序關系
public Reference(SimpleSpanContext context, String referenceType) {
this.context = context;
this.referenceType = referenceType;
}
}
}
上面就是SimpleSpan的關鍵實現,關鍵點已標注,下面來看下數據上報這里的實現:
public class PushHandler {
private static final PushHandler handler = new PushHandler();
private BlockingQueue queue;
private PushHandler() {
this.queue = new LinkedBlockingQueue<>(); //數據管道
new Thread(this::pushTask).start();
}
public static PushHandler getHandler() {
return handler;
}
public void pushSpan(SimpleSpan span) {
queue.offer(span);
}
private void pushTask() {
if (queue != null) {
SimpleSpan span;
while (true) {
try {
span = queue.take();
//為了測試,這里只打印了基本信息,實際環境中這里需要做數據推送(kafka、UnixSocket等)
StringBuilder sb = new StringBuilder()
.append("tracerId=")
.append(span.context().traceId())
.append(", parentId=")
.append(span.parentId())
.append(", spanId=")
.append(span.context().spanId())
.append(", title=")
.append(span.title())
.append(", 耗時=")
.append((span.endTime() / 1000000) - (span.startTime() / 1000000))
.append("ms, tags=")
.append(span.tags().toString());
System.out.println(sb.toString());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
只是做了簡單的測試,所以處理邏輯只是簡單的做了打印,實際當中這里要上報鏈路數據(spans)。這里使用了一個阻塞隊列做數據接收的緩沖區。
這套實現是非常簡單的,只進行簡單的計時、推送,並沒有涉及active方式的用法,一切創建、建立父子關系均交由開發人員自己把控,清晰度也更高些。
代碼完整地址:simple-trace
三、simple-trace的使用
看了上面的實現,這里利用simple-trace來進行程序追蹤,看一個簡單的例子:
public class SimpleTest {
private SimpleTracer tracer = null;
private SimpleSpan parent = null;
//假設這里是鏈路開始的地方
@Test
public void test1() {
//創建鏈路
tracer = new SimpleTracer("test1", "projectName");
parent = tracer.buildSpan("test1")
.withTag(SpanTags.COMPONENT, "http")
.withTag(SpanTags.SPAN_KIND, "server")
.start(); //span開始
//--------------------------------------------------
String result1 = getResult1(); //假設getResult1需要鏈路追蹤
System.out.println("r1 = " + result1);
String result2 = getResult2(); //假設getResult2需要鏈路追蹤
System.out.println("r2 = " + result2);
//--------------------------------------------------
//下面標記着一次鏈路追蹤的結束
parent.finish(); //主span結束
tracer.pushSpans(); //觸發span數據推送
}
public String getResult1() {
//前戲,建立getResult1自己的追蹤span
SimpleSpan currentSpan = null;
if (tracer != null && parent != null) {
//當前鏈路視為test1方法的子鏈路,建立父子關系
SimpleSpan.SimpleSpanContext context = new SimpleSpan.SimpleSpanContext(parent.context().traceId(),
parent.context().spanId(), new HashMap<>()); //建立父子關系,traceId和父spanId被指定
currentSpan = tracer.buildSpan("getResult1")
.addReference(References.CHILD_OF, context)
.withTag(SpanTags.COMPONENT, "redis")
.withTag(SpanTags.SPAN_KIND, "client").start(); //啟動自己的追蹤span
}
try {
Thread.sleep(1000L);
return "result1";
} catch (InterruptedException e) {
e.printStackTrace();
return "";
} finally {
if (currentSpan != null) {
currentSpan.finish(); //最后完成本次鏈路追蹤
}
}
}
public String getResult2() {
//前戲,建立getResult2自己的追蹤span
SimpleSpan currentSpan = null;
if (tracer != null && parent != null) {
//當前鏈路視為test2方法的子鏈路,建立父子關系
SimpleSpan.SimpleSpanContext context = new SimpleSpan.SimpleSpanContext(parent.context().traceId(),
parent.context().spanId(), new HashMap<>()); //建立父子關系,traceId和父spanId被指定
currentSpan = tracer.buildSpan("getResult2")
.addReference(References.CHILD_OF, context)
.withTag(SpanTags.COMPONENT, "redis")
.withTag(SpanTags.SPAN_KIND, "client").start(); //啟動自己的追蹤span
}
try {
Thread.sleep(2000L);
return "result2";
} catch (InterruptedException e) {
e.printStackTrace();
return "";
} finally {
if (currentSpan != null) {
currentSpan.finish(); //最后完成本次鏈路追蹤
}
}
}
}
運行結果:
r1 = result1
r2 = result2
tracerId=1507767477962777317, parentId=2107142446015091038, spanId=5095502823334701185, title=getResult1, 耗時=1555839336570 - 1555839335569 = 1001ms, tags={span.kind=client, component=redis}
tracerId=1507767477962777317, parentId=2107142446015091038, spanId=9071431876337611242, title=getResult2, 耗時=1555839338572 - 1555839336571 = 2001ms, tags={span.kind=client, component=redis}
tracerId=1507767477962777317, parentId=0, spanId=2107142446015091038, title=test1, 耗時=1555839338572 - 1555839334687 = 3885ms, tags={span.kind=server, component=http}
通過該實例,關於simple-trace的基本用法已經展示出來了(創建tracer、span、建立關系、tags、finish等),看下打印結果(打印結果就是simple-trace推送數據時直接打印的,耗時是根據startTime和endTime推算出來的),父子關系建立完成,假如說這些數據已經落庫完成,那么通過鏈路系統的API解析和前端渲染,會變成下面這樣(繪圖和上面測試結果不是同一次,所以圖里耗時跟上面打印的耗時不一致😭):

圖4
本篇不討論圖如何生成,可以說下后端可以給前端提供的接口結構以及組裝方式:首先可以根據traceId查出來所有相關span,然后根據parentId進行封裝層級,比如圖4的API結構大致上如下:
{
"spanId": 2107142446015091038,
"traceId": 1507767477962777317,
"parentId": 0,
"title": "test1",
"project": "projectName",
"startTime": 1555839334687,
"endTime": 1555839338572,
"tags": {
"span.kind": "server",
"component": "http"
},
"children": [{
"spanId": 5095502823334701185,
"traceId": 1507767477962777317,
"parentId": 2107142446015091038,
"title": "getResult1",
"project": "projectName",
"startTime": 1555839335569,
"endTime": 1555839336570,
"tags": {
"span.kind": "client",
"component": "redis"
},
"children": []
},
{
"spanId": 9071431876337611242,
"traceId": 1507767477962777317,
"parentId": 2107142446015091038,
"title": "getResult2",
"project": "projectName",
"startTime": 1555839336571,
"endTime": 1555839338572,
"tags": {
"span.kind": "client",
"component": "redis"
},
"children": []
}
]
}
包裝成上面的結構,前端根據層級關系、startTime、endTime進行調用樹和時間軸的渲染即可,在實際生產中,這個層級樹可能更加龐大,比如圖2。
基本使用很簡單,那么基於簡單的例子再進行一層抽象,如果在生實際項目中,就不能單單像上面那樣使用了,需要封裝、解耦,那么實際項目中一般會通過怎樣的方式來使用呢?跨系統的時候如何建立層級關系呢?下面針對圖2中的例子,進行簡單的方案設計(圖2過於復雜,這里只說服務A的調用鏈路,其余按照服務A類推即可),下面將會采用偽代碼的方式進行說明問題的解決方案,實際當中需要自己按照實現思路自行封裝。
現在引入兩個概念,攔截器和Context(上下文),它們屬於正常業務中常用的概念,Context是指一次調用產生的上下文信息,上下文信息可以在單次程序調用中的任意位置取到,一般上下文都是利用ThreadLocal(簡稱TL)實現的,線程本地變量,單純理解就是只要本次調用的信息都處於同一個線程,那么任意地方都可以通過TL對象拿到上下文對象信息,但是由於系統的復雜度越來越高,一些地方會采用線程池來進行優化業務代碼,比如一次調用可能會利用CompletableFuture來進行異步任務調度來優化當前代碼執行效率,這個時候單純使用TL就辦不成事兒了,而使用InheritableThreadLocal(簡稱ITL)又解決不了線程池傳遞問題,於是就有了阿里推出的TransmittableThreadLocal(簡稱TTL),這個可以完美解決跨線程傳遞上下文信息(不管是new Thread還是線程池,都可以准確傳遞),當然,你也可以仿照TTL的實現,簡單代理線程池對象,仍然使用TL實現跨線程傳遞,也是可以的,TL系列文章傳送門:ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal
下面是關於系統上下文的簡單定義:
//自定上下文類
public class Context {
private SimpleTracer simpleTracer; //當前鏈路對象
private SimpleSpan parent; //當前鏈路全局父span
//也可以放很多別的上下文內容,這里省略...
public SimpleTracer getSimpleTracer() {
return simpleTracer;
}
public void setSimpleTracer(SimpleTracer simpleTracer) {
this.simpleTracer = simpleTracer;
}
public SimpleSpan getParent() {
return parent;
}
public void setParent(SimpleSpan parent) {
this.parent = parent;
}
}
public class ContextHolder {
//這里僅用TL簡單實現,如果項目里使用了線程池,那么這里的實現要變成TTL,並讓TTL代理全局的線程池對象,也可以不用TTL,自己代理線程池對象,這里不再詳述
private static ThreadLocal contextThreadLocal = new ThreadLocal<>();
private ContextHolder() {
}
public static void removeContext() {
contextThreadLocal.remove();
}
public static Context getContext() {
return contextThreadLocal.get();
}
public static void setContext(Context context) {
if (context == null) {
removeContext();
}
contextThreadLocal.set(context);
}
}
我們把鏈路對象和鏈路第一次產生的父span放到上下文,意味着我們可以在這次調用的任意位置通過ContextHolder獲取到當前鏈路對象(偽代碼會出現該類),下面來結合圖2的A服務鏈路,結合aop思想,寫一次從圖2API調用開始到Redis01調用結束的代碼。
按照流程,API屬於一次Http調用,也是鏈路入口,那么利用這一點,和Http服務的攔截器功能(大部分系統都會用到一個http調用的攔截器,一般上下文也是這里產生的),偽代碼如下:
public class ApiInterceptor {
//開始Http處理請求之前要做的,一般這里產生上下文,並交給TL傳遞上下文對象,這里也是鏈路初始化的地方
public void beforeHandle(Request request) {
Context context = new Context(); //上下文對象
SimpleTracer tracer = null;
SimpleSpan parent = null;
//這里是為跨系統調用做的協議頭傳遞,因為我們這個API也可能是公司內別的業務方內部調用,那么這個時候就需要約定協議頭,一旦協議頭中帶有約定好的鏈路字段,那么就認為我們這個API本次調用相對於別的系統是個子鏈路
String traceId = request.headers.get("x1-trace-id"); //拿到協議頭的父鏈路id,子鏈路繼承之
String parentId = request.headers.get("x1-span-id"); //拿到協議頭的父span信息
String sampled = request.headers.get.get("x1-sampled"); //是否上報
if (traceId != null && parentId != null && sampled == true) {
tracer = new SimpleTracer(request.getUri, "所屬項目名"); //這里用url當成是初始化span的title
// 符合這種情況的,我們這里的parent其實只是一個相對於別的系統的child
SimpleSpan.SimpleSpanContext simpleSpanContext = new SimpleSpan.SimpleSpanContext(traceId, parentId, new HashMap<>());
parent = tracer.buildSpan(request.getUri)
.addReference(References.CHILD_OF, simpleSpanContext) //建立父子關系,如果是別的業務方調用我們這個http服務,那么這里這一步,也就建立了跟調用方的父子關系,traceId等是繼承的調用方的,意味着本次調用也屬於調用方的一環,這也就實現了跨系統的鏈路追蹤
.withTag(SpanTags.COMPONENT, "http")
.withTag(SpanTags.SPAN_KIND, "server").start(); //啟動span
} else { //執行else,說明該http調用是一次自己完整的調用,不屬於任何父鏈路,那么就無需建立關系,直接初始化tracer即可
tracer = new SimpleTracer(request.getUri, "所屬項目名");
parent = tracer.buildSpan(request.getUri)
.withTag(SpanTags.COMPONENT, "http")
.withTag(SpanTags.SPAN_KIND, "server")
.start(); //啟動span
}
//將封裝好的tracer和parentSpan設置到上下文對象里去
context.setSimpleTracer(tracer);
context.setParent(parent);
ContextHolder.setContext(context); //將本次請求生成的上下文對象放進ContextHolder(也就是TL里),方便在任意位置取出使用
}
//業務邏輯處理中
public void hadle() {
//本次API請求實際走的業務邏輯,也就是A服務調用、B服務調用等這些實際的業務邏輯處理
doing();
}
//Http業務處理完成后的觸發
public void afterHandler() {
//Http調用結束的時候,取出當前鏈路信息,完成數據的上報
SimpleTracer tracer = ContextHolder.getContext().getTracer();
SimpleSpan parent = ContextHolder.getContext().getParent();
if (tracer != null && parent != null) {
parent.finish(); //結束掉parent Span
tracer.pushSpans(); //上報這次產生的鏈路數據(spans)
}
}
}
通過這個外部的API鏈路包裝,可以知道的事情是上下文在這里面充當的角色,API調用是一個系統的入口,這種入口有很多,一次系統調用都會有一個類似的入口,比如RPC調用,跨系統后的rpcServer端也是一個入口,這種入口級的攔截器,before里面做的通常都是建立Tracer,但是代碼里不是簡單的創建一個Tracer對象就完事兒了,還有協議頭的分析,鏈路系統如何實現跨系統的傳輸呢?這就牽扯到協議約定,比如Http請求,可以在協議頭里約定幾個特殊字符串來存放來源系統的tracerId等,結合上面的例子,假如我們這個API是公司內別的系統API01發起的http調用,API01本身也會有鏈路追蹤,API01系統內發起對我們API的http請求,這就屬於跨系統調用,我們這次API調用相對於API01是一個子鏈路,需要建立父子關系,結合上面的例子簡單畫下這次調用圖:

圖5
包括API的其他跨系統的調用,比如A服務的調用,也是使用同樣的原理進行鏈路跨系統傳輸的(很多RPC框架上層協議也是支持擴展協議頭的,比如grpc的上層協議就是http2),那么接下來看下圖中(截自圖2)標紅模塊對應的偽代碼吧:

圖6
這塊是指當前系統通過rpc client發起對A服務的調用,從發起調用到A服務響應,這個過程仍然屬於API這次調用的子span(沒有出系統),但是到了A服務的觸發,就牽扯到跨系統,A服務的鏈路相對於rpc client(圖6標紅的操作)的span,是一個子span,通過上面對跨系統的處理,這里rpc client里一定會把自身的spanId作為A服務的parentId傳過去,包括traceId等,來看下偽代碼:
public class RpcClient {
//等待服務端響應方法
public void requestRpc(RpcRequest request) {
//調用前執行
SimpleSpan span = null;
SimpleSpan parent = ContextHolder.getContext().getParent();
SimpleTracer tracer = ContextHolder.getContext().getTracer();
if (tracer != null && parent != null) {//↓這個title就設置成rpc調用的那個方法名即可
span = tracer.buildSpan(request.getRpcMethod).asChildOf(parent) //建立父子關系,因為rpc client調用屬於API調用的子鏈路
.withTag(SpanTags.COMPONENT, "grpc")
.withTag(SpanTags.PEER_SERVICE, request.getRpcMethod)
.withTag(SpanTags.SPAN_KIND, "client")
.start(); //啟動這個span
//設置協議頭,因為被調用的RPC服務相對於我們來說是個子鏈路
request.setHeader("x1-rpc-span-id", span.context().spanId());
request.setHeader("x1-rpc-trace-id", span.context().traceId());
request.setHeader("x1-rpc-sampled", span.sampled());
}
rpcServerRequest(request); //實際調用rpc服務
//調用后執行
if(span != null){
span.finish(); //完成本次追蹤
}
}
}
這樣就完成了圖6中紅線部分的span,然后來看下被調用的服務A內部是怎么處理的(其實很像上面http入口的處理方式):
public class RpcServerInterceptor {
//服務的入口,Rpc服務處理請求之前要做的,一般這里產生上下文,並交給TL傳遞上下文對象,這里也是鏈路初始化的地方
public void beforeHandle(RpcRequest request) {
Context context = new Context(); //上下文對象
SimpleTracer tracer = null;
SimpleSpan parent = null;
//解析協議頭
String traceId = request.headers.get("x1-rpc-trace-id"); //拿到協議頭的父鏈路id,子鏈路繼承之
String parentId = request.headers.get("x1-rpc-span-id"); //拿到協議頭的父span信息
String sampled = request.headers.get.get("x1-rpc-sampled"); //是否上報
if (traceId != null && parentId != null && sampled == true) {
tracer = new SimpleTracer(request.getMethod, "所屬項目名");
// 符合這種情況的,我們這里的parent其實只是一個相對於別的系統的child
SimpleSpan.SimpleSpanContext simpleSpanContext = new SimpleSpan.SimpleSpanContext(traceId, parentId, new HashMap<>());
parent = tracer.buildSpan(request.getMethod)
.addReference(References.CHILD_OF, simpleSpanContext) //建立父子關系,如果是別的業務方調用我們這個服務,那么這里這一步,也就建立了跟調用方的父子關系,traceId等是繼承的調用方的,意味着本次調用也屬於調用方的一環,這也就實現了跨系統的鏈路追蹤
.withTag(SpanTags.COMPONENT, "rpc")
.withTag(SpanTags.SPAN_KIND, "server").start(); //啟動span
} else { //執行else,說明該rpc調用是一次自己完整的調用,不屬於任何父鏈路,那么就無需建立關系,直接初始化tracer即可
tracer = new SimpleTracer(request.getMethod, "所屬項目名");
parent = tracer.buildSpan(request.getMethod)
.withTag(SpanTags.COMPONENT, "rpc")
.withTag(SpanTags.SPAN_KIND, "server")
.start(); //啟動span
}
//將封裝好的tracer和parentSpan設置到上下文對象里去
context.setSimpleTracer(tracer);
context.setParent(parent);
ContextHolder.setContext(context); //將本次請求生成的上下文對象放進ContextHolder(也就是TL里),方便在任意位置取出使用
}
//業務邏輯處理中
public void rpcServerHadle() {
doing();
}
//Rpc業務處理完成后的觸發
public void afterHandler() {
//Rpc Server調用結束的時候,取出當前鏈路信息,完成數據的上報
SimpleTracer tracer = ContextHolder.getContext().getTracer();
SimpleSpan parent = ContextHolder.getContext().getParent();
if (tracer != null && parent != null) {
parent.finish(); //結束掉parent Span
tracer.pushSpans(); //上報這次產生的鏈路數據(spans)
}
}
}
可以看到,client發起調用時傳遞的協議字段,在服務端這里被解析了,建立好父子關系后,A服務再去處理自己的邏輯和鏈路。
沒有牽扯到跨系統的鏈路追蹤,如對redis、memcached、mysql等DB的調用,可以簡單在調用元方法上搞個aop代理,然后通過通過上下文對象里的Tracer和parent建立父子關系,結束時finish即可,而pushSpans這個動作通常發生在一次系統調用執行完畢的時候發生,比如API的調用結束時、A服務調用結束時,都是pushSpans的觸發點。
到這里基本上關於鏈路追蹤的介紹算結束了,因為系統級的實現方式想要完整的展現在一篇文章里不太現實,所以在使用simple-trace sdk的時候使用了偽代碼,便於說明問題,文章沒有針對整個鏈路系統作說明,主要是針對數據采集、數據跨系統追蹤做了描述,因為數據采集這一環算是比較重要的一環,也是跟業務開發人員息息相關的一環,如果想要完整搞一個鏈路追蹤系統,可以參考之前的架構搭建一套,以完成采集、上報、落庫、解析、展示整個流程。

