呼叫鏈系列(4):服務資訊上下文傳遞
在之前的呼叫鏈系列文章中,我們已經對呼叫鏈進行了詳細介紹,相信大家已經對呼叫鏈技術有了基本的瞭解。
拓展閱讀:
呼叫鏈系列(1):解讀UAVStack中的貪吃蛇
呼叫鏈系列(2):輕呼叫鏈實現
呼叫鏈系列(3):如何從零開始捕獲body和header
其實,在呼叫鏈的繪製過程中,呼叫鏈上下文的傳遞非常值得關注。各個節點在獲取上層上下文後生成新的上下文並向後傳遞。在傳遞過程中,上下文一旦丟失或出現異常就會導致呼叫鏈資料缺失,甚至可能會發生斷裂。
本文主要講述UAV中呼叫鏈上下文傳遞過程中的部分實現細節。
前言
在呼叫鏈的實現中,主要存在以下幾種呼叫鏈上下文的傳遞方式:
-
請求處理前到請求處理後的上下文傳遞;
-
各個客戶端呼叫間的上下文傳遞;
-
各個服務間呼叫時的上下文傳遞。
在這三種情況中,上下文傳遞過程中所傳遞的資訊以及遇到的問題會有所不同。
-
在請求處理前後的上下文傳遞過程中,需要傳遞的資訊一般包括traceID、 spanID、請求開始的時間以及部分請求引數等。相關程式碼可能會因為非同步執行導致上下文面臨非同步執行緒傳遞的問題。
-
在客戶端呼叫間及服務間呼叫中,需要傳遞的上下文資訊一般只包括traceID和spanID。但客戶端呼叫之間的上下文傳遞可能會遇到跨執行緒池傳遞的問題,服務間呼叫則存在跨應用傳遞的問題。
因此,我們把今天所講的上下文傳遞劃分為以下四種場景進行分析:
-
在同一執行緒內傳遞
-
跨執行緒池傳遞
-
非同步執行緒傳遞
-
跨應用傳遞
為了更好地闡述這四種場景,我們假設存在以下業務呼叫過程:
假設某次請求首先進入服務A,在服務A的業務程式碼中發起了一次JDBC請求,訪問了一次資料來源;然後又通過httpClient(同步,非同步)發起了一次http訪問並返回相應結果。
數字表示所在點存在呼叫鏈上下文資訊的獲取。在大多數的相鄰點之間都會涉及到呼叫鏈上下文的傳遞。
例如,從2點到3點就是請求前和請求後的上下文傳遞,從3點到4點就是兩次客戶端呼叫間的上下文傳遞,從4點到5點就是服務間的上下文傳遞。下面我們將在不同的場景下說明各點之間的上下文傳遞過程。
一、在同一執行緒內的上下文傳遞
這種場景比較常見,也是最簡單的場景。
假設上述模擬流程中全部為同步操作,業務程式碼中不涉及任何的執行緒池(資料庫連線池不影響)及非同步操作,那麼服務A中呼叫鏈的相關程式碼均會在同一個執行緒中執行。
說到這裡,想必大家都會想到使用ThreadLocal便可以解決。使用ThreadLocal的確可以解決同線程中的引數共享傳遞問題。在UAV中,一般兩次客戶端呼叫之間的上下文傳遞都直接使用ThreadLocal(其實並不是原生的ThreadLocal,後文會有所介紹),傳遞過程如下:
但是很多時候,業務程式碼中經常會涉及到非同步或者提交執行緒池的操作,此時單單使用ThreadLocal便無法滿足相應的需求。下面我們就來討論有關含有執行緒池操作和非同步請求的上下文傳遞問題。
二、跨執行緒池的上下文傳遞
首次我們來看一下跨執行緒池上下文傳遞問題。
假設上述的業務場景中在進行JDBC操作時,當前的執行緒僅負責將JDBC操作提交到執行緒池中,那麼此時上下文資訊從1點傳遞到2點就會遇到跨執行緒池的問題,此時使用ThreadLocal無法上下文資訊的傳遞。
當然有的同學可能會說用InheritableThreadLocal。但是提交執行緒和執行緒池執行緒本身並不存在父子關係,因此InheritableThreadLocal也是無法完成跨執行緒池的上下文傳遞的。
為了解決這個問題,我們使用了阿里開源的跨執行緒池的ThreadLocal元件:transmittable-thread-local(以下簡稱TTL,具體的實現方式有興趣的同學可以去了解下https://github.com/alibaba/transmittable-thread-local)。
通過該元件可以增強ThreadLocal的功能實現跨執行緒池的傳遞。以下是github中TTL的使用示例:
TransmittableThreadLocal<String> parent =newTransmittableThreadLocal<String>(); parent.set("value-set-in-parent"); Runnable task =new Task("1"); // 額外的處理,生成修飾了的物件ttlRunnable Runnable ttlRunnable = TtlRunnable.get(task); executorService.submit(ttlRunnable); // Task中可以讀取,值是"value-set-in-parent" String value = parent.get();
可以看到,想要TTL起作用,就需要將業務程式碼中的runnable更換為TtlRunnable。為了實現對業務程式碼的零入侵,我們藉助javaagent機制增加了一個針對ThreadPoolExecutor等一些Eexecutor的ClassFileTransformer,將提交到執行緒池中的Runnable和Callable包裝成相應的TtlRunnable和TtlCallable,這樣就實現了在不修改業務程式碼的情況下完成跨執行緒池的上下文傳遞。
另外,由於TTL具備ThreadLocal的所有特性,因此UAV的上下文傳遞過程中用到的ThreadLocal均是TTL。
三、非同步執行緒中上下文傳遞
看完上面的跨執行緒池操作,我們再來看一下非同步執行緒的問題。
假設在上述模擬場景中,我們使用非同步HttpClient傳送了一個非同步的Http請求。由於是非同步操作,4點的程式碼和7點的程式碼(這裡7點的上下文是從4點中獲取的屬於請求前後的上下文獲取場景)實際上會在不同的執行緒中執行,導致7點無法獲取4點放入ThreadLocal中的上下文資料,進而導致呼叫鏈的資料丟失。
為了解決這個問題,在UAV中我們同時使用了位元組碼改寫和動態代理技術。關鍵在於目標jiechi函式的選擇,需要能夠獲取到非同步執行緒的回撥物件。
下面以非同步HttpClient為例介紹UAV中非同步執行緒上下文的傳遞過程。
在非同步HttpClient中,我們jiechi的是InternalHttpAsyncClient類的execute()方法,該方法宣告如下:
一般情況下,非同步的使用方式為傳入一個callback介面物件,在callback中實現相應的非同步邏輯;或者使用返回的Future介面物件的get()方法實現一種非同步轉同步的操作。
為了能夠在相應的地方獲取到呼叫鏈的上下文,我們首先通過改寫位元組碼的方式,在方法執行前生成呼叫鏈的上下文資訊;然後對FutureCallback介面做動態代理,同時將生成的上下文資訊傳入到代理物件中,並替換原來的callback物件。
這樣當非同步請求返回呼叫callback介面時,實際上拿到的是我們的代理物件,此時也就完成了非同步執行緒中上下文的傳遞過程,具體過程如下:
為了支援通過get()方法的非同步轉同步操作,在這裡我們也對返回的Future介面做了動態代理來完成上下文的傳遞。
四、跨應用上下文傳遞
說完應用內的上下文傳遞過程,我們來看一下跨應用的上下文傳遞問題。
跨應用的場景也是比較常見的。在這種場景下,上下文傳遞的思路一般是將上下文的資訊按照一定的協議反序列化,然後放入到請求的傳輸報文中;在下游服務中jiechi請求,獲取其中的資訊完成上下文的傳遞。在整個處理過程中,不對應用報文解析造成任何影響。
常見的傳輸協議中如HTTP協議,Dubbo的RPC協議,RocketMQ的MQ協議等。這些協議一般會含有類似於頭資訊的結構,用於表示本次請求的額外資訊。
我們恰好可以利用這一點,將上下文資訊放入其中傳輸給下游服務,完成上下文的傳遞。
下面我們仍然以非同步HttpClient來介紹UAV跨應用上下文的傳遞過程。
之前我們說過,在非同步HttpClient中,我們jiechi的是execute()方法。在這個方法中,我們可以拿到HttpAsyncRequestProducer介面物件,具體介面如下:
通過其中的generateRequest()方法,我們就可以拿到本次請求將要傳送的request物件,利用request的setHeader()方法,將呼叫鏈的上下文資訊放入Header中傳入下游。
這裡的上下文一般比較簡單,基本上都是由traceID和spanID的字串構成,傳輸成本也不高。
至於下游服務中如何解析該上下文,實際上之前的呼叫鏈系列中有談到,就是藉助UAV的中介軟體增強框架(MOF),在服務端jiechi請求對應的request物件,然後直接從其頭資訊中獲取即可。
其他的RPC或者MQ等協議,在UAV中均是採用這種方式完成,只是具體的API和jiechi點有所不同。
例如,Dubbo遠端呼叫過程中使用是其中的RpcContext,而RocketMQ則是放入到了msg的UserProperty中。感興趣的同學可以到UAVStack(https://github.com/uavorg/uavstack)中檢視相關的原始碼。
總結
瞭解這些上下文的傳遞過程後,大家便可以基於呼叫鏈實現更為強大的功能。UAV中,呼叫鏈和日誌關聯功能就是通過jiechi日誌輸入部分的相關程式碼,獲取呼叫鏈上下文,然後將traceID輸出到業務日誌中來實現的。
大家也可以自己在業務程式碼中嘗試獲取呼叫鏈的上下文,將業務資料與呼叫鏈資料打通,方便資料統計和問題排查。
作者:朱文強
宜信技術