一次「找回」TraceId的問題分析與過程思考

美團技術團隊發表於2023-04-21
用好中介軟體是每一個開發人員的基本功,一個專業的開發人員,追求的不僅是中介軟體的日常使用,還要探究這背後的設計初衷和底層邏輯,進而保證我們的系統執行更加穩定,讓開發工作更加高效。結合這一主題,本文從一次線上告警問題出發,透過第一時間定位問題的根本原因,進而引出Google Dapper與MTrace(美團內部自研)這類分散式鏈路追蹤系統的設計思想和實現途徑,再回到問題本質深入@Async的原始碼分析底層的非同步邏輯和實現特點,並給出MTrace跨執行緒傳遞失效的原因和解決方案,最後梳理目前主流的分散式跟蹤系統的現狀,並結合開發人員日常使用中介軟體的場景提出一些思考和總結。

1. 問題背景和思考

1.1 問題背景

在一次排查線上告警的過程中,突然發現一個鏈路資訊有點不同尋常(這裡僅展示測試復現的內容):

在機器中可以清楚的發現“2022-08-02 19:26:34.952 DXMsgRemoteService ”這一行日誌資訊並沒有攜帶TraceId,導致呼叫鏈路資訊戛然而止,無法追蹤當時的呼叫情況。

1.2 問題復現和思考

在處理完線上告警後,我們開始分析“丟失”的TraceId到底去了哪裡?首先在程式碼中定位TraceId沒有追蹤到的部分,發現問題出現在一個@Async註解下的方法,刪除無關的業務資訊程式碼,並增加MTrace埋點方法後的復現程式碼如下:

@SpringBootTest
@RunWith(SpringRunner.class)
@EnableAsync
public class DemoServiceTest extends TestCase {
    @Resource
        private DemoService demoService;
    @Test
        public void testTestAsy() {
        Tracer.serverRecv("test");
        String mainThreadName = Thread.currentThread().getName();
        long mainThreadId = Thread.currentThread().getId();
        System.out.println("------We got main thread: "+ mainThreadName + " - " +  mainThreadId + "  Trace Id: " + Tracer.id() + "----------");
        demoService.testAsy();
    }
}
@Component
public class DemoService {
    @Async
        public void testAsy(){
        String asyThreadName = Thread.currentThread().getName();
        long asyThreadId = Thread.currentThread().getId();
        System.out.println("======Async====");
        System.out.println("------We got asy thread: "+ asyThreadName + " - " +  asyThreadId + "  Trace Id: " + Tracer.id() + "----------");
    }
}

執行這段程式碼後,我們看看控制檯實際的輸出結果:

------We got main thread: main - 1  Trace Id: -5292097998940230785----------
======Async====
------We got asy thread: SimpleAsyncTaskExecutor-1 - 630  Trace Id: null----------

至此我們可以發現TraceId是在@Async非同步傳遞的過程中發生丟失現象,明白了造成這一現象的原因後,我們開始思考:

  • MTrace(美團內部自研的分散式鏈路追蹤系統)這類分散式鏈路追蹤系統是如何設計的?
  • @Async非同步方法是如何實現的?
  • InheritableThreadLocal、TransmittableThreadLocal和TransmissibleThreadLocal有什麼區別?
  • 為什麼MTrace的跨執行緒傳遞方案“失效”了?
  • 如何解決@Async場景下“弄丟”TraceId的問題?
  • 目前有哪些分散式鏈路追蹤系統?它們又是如何解決跨執行緒傳遞問題的?

2. 深度分析

2.1 MTrace與Google Dapper

MTrace是美團參考Google Dapper對服務間呼叫鏈資訊收集和整理的分散式鏈路追蹤系統,目的是幫助開發人員分析系統各項效能和快速排查告警問題。要想了解MTrace是如何設計分散式鏈路追蹤系統的,首先看看Google Dapper是如何在大型分散式環境下實現分散式鏈路追蹤。我們先來看看下圖一個完整的分散式請求:

使用者傳送一個請求到前端A,然後請求分發到兩個不同的中間層服務B和C,服務B在處理完請求後將結果返回,同時服務C需要繼續呼叫後端服務D和E再將處理後的請求結果進行返回,最後由前端A彙總來響應使用者的這次請求。

回顧這次完整的請求我們不難發現,要想直觀可靠的追蹤多項服務的分散式請求,我們最關注的是每組客戶端和服務端之間的請求響應以及響應耗時,因此,Google Dapper採取對每一個請求和響應設定識別符號和時間戳的方式實現鏈路追蹤,基於這一設計思想的基本追蹤樹模型如下圖所示:

追蹤樹模型由span組成,其中每個span包含span name、span id、parent id和trace id,進一步分析跟蹤樹模型中各個span之間的呼叫關係可以發現,其中沒有parent id且span id為1代表根服務呼叫,span id越小代表服務在呼叫鏈的過程中離根服務就越近,將模型中各個相對獨立的span聯絡在一起就構成了一次完整的鏈路呼叫記錄,我們再繼續深入看看span內部的細節資訊:

除了最基本的span name、span id和parent id之外,Annotations扮演著重要的角色,Annotations包括\<Strat>、Client Send、Server Recv、Server Send、Client Recv和\<End>這些註解,記錄了RPC請求中Client傳送請求到Server的處理響應時間戳資訊,其中foo註解代表可以自定義的業務資料,這些也會一併記錄到span中,提供給開發人員記錄業務資訊;在這當中有64位整數構成的trace id作為全域性的唯一標識儲存在span中。

至此我們已經瞭解到,Google Dapper主要是在每個請求中配置span資訊來實現對分散式系統的追蹤,那麼又是用什麼方式在分散式請求中植入這些追蹤資訊呢?

為滿足低損耗、應用透明和大範圍部署的設計目標,Google Dapper支援應用開發者依賴於少量通用元件庫,實現幾乎零投入的成本對分散式鏈路進行追蹤,當一個服務執行緒在鏈路中呼叫其他服務之前,會在ThreadLocal中儲存本次跟蹤的上下文資訊,主要包括一些輕量級且易複製的資訊(類似spand id和trace id),當服務執行緒收到響應之後,應用開發者可以透過回撥函式進行服務資訊日誌列印。

MTrace是美團參考Google Dapper的設計思路並結合自身業務進行了改進和完善後的自研產品,具體的實現流程這裡就不再贅述了,我們重點看看MTrace做了哪些改進:

  • 在美團的各個中介軟體中埋點,來採集發生呼叫的呼叫時長和呼叫結果等資訊,埋點的上下文主要包括傳遞資訊、呼叫資訊、機器相關資訊和自定義資訊,各個呼叫鏈路之間有一個全域性且唯一的變數TraceId來記錄一次完整的呼叫情況和追蹤資料。
  • 在網路間的資料傳遞中,MTrace主要傳遞使用UUID異或生成的TraceId和表示層級和前後關係的SpanId,支援批次壓縮上報、TraceId做聚合和SpanId構建形態。
  • 目前,產品已經覆蓋到RPC服務、HTTP服務、MySQL、Cache快取和MQ,基本實現了全覆蓋。
  • MTrace支援跨執行緒傳遞和代理來最佳化埋點方式,減輕開發人員的使用成本。

2.2 @Async的非同步過程追溯

從Spring3開始提供了@Async註解,該註解的使用需要注意以下幾點:

  1. 需要在配置類上增加@EnableAsync註解;
  2. @Async註解可以標記一個非同步執行的方法,也可以用來標記一個類表明該類的所有方法都是非同步執行;
  3. 可以在@Async中自定義執行器。

我們以@EnableAsync為入口開始分析非同步過程,除了基本的配置方法外,我們重點關注下配置類AsyncConfigurationSelector的內部邏輯,由於預設條件下我們使用JDK介面代理,這裡重點看看ProxyAsyncConfiguration類的程式碼邏輯:

@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {
    @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME)
        @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
        public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
        Assert.notNull(this.enableAsync, "@EnableAsync annotation metadata was not injected");
        //新建一個非同步註解bean後置處理器
        AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
        //如果@EnableAsync註解中有自定義annotation配置則進行設定
        Class<? extends Annotation> customAsyncAnnotation = this.enableAsync.getClass("annotation");
        if (customAsyncAnnotation != AnnotationUtils.getDefaultValue(EnableAsync.class, "annotation")) {
            bpp.setAsyncAnnotationType(customAsyncAnnotation);
        }
        if (this.executor != null) {
            //設定執行緒處理器
            bpp.setExecutor(this.executor);
        }
        if (this.exceptionHandler != null) {
            //設定異常處理器
            bpp.setExceptionHandler(this.exceptionHandler);
        }
        //設定是否需要建立CGLIB子類代理,預設為false
        bpp.setProxyTargetClass(this.enableAsync.getBoolean("proxyTargetClass"));
        //設定非同步註解bean處理器應該遵循的執行順序,預設最低的優先順序
        bpp.setOrder(this.enableAsync.<Integer>getNumber("order"));
        return bpp;
    }
}

ProxyAsyncConfiguration繼承了父類AbstractAsyncConfiguration的方法,重點定義了一個AsyncAnnotationBeanPostProcessor的非同步註解bean後置處理器。看到這裡我們可以知道,@Async主要是透過後置處理器生成一個代理物件來實現非同步的執行邏輯,接下來我們重點關注AsyncAnnotationBeanPostProcessor是如何實現非同步的:

從類圖中我們可以直觀地看到AsyncAnnotationBeanPostProcessor同時實現了BeanFactoryAware的介面,因此我們進入setBeanFactory()方法,可以看到對AsyncAnnotationAdvisor非同步註解切面進行了構造,再接著進入AsyncAnnotationAdvisor的buildAdvice()方法中可以看AsyncExecutionInterceptor類,再看類圖發現AsyncExecutionInterceptor實現了MethodInterceptor介面,而MethodInterceptor是AOP中切入點的處理器,對於interceptor型別的物件,處理器中最終被呼叫的是invoke方法,所以我們重點看看invoke的程式碼邏輯:

public Object invoke(final MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
    Method specificMethod = ClassUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
    final Method userDeclaredMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
  //首先獲取到一個執行緒池
    AsyncTaskExecutor executor = determineAsyncExecutor(userDeclaredMethod);
    if (executor == null) {
        throw new IllegalStateException("No executor specified and no default executor set on AsyncExecutionInterceptor either");
    }
  //封裝Callable物件到執行緒池執行
    Callable<Object> task = () -> {
        try {
            Object result = invocation.proceed();
            if (result instanceof Future) {
                return ((Future<?>) result).get();
            }
        }
        catch (ExecutionException ex) {
            handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments());
        }
        catch (Throwable ex) {
            handleError(ex, userDeclaredMethod, invocation.getArguments());
        }
        return null;
    };
  //任務提交到執行緒池
    return doSubmit(task, executor, invocation.getMethod().getReturnType());
}

我們再接著看看@Async用了什麼執行緒池,重點關注determineAsyncExecutor方法中getExecutorQualifier指定獲取的預設執行緒池是哪一個:

@Override
@Nullable
protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
    Executor defaultExecutor = super.getDefaultExecutor(beanFactory);   
    return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor()); //其中預設執行緒池是SimpleAsyncTaskExecutor
}

至此,我們瞭解到在未指定執行緒池的情況下呼叫被標記為@Async的方法時,Spring會自動建立SimpleAsyncTaskExecutor執行緒池來執行該方法,從而完成非同步執行過程。

2.3. “丟失”TraceId的原因

回顧我們之前對MTrace的學習和了解,TraceId等資訊是在ThreadLocal中進行傳遞和儲存,那麼當非同步方法切換執行緒的時候,就會出現下圖中上下文資訊傳遞丟失的問題:

下面我們探究一下ThreadLocal有哪些跨執行緒傳遞方案?MTrace又提供哪些跨執行緒傳遞方案?SimpleAsyncTaskExecutor又有什麼不一樣?逐步找到“丟失”TraceId的原因。

2.3.1 InheritableThreadLocal、TransmittableThreadLocal和TransmissibleThreadLocal

在前面的分析中,我們發現跨執行緒場景下上下文資訊是儲存在ThreadLocal中發生丟失,那麼我們接下來看看ThreadLocal的特點及其延伸出來的類,是否可以解決這一問題:

  • ThreadLocal主要是為每個ThreadLocal物件建立一個ThreadLocalMap來儲存物件和執行緒中的值的對映關係。當建立一個ThreadLocal物件時會呼叫get()或set()方法,在當前執行緒的中查詢這個ThreadLocal物件對應的Entry物件,如果存在,就獲取或設定Entry中的值;否則,在ThreadLocalMap中建立一個新的Entry物件。ThreadLocal類的例項被多個執行緒共享,每個執行緒都擁有自己的ThreadLocalMap物件,儲存著自己執行緒中的所有ThreadLocal物件的鍵值對。ThreadLocal的實現比較簡單,但需要注意的是,如果使用不當,可能會出現記憶體洩漏問題,因為ThreadLocalMap中的Entry物件並不會自動刪除。
  • InheritableThreadLocal的實現方式和ThreadLocal類似,但不同之處在於,當一個執行緒建立子執行緒時會呼叫init()方法:
private void init(ThreadGroup g, Runnable target, String name,long stackSize, AccessControlContext acc,Boolean inheritThreadLocals) {
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
  //複製父執行緒的變數
    this.inheritableThreadLocals =ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);    
    this.stackSize = stackSize;
    tid = nextThreadID();
}

這意味著子執行緒可以訪問父執行緒中的InheritableThreadLocal例項,而且在子執行緒中呼叫set()方法時,會在子執行緒自己的inheritableThreadLocals欄位中建立一個新的Entry物件,而不會影響父執行緒中的Entry物件。同時,根據原始碼我們也可以看到Thread的init()方法是線上程構造方法中複製的,線上程複用的執行緒池中是沒有辦法使用的。

  • TransmittableThreadLocal是阿里巴巴提供的解決跨執行緒傳遞上下文的InheritableThreadLocal子類,引入了holder來儲存需要線上程間進行傳遞的變數,大致流程我們可以參考下面給出的時序圖分析:

步驟可以總結為:① 裝飾Runnable,將主執行緒的TTL傳入到TtlRunnable的構造方法中;② 將子執行緒的TTL的值進行備份,將主執行緒的TTL設定到子執行緒中(value是物件引用,可能存線上程安全問題);③ 執行子執行緒邏輯;④ 刪除子執行緒新增的TTL,將備份還原重新設定到子執行緒的TTL中,從而保證了ThreadLocal的值在多執行緒環境下的傳遞性。

TransmittableThreadLocal雖然解決了InheritableThreadLocal的繼承問題,但是由於需要在序列化和反序列化時對ThreadLocalMap進行處理,會增加物件建立和序列化的成本,並且需要支援的序列化框架較少,不夠靈活。

  • TransmissibleThreadLocal是繼承了InheritableThreadLocal類並重寫了get()、set()和remove()方法,TransmissibleThreadLocal的實現方式和TransmittableThreadLocal類似,主要的執行邏輯在Transmitter的capture()方法複製holder中的變數,replay()方法過濾非父執行緒的holder的變數,restore()來恢復經過replay()過濾後holder的變數:
public class TransmissibleThreadLocal<T> extends InheritableThreadLocal<T> {
    public static class Transmitter {
        public static Object capture() {
            Map<TransmissibleThreadLocal<?>, Object> captured = new HashMap<TransmissibleThreadLocal<?>, Object>();
      //獲取所有儲存在holder中的變數
            for (TransmissibleThreadLocal<?> threadLocal : holder.get().keySet()) { 
                captured.put(threadLocal, threadLocal.copyValue());
            }
            return captured;
        }
        public static Object replay(Object captured) {
            @SuppressWarnings("unchecked")
            Map<TransmissibleThreadLocal<?>, Object> capturedMap = (Map<TransmissibleThreadLocal<?>, Object>) captured;
            Map<TransmissibleThreadLocal<?>, Object> backup = new HashMap<TransmissibleThreadLocal<?>, Object>();
            for (Iterator<? extends Map.Entry<TransmissibleThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator();iterator.hasNext(); ) {
                Map.Entry<TransmissibleThreadLocal<?>, ?> next = iterator.next();
                TransmissibleThreadLocal<?> threadLocal = next.getKey();
                // backup
                backup.put(threadLocal, threadLocal.get());
                // clear the TTL value only in captured
                // avoid extra TTL value in captured, when run task.
        //過濾非傳遞的變數
                if (!capturedMap.containsKey(threadLocal)) { 
                    iterator.remove();
                    threadLocal.superRemove();
                }
            }
            // set value to captured TTL
            for (Map.Entry<TransmissibleThreadLocal<?>, Object> entry : capturedMap.entrySet()) {
                @SuppressWarnings("unchecked")
                TransmissibleThreadLocal<Object> threadLocal = (TransmissibleThreadLocal<Object>) entry.getKey();
                threadLocal.set(entry.getValue());
            }
            // call beforeExecute callback
            doExecuteCallback(true);
            return backup;
        }
        public static void restore(Object backup) {
            @SuppressWarnings("unchecked")
            Map<TransmissibleThreadLocal<?>, Object> backupMap = (Map<TransmissibleThreadLocal<?>, Object>) backup;
            // call afterExecute callback
            doExecuteCallback(false);
            for (Iterator<? extends Map.Entry<TransmissibleThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator();
                                         iterator.hasNext(); ) {
                Map.Entry<TransmissibleThreadLocal<?>, ?> next = iterator.next();
                TransmissibleThreadLocal<?> threadLocal = next.getKey();
                // clear the TTL value only in backup
                // avoid the extra value of backup after restore
                if (!backupMap.containsKey(threadLocal)) { 
                    iterator.remove();
                    threadLocal.superRemove();
                }
            }
            // restore TTL value
            for (Map.Entry<TransmissibleThreadLocal<?>, Object> entry : backupMap.entrySet()) {
                @SuppressWarnings("unchecked")
                TransmissibleThreadLocal<Object> threadLocal = (TransmissibleThreadLocal<Object>) entry.getKey();
                threadLocal.set(entry.getValue());
            }
        }
    }
}

TransmissibleThreadLocal不但可以解決跨執行緒的傳遞問題,還能保證子執行緒和主執行緒之間的隔離,但是目前跨執行緒複製span資料時,採用淺複製有丟失資料的風險。最後,我們可以根據下表綜合對比:

考慮到TransmittableThreadLocal並非標準的Java API,而是第三方庫提供的,存在與其它庫的相容性問題,無形中增加了程式碼的複雜性和使用難度。因此,MTrace選擇自定義實現的TransmissibleThreadLocal類可以方便地在跨執行緒和跨服務的情況下傳遞追蹤資訊,透明自動完成所有非同步執行上下文的可定製、規範化的捕捉傳遞,使得整個跟蹤資訊更加完整和準確。

2.3.2 Mtrace的跨執行緒傳遞方案

這一問題MTrace其實已經提供解決方案,主要的設計思路是在子執行緒初始化Runnable物件的時候首先會去父執行緒的ThreadLocal中拿到儲存的trace資訊,然後作為引數傳遞給子執行緒,子執行緒在初始化的時候設定trace資訊來避免丟失。下面我們看看具體實現。

父執行緒新建任務時捕捉所有TransmissibleThreadLocal中的變數資訊,如下圖所示:

子執行緒執行任務時複製父執行緒捕捉的TransmissibleThreadLocal變數資訊,並返回備份的TransmissibleThreadLocal變數資訊,如下圖所示:

在子執行緒執行完業務流程後會恢復之前備份的TransmissibleThreadLocal變數資訊,如下圖所示:

這種方案可以解決跨執行緒傳遞上下文丟失的問題,但是需要程式碼層面的開發會增加開發人員的工作量,對於一個分散式追蹤系統而言並不是最優解:

TraceRunnable command = new TraceRunnable(runnable);
newThread(command).start();
executorService.execute(command);

因此,MTrace同時提供無侵入方式的javaagent&instrument技術,可以簡單理解成一個類載入時的AOP功能,只要在JVM引數新增javaagent的配置,不需要修飾Runnable或是執行緒池的程式碼,就可以在啟動時增強完成跨執行緒傳遞問題。

迴歸到本次的問題中來,目前使用的MDP本身就已經整合了MTrace-agent的模式,但是為什麼還是會“弄丟”TraceId呢?檢視MTrace的ThreadPoolTransformer類和ForkJoinPoolTransformer類我們可以知道,MTrace修改了ThreadPoolExecutor類、ScheduledThreadPoolExecutor類和ForkJoinTask類的位元組碼,順著這個思路我們再看看@Async用到的SimpleAsyncTaskExecutor執行緒池是怎麼一回事。

2.3.3 SimpleAsyncTaskExecutor是怎麼一回事

我們先深入SimpleAsyncTaskExecutor的程式碼中,看看執行邏輯:

public class SimpleAsyncTaskExecutor extends CustomizableThreadCreator implements AsyncListenableTaskExecutor, Serializable {
    private ThreadFactory threadFactory;
    public void execute(Runnable task, long startTimeout) {
        Assert.notNull(task, "Runnable must not be null");
    //isThrottleActive是否開啟限流(預設concurrencyLimit=-1,不開啟限流)
        if(this.isThrottleActive() && startTimeout > 0L) {        
            this.concurrencyThrottle.beforeAccess();
            this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(task));
            this.concurrencyThrottle.beforeAccess();
            this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(task));
            this.concurrencyThrottle.beforeAccess();
            this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(task));
        } else {
            this.doExecute(task);
        }
    }
    protected void doExecute(Runnable task) {
    //沒有執行緒工廠的話預設建立執行緒
        Thread thread = this.threadFactory != null?this.threadFactory.newThread(task):this.createThread(task);        
        thread.start();
    }
    public Thread createThread(Runnable runnable) {
    //和執行緒池不同,每次都是建立新的執行緒
        Thread thread = new Thread(getThreadGroup(), runnable, nextThreadName());
        thread.setPriority(getThreadPriority());
        thread.setDaemon(isDaemon());
        return thread;
    }
}

看到這裡我們可以得出以下幾個特性:

  • SimpleAsyncTaskExecutor每次執行提交給它的任務時,會啟動新的執行緒,並不是嚴格意義上的執行緒池,達不到執行緒複用的功能。
  • 允許開發者控制併發執行緒的上限(concurrencyLimit)起到一定的資源節流作用,但預設concurrencyLimit取值為-1,即不啟用資源節流,有引發記憶體洩漏的風險。
  • 阿里技術編碼規約要求用ThreadPoolExecutor的方式來建立執行緒池,規避資源耗盡的風險。

結合之前說過的MTrace執行緒池代理模型,我們繼續再來看看SimpleAsyncTaskExecutor的類圖:

可以發現,其繼承了spring的TaskExecutor介面,其實質是java.util.concurrent.Executor,結合我們這次“丟失”的TraceId問題來看,我們已經找到了Mtrace的跨執行緒傳遞方案“失效”的原因:雖然MTrace已經透過javaagent&instrument技術可以完成Trace資訊跨執行緒傳遞,但是目前只覆蓋到ThreadPoolExecutor類、ScheduledThreadPoolExecutor類和ForkJoinTask類的位元組碼,而@Async在未指定執行緒池的情況下預設會啟用SimpleAsyncTaskExecutor,其本質是java.util.concurrent.Executor沒有被覆蓋到,就會造成ThreadLocal中的get方法獲取資訊為空,導致最終TraceId傳遞丟失。

3. 解決方案

實際上@Async支援我們使用自定義的執行緒池,可以手動自定義Configuration來配置ThreadPoolExecutor執行緒池,然後在註解裡面指定bean的名稱,就可以切換到對應的執行緒池去,可以看看下面的程式碼:

@Configuration
public class ThreadPoolConfig {
    @Bean("taskExecutor")
        public Executor taskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        //設定執行緒池引數資訊
        taskExecutor.setCorePoolSize(10);
        taskExecutor.setMaxPoolSize(50);
        taskExecutor.setQueueCapacity(200);
        taskExecutor.setKeepAliveSeconds(60);
        taskExecutor.setThreadNamePrefix("myExecutor--");
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        taskExecutor.setAwaitTerminationSeconds(60);
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        taskExecutor.initialize();
        return taskExecutor;
    }
}

然後在註解中標註這個執行緒池:

@SpringBootTest
@RunWith(SpringRunner.class)
@EnableAsync
public class DemoServiceTest extends TestCase {
    @Resource
      private DemoService demoService;
    @Test
      public void testTestAsy() {
        Tracer.serverRecv("test");
        String mainThreadName = Thread.currentThread().getName();
        long mainThreadId = Thread.currentThread().getId();
        System.out.println("------We got main thread: "+ mainThreadName + " - " +  mainThreadId + "  Trace Id: " + Tracer.id() + "----------");
        demoService.testAsy();
    }
}
@Component
public class DemoService {
    @Async("taskExecutor")
      public void testAsy(){
        String asyThreadName = Thread.currentThread().getName();
        long asyThreadId = Thread.currentThread().getId();
        System.out.println("======Async====");
        System.out.println("------We got asy thread: "+ asyThreadName + " - " +  asyThreadId + "  Trace Id: " + Tracer.id() + "----------");
    }
}

看看輸出臺的列印:

------We got main thread: main - 1  Trace Id: -3495543588231940494----------
======Async====
------We got asy thread: SimpleAsyncTaskExecutor-1 - 658  Trace Id: 3495543588231940494----------

最終,我們可以透過這一方式“找回”在@Async註解下跨執行緒傳遞而“丟失”的TraceId。

4. 其他方案對比

分散式追蹤系統從誕生之際到有實質性的突破,很大程度受到Google Dapper的影響,目前常見的分散式追蹤系統有Twitter的Zipkin、SkyWalking、阿里的EagleEye、PinPoint和美團的MTrace等,這些大多都是基於Google Dapper的設計思想,考慮到設計思路和架構特點,我們重點介紹Zipkin、SkyWalking和EagleEye的基本框架和跨執行緒解決方案(以下內容主要來源官網及作者總結,僅供參考,不構成技術建議)。

4.1 Zipkin

Zipkin是由Twitter公司貢獻開發的一款開源的分散式追蹤系統,官方提供有基於Finagle框架(Scala語言)的介面,而其他框架的介面由社群貢獻,目前可以支援Java、Python、Ruby和C#等主流開發語言和框架,其主要功能是聚集來自各個異構系統的實時監控資料。主要由4個核心元件構成,如下圖所示:

  • Collector:收集器元件,它主要用於處理從外部系統傳送過來的跟蹤資訊,將這些資訊轉換為Zipkin內部處理的Span格式,以支援後續的儲存、分析、展示等功能。
  • Storage:儲存元件,它主要對處理收集器接收到的跟蹤資訊,預設會將這些資訊儲存起來,同時支援修改儲存策略。
  • API:API元件,它主要用來提供外部訪問介面,比如給客戶端展示跟蹤資訊,或是外接系統訪問以實現監控等。
  • UI:UI元件,基於API元件實現的上層應用,透過UI元件使用者可以方便而有直觀地查詢和分析跟蹤資訊。

當使用者發起一次呼叫的時候,Zipkin的客戶端會在入口處先記錄這次請求相關的trace資訊,然後在呼叫鏈路上傳遞trace資訊並執行實際的業務流程,為防止追蹤系統傳送延遲與傳送失敗導致使用者系統的延遲與中斷,採用非同步的方式傳送trace資訊給Zipkin Collector,Zipkin Server在收到trace資訊後,將其儲存起來。隨後Zipkin的Web UI會透過 API訪問的方式從儲存中將trace資訊提取出來分析並展示。

最後,我們看看Zipkin的跨執行緒傳遞方案的優缺點:在單個執行緒的呼叫中Zipkin透過定義一個ThreadLocal\<TraceContext> local來完成在整個執行緒執行過程中獲取相同的Trace值,但是當新起一個執行緒的時候ThreadLocal就會失效,對於這種場景,Zipkin對於不提交執行緒池的場景提供InheritableThreadLocal\<TraceContext>來解決父子執行緒trace資訊傳遞丟失的問題。

而對於@Async的使用場景,Zipkin提供CurrentTraceContext類首先獲取父執行緒的trace資訊,然後將trace資訊複製到子執行緒來,其基本思路和上文MTrace的一致,但是需要程式碼開發,具有較強的侵入性。

4.2 SkyWalking

SkyWalking是Apache基金會下面的一個開源的應用程式效能監控系統,提供了一種簡便的方式來清晰地觀測雲原生和基於容器的分散式系統。具有支援多種語言探針;微核心+外掛的架構;儲存、叢集管理和使用外掛集合都可以自由選擇;支援告警;優秀的視覺化效果的特點。其主要由4個核心元件構成,如下圖所示:

  • 探針:基於不同的來源可能是不一樣的,但作用都是收集資料,將資料格式化為 SkyWalking適用的格式。
  • 平臺後端:支援資料聚合,資料分析以及驅動資料流從探針到使用者介面的流程。分析包括Skywalking原生追蹤和效能指標以及第三方來源,包括Istio、Envoy telemetry、Zipkin追蹤格式化等。
  • 儲存:透過開放的外掛化的介面存放SkyWalking資料。使用者可以選擇一個既有的儲存系統,如ElasticSearch、H2或MySQL叢集(Sharding-Sphere管理),也可以指定選擇實現一個儲存系統。
  • UI :一個基於介面高度定製化的Web系統,使用者可以視覺化檢視和管理SkyWalking資料。

SkyWalking的工作原理和Zipkin類似,但是相比較於Zipkin接入系統的方式,SkyWalking使用了外掛化+javaagent 的形式來實現:透過虛擬機器提供的用於修改程式碼的介面來動態加入打點的程式碼,如透過javaagent premain來修改Java 類,在系統執行時操作程式碼,讓使用者可以在不需要修改程式碼的情況下進行鏈路追蹤,對業務的程式碼無侵入性,同時使用位元組碼操作技術(Byte-Buddy)和AOP概念來實現攔截追蹤上下文的trace資訊,這樣一來每個使用者只需要根據自己的需用定義攔截點,就可以實現對一些模組實施分散式追蹤。

最後,我們總結一下SkyWalking的跨執行緒傳遞方案的優缺點:和主流的分散式追蹤系統類似,SkyWalking也是藉助ThreadLocal來儲存上下文資訊,當遇到跨執行緒傳輸時也面臨傳遞丟失的場景,針對這一問題SkyWalking會在父執行緒呼叫ContextManager.capture()將trace資訊儲存到一個ContextSnapshot的例項中並返回,ContextSnapshott則被附加到任務物件的特定屬性中,那麼當子執行緒處理任務物件的時會先取出ContextSnapshott物件,將其作為入參呼叫ContextManager.continued(contextSnapshot)來儲存到子執行緒中。

整體思路其實和主流的分散式追蹤系統的相似,SkyWalking目前只針對帶有@TraceCrossThread註解的Callable、Runnable和Supplier這三種介面的實現類進行增強攔截,透過使用xxxWrapper.of的包裝方式,避免開發者需要大的程式碼改動。

4.3 EagleEye

EagleEye阿里巴巴開源的應用效能監控工具,提供了多維度、實時、自動化的應用效能監控和分析能力。它可以幫助開發人員實時監控應用程式的效能指標、日誌、異常資訊等,並提供相應的效能分析和報告,幫助開發人員快速定位和解決問題。主要由以下5部分組成:

  • 代理:代理是鷹眼的資料採集元件,透過代理可以採集應用程式的效能指標、日誌、異常資訊等資料,並將其傳輸到鷹眼的儲存和分析元件中。代理支援多種協議,如HTTP、Dubbo、RocketMQ、Kafka等,能夠滿足不同場景下的資料採集需求。
  • 儲存:儲存是鷹眼的資料儲存元件,負責儲存代理採集的資料,並提供高可用、高效能、高可靠的資料儲存服務。儲存支援多種儲存引擎,如HBase、Elasticsearch、TiDB等,可以根據實際情況進行選擇和配置。
  • 分析:分析是鷹眼的資料分析元件,負責對代理採集的資料進行實時分析和處理,並生成相應的監控指標和效能報告。分析支援多種分析引擎,如Apache Flink、Apache Spark等,可以根據實際情況進行選擇和配置。
  • 視覺化:視覺化是鷹眼的資料展示元件,負責將分析產生的監控指標和效能報告以圖形化的方式展示出來,以便使用者能夠直觀地瞭解系統的執行狀態和效能指標。
  • 告警:告警是鷹眼的告警元件,負責根據使用者的配置進行異常檢測和告警,及時發現和處理系統的異常情況,防止系統出現故障。

不同於SkyWalking的開源社群,EagleEye重點面向阿里內部環境開發,針對海量實時監控的痛點,對底層的流計算、多維時序指標與互動體系等進行了大量最佳化,同時引入了時序檢測、根因分析、業務鏈路特徵等技術,將問題發現與定位由被動轉為主動。

EagleEye採用了StreamLib實時流式處理技術提升流計算效能,對採集的資料進行實時分析和處理,當監控一個電商網站時,可以實時地分析使用者訪問的日誌資料,並根據分析結果來最佳化網站的效能和使用者體驗;參考Apache Flink的Snapshot最佳化齊全度演算法來保證監控系統確定性;為了滿足不同的個性化需求,把一些可複用的邏輯變成了“積木塊”,讓使用者按照自己的需求,拼裝流計算的pipeline。

最後總結一下EagleEye的跨執行緒傳遞方案優缺點:EagleEye的解決思路和大多數分散式追蹤系統一致,都是透過javaagent的方式修改執行緒池的實現,進而子執行緒可以獲取到父執行緒到trace資訊,不同於SkyWalking這種開源系統採用的位元組碼增強,EagleEye大多數場景是內部使用,所以採用直接編碼的方式,維護和效能消耗方面也是非常有優勢的,但擴充套件性和開放性並不是非常友好。

5. 總結

本文意在從日常工作中一個很細微的問題出發,探究分析背後的設計思想和底層原因,主要涉及以下方面:

  • 抓住問題本質:在業務系統報警中抓住問題的核心程式碼並嘗試再次復現問題,找到真正出問題的模組。
  • 深入理解設計思想:在查閱公司中介軟體的產品文件的基礎上再繼續追根溯源,學習業內領先者最開始的分散式鏈路追蹤系統的設計思想和實現途徑。
  • 結合實際問題提出疑問:結合瞭解到的分散式鏈路追蹤系統的實現流程和設計思想,迴歸到一開始我們要解決的TraceId丟失情況分析是在什麼環節出現問題。
  • 閱讀原始碼找到底層邏輯:從@Async註解、SimpleAsyncTaskExecutor和ThreadLocal類原始碼進行層層追蹤,分析底層真正的實現邏輯和特點。
  • 對比分析找到解決方案:分析為什麼Mtrace的跨執行緒傳遞方案“失效”了,找到原因提供解決方案並總結其他分散式追蹤系統。

從本文可以看出,中介軟體的出現不僅為我們維護系統的穩定提供有力的支援,還已經為使用中可能發生的問題提供了更高效的解決方案,作為開發人員在享受這一極大便利的同時,還是要沉下心來認真思考其中的實現邏輯和使用場景,如果只是一味的低頭使用不求甚解,那麼在一些特定問題上往往會顯得十分被動,無法發揮中介軟體真正的價值,甚至在沒有中介軟體支撐時無法高效的解決問題。

本文作者

李禎,美團到店事業群/充電寶業務部工程師。

參考資料

| 在美團公眾號選單欄對話方塊回覆【2022年貨】、【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可檢視美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至tech@meituan.com申請授權。

相關文章