分散式鏈路追蹤框架的基本實現原理

痴者工良發表於2021-01-24


本系列共有三篇:

.NET Core 中的日誌與分散式鏈路追蹤

分散式鏈路追蹤框架的基本實現原理(當前)

開源一個簡單的相容 Jaeger 的框架

檸檬(Lemon丶)大佬在一月份開業了檸檬研究院,研究院指導成員學習分散式和雲原生技術,本月課題是分散式鏈路追蹤,學習 Dapper 論文、Jaeger 的使用,以及完成一個相容 Jaeger 的鏈路追蹤框架。

筆者將作業分為三部分,三篇文章加上實現程式碼,本文是第二篇。

分散式追蹤

什麼是分散式追蹤

分散式系統

當我們使用 Google 或者 百度搜尋時,查詢服務會將關鍵字分發到多臺查詢伺服器,每臺伺服器在自己的索引範圍內進行搜尋,搜尋引擎可以在短時間內獲得大量準確的搜尋結果;同時,根據關鍵字,廣告子系統會推送合適的相關廣告,還會從競價排名子系統獲得網站權重。通常一個搜尋可能需要成千上萬臺伺服器參與,需要經過許多不同的系統提供服務。

多臺計算機通過網路組成了一個龐大的系統,這個系統即是分散式系統。

在微服務或者雲原生開發中,一般認為分散式系統是通過各種中介軟體/服務網格連線的,這些中介軟體提供了共享資源、功能(API等)、檔案等,使得整個網路可以當作一臺計算機進行工作。

分散式追蹤

在分散式系統中,使用者的一個請求會被分發到多個子系統中,被不同的服務處理,最後將結果返回給使用者。使用者發出請求和獲得結果這段時間是一個請求週期。

當我們購物時,只需要一個很簡單的過程:

獲取優惠劵 -> 下單 -> 付款 -> 等待收貨

然而在後臺系統中,每一個環節都需要經過多個子系統進行協作,並且有嚴格的流程。例如在下單時,需要檢查是否有優惠卷、優惠劵能不能用於當前商品、當前訂單是否符合使用優惠劵條件等。

下圖是一個使用者請求後,系統處理請求的流程。

淘寶-使用者請求

【圖片來源:鷹眼下的淘寶分散式呼叫跟蹤系統介紹

圖中出現了很多箭頭,這些箭頭指向了下一步要流經的服務/子系統,這些箭頭組成了鏈路網路。

在一個複雜的分散式系統中,任何子系統出現效能不佳的情況,都會影響整個請求週期。根據上圖,我們設想:

1.系統中有可能每天都在增加新服務或刪除舊服務,也可能進行升級,當系統出現錯誤,我們如何定位問題?

2.當使用者請求時,響應緩慢,怎麼定位問題?

3.服務可能由不同的程式語言開發,1、2 定位問題的方式,是否適合所有程式語言?

分散式追蹤有什麼用呢

隨著微服務和雲原生開發的興起,越來越多應用基於分散式進行開發,但是大型應用拆分為微服務後,服務之間的依賴和呼叫變得越來越複雜,這些服務是不同團隊、使用不同語言開發的,部署在不同機器上,他們之間提供的介面可能不同(gRPC、Restful api等)。

為了維護這些服務,軟體領域出現了 Observability 思想,在這個思想中,對微服務的維護分為三個部分:

  • 度量(Metrics):用於監控和報警;
  • 分散式追蹤(Tracing):用於記錄系統中所有的跟蹤資訊;
  • 日誌(Logging):記錄每個服務只能中離散的資訊;

這三部分並不是獨立開來的,例如 Metrics 可以監控 Tracing 、Logging 服務是否正常執行。Tacing 和 Metrics 服務在執行過程中會產生日誌。

APM

深入瞭解請戳爆你的螢幕:https://peter.bourgon.org/blog/2017/02/21/metrics-tracing-and-logging.html

近年來,出現了 APM 系統,APM 稱為 應用程式效能管理系統,可以進行 軟體效能監視和效能分析。APM 是一種 Metrics,但是現在有融合 Tracing 的趨勢。

迴歸正題,分散式追蹤系統(Tracing)有什麼用呢?這裡可以以 Jaeger 舉例,它可以:

  • 分散式跟蹤資訊傳遞
  • 分散式事務監控
  • 服務依賴性分析
  • 展示跨程式呼叫鏈
  • 定位問題
  • 效能優化

Jaeger 需要結合後端進行結果分析,jaeger 有個 Jaeger UI,但是功能並不多,因此還需要依賴 Metrics 框架從結果呈現中視覺化,以及自定義監控、告警規則,所以很自然 Metrics 可能會把 Tracing 的事情也做了。

Dapper

Dapper 是 Google 內部使用的分散式鏈路追蹤系統,並沒有開源,但是 Google 釋出了一篇 《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》 論文,這篇論文講述了分散式鏈路追蹤的理論和 Dapper 的設計思想。

有很多鏈路追蹤系統是基於 Dapper 論文的,例如淘寶的鷹眼、Twitter 的 Zipkin、Uber 開源的 Jaeger,分散式鏈路追蹤標準 OpenTracing 等。

論文地址:

https://static.googleusercontent.com/media/research.google.com/en//archive/papers/dapper-2010-1.pdf

譯文:

http://bigbully.github.io/Dapper-translation/

不能訪問 github.io 的話,可以 clone 倉庫去看 https://github.com/bigbully/Dapper-translation/tree/gh-pages

Dapper 使用者介面:

dapper-使用者介面

分散式追蹤系統的實現

下圖是一個由使用者 X 請求發起的,穿過多個服務的分散式系統,A、B、C、D、E 表示不同的子系統或處理過程。

Dapper

在這個圖中, A 是前端,B、C 是中間層、D、E 是 C 的後端。這些子系統通過 rpc 協議連線,例如 gRPC。

一個簡單實用的分散式鏈路追蹤系統的實現,就是對伺服器上每一次請求以及響應收集跟蹤識別符號(message identifiers)和時間戳(timestamped events)。

分散式服務的跟蹤系統需要記錄在一次特定的請求後系統中完成的所有工作的資訊。使用者請求可以是並行的,同一時間可能有大量的動作要處理,一個請求也會經過系統中的多個服務,系統中時時刻刻都在產生各種跟蹤資訊,必須將一個請求在不同服務中產生的追蹤資訊關聯起來。

為了將所有記錄條目與一個給定的發起者X關聯上並記錄所有資訊,現在有兩種解決方案,黑盒(black-box)和基於標註(annotation-based)的監控方案。

黑盒方案:

假定需要跟蹤的除了上述資訊之外沒有額外的資訊,這樣使用統計迴歸技術來推斷兩者之間的關係。

基於標註的方案:

依賴於應用程式或中介軟體明確地標記一個全域性ID,從而連線每一條記錄和發起者的請求。

優缺點:

雖然黑盒方案比標註方案更輕便,他們需要更多的資料,以獲得足夠的精度,因為他們依賴於統計推論。基於標註的方案最主要的缺點是,很明顯,需要程式碼植入。在我們的生產環境中,因為所有的應用程式都使用相同的執行緒模型,控制流和 RPC 系統,我們發現,可以把程式碼植入限制在一個很小的通用元件庫中,從而實現了監測系統的應用對開發人員是有效地透明。

Dapper 基於標註的方案,接下來我們將介紹 Dapper 中的一些概念知識。

跟蹤樹和 span

從形式上看,Dapper 跟蹤模型使用的是樹形結構,Span 以及 Annotation。

在前面的圖片中,我們可以看到,整個請求網路是一個樹形結構,使用者請求是樹的根節點。在 Dapper 的跟蹤樹結構中,樹節點是整個架構的基本單元。

span 稱為跨度,一個節點在收到請求以及完成請求的過程是一個 span,span 記錄了在這個過程中產生的各種資訊。每個節點處理每個請求時都會生成一個獨一無二的的 span id,當 A -> C -> D 時,多個連續的 span 會產生父子關係,那麼一個 span 除了儲存自己的 span id,也需要關聯父、子 span id。生成 span id 必須是高效能的,並且能夠明確表示時間順序,這點在後面介紹 Jaeger 時會介紹。

Annotation 譯為註釋,在一個 span 中,可以為 span 新增更多的跟蹤細節,這些額外的資訊可以幫助我們監控系統的行為或者幫助除錯問題。Annotation 可以新增任意內容。

dapper-span

到此為止,簡單介紹了一些分散式追蹤以及 Dapper 的知識,但是這些不足以嚴謹的說明分散式追蹤的知識和概念,建議讀者有空時閱讀 Dapper 論文。

要實現 Dapper,還需要程式碼埋點、取樣、跟蹤收集等,這裡就不再細談了,後面會介紹到,讀者也可以看看論文。

Jaeger 和 OpenTracing

OpenTracing

OpenTracing 是與分散式系統無關的API和用於分散式跟蹤的工具,它不僅提供了統一標準的 API,還致力於各種工具,幫助開發者或服務提供者開發程式。

OpenTracing 為標準 API 提供了接入 SDK,支援這些語言:Go, JavaScript, Java, Python, Ruby, PHP, Objective-C, C++, C#。

當然,我們也可以自行根據通訊協議,自己封裝 SDK。

讀者可以參考 OpenTracing 文件:https://opentracing.io/docs/

接下來我們要一點點弄清楚 OpenTracing 中的一些概念和知識點。由於 jaeger 是 OpenTracing 最好的實現,因此後面講 Jaeger 就是 Opentracing ,不需要將兩者嚴格區分。

Jaeger 結構

首先是 JAEGER 部分,這部分是程式碼埋點等流程,在分散式系統中處理,當一個跟蹤完成後,通過 jaeger-agent 將資料推送到 jaeger-collector。jaeger-collector 負責處理四面八方推送來的跟蹤資訊,然後儲存到後端,可以儲存到 ES、資料庫等。Jaeger-UI 可以將讓使用者在介面上看到這些被分析出來的跟蹤資訊。

OpenTracing API 被封裝成程式語言的 SDK(jaeger-client),例如在 C# 中是 .dll ,Java 是 .jar,應用程式程式碼通過呼叫 API 實現程式碼埋點。

jaeger-Agent 是一個監聽在 UDP 埠上接收 span 資料的網路守護程式,它會將資料批量傳送給 collector。

【圖片來源:https://segmentfault.com/a/1190000011636957

OpenTracing 資料模型

在 OpenTracing 中,跟蹤資訊被分為 Trace、Span 兩個核心,它們按照一定的結構儲存跟蹤資訊,所以它們是 OpenTracing 中資料模型的核心。

Trace 是一次完整的跟蹤,Trace 由多個 Span 組成。下圖是一個 Trace 示例,由 8 個 Span 組成。

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                                       ↑
                         (Span G `FollowsFrom` Span F)

Tracing:

a Trace can be thought of as a directed acyclic graph (DAG) of Spans

有點難翻譯,大概意思是 Trace 是多個 Span 組成的有向非迴圈圖。

在上面的示例中,一個 Trace 經過了 8 個服務,A -> C -> F -> G 是有嚴格順序的,但是從時間上來看,B 、C 是可以並行的。為了準確表示這些 Span 在時間上的關係,我們可以用下圖表示:

––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time

 [Span A···················································]
   [Span B··············································]
      [Span D··········································]
    [Span C········································]
         [Span E·······]        [Span F··] [Span G··] [Span H··]

有個要注意的地方, 並不是 A -> C -> F 表示 A 執行結束,然後 C 開始執行,而是 A 執行過程中,依賴 C,而 C 依賴 F。因此,當 A 依賴 C 的過程完成後,最終回到 A 繼續執行。所以上圖中 A 的跨度最大。

Span 格式

要深入學習,就必須先了解 Span,請讀者認真對照下面的圖片和 Json:

OpenTracing-Span

json 地址: https://github.com/whuanle/DistributedTracing/issues/1

後續將圍繞這張圖片和 Json 來舉例講述 Span 相關知識。

Trace

一個簡化的 Trace 如下:

注:不同程式語言的欄位名稱有所差異,gRPC 和 Restful API 的格式也有所差異。

            "traceID": "790e003e22209ca4",
            "spans":[...],
            "processes":{...}

前面說到,在 OpenTracing 中,Trace 是一個有向非迴圈圖,那麼 Trace 必定有且只有一個起點。

這個起點會建立一個 Trace 物件,這個物件一開始初始化了 trace id 和 process,trace id 是一個 32 個長度的字串組成,它是一個時間戳,而 process 是起點程式所在主機的資訊。

下面筆者來說一些一下 trace id 是怎麼生成的。trace id 是 32個字串組成,而實際上只使用了 16 個,因此,下面請以 16 個字元長度去理解這個過程。

首先獲取當前時間戳,例如獲得 1611467737781059 共 16 個數字,單位是微秒,表示時間 2021-01-24 13:55:37,秒以下的單位這裡就不給出了,明白表示時間就行。

在 C# 中,將當前時間轉為這種時間戳的程式碼:

        public static long ToTimestamp(DateTime dateTime)
        {
            DateTime dt1970 = new DateTime(1970, 1, 1, 0, 0, 0, 0);
            return (dateTime.Ticks - dt1970.Ticks)/10;
        }

// 結果:1611467737781059

如果我們直接使用 Guid 生成或者 string 儲存,都會消耗一些效能和記憶體,而使用 long,剛剛好可以表示時間戳,還可以節約記憶體。

獲得這個時間戳後,要傳輸到 Jaeger Collector,要轉為 byet 資料,為什麼要這樣不太清楚,按照要求傳輸就是了。

將 long 轉為一個 byte 陣列:

            var bytes = BitConverter.GetBytes(time);
			// 大小端
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(bytes);
            }

long 佔 8 個位元組,每個 byte 值如下:

0x00 0x05 0xb9 0x9f 0x12 0x13 0xd3 0x43

然後傳輸到 Jaeger Collector 中,那麼獲得的是一串二進位制,怎麼表示為字串的 trace id?

可以先還原成 long,然後將 long 輸出為 16 進位制的字串:

轉為字串(這是C#):

            Console.WriteLine(time.ToString("x016"));

結果:

0005b99f1213d343

Span id 也是這樣轉的,每個 id 因為與時間戳相關,所以在時間上是唯一的,生成的字串也是唯一的。

這就是 trace 中的 trace id 了,而 trace process 是發起請求的機器的資訊,用 Key-Value 的形式儲存資訊,其格式如下:

                        {
                            "key": "hostname",
                            "type": "string",
                            "value": "Your-PC"
                        },
                        {
                            "key": "ip",
                            "type": "string",
                            "value": "172.6.6.6"
                        },
                        {
                            "key": "jaeger.version",
                            "type": "string",
                            "value": "CSharp-0.4.2.0"
                        }

Ttace 中的 trace id 和 process 這裡說完了,接下來說 trace 的 span。

Span

Span 由以下資訊組成:

  • An operation name:操作名稱,必有;
  • A start timestamp:開始時間戳,必有;
  • A finish timestamp:結束時間戳,必有;
  • Span Tags.:Key-Value 形式表示請求的標籤,可選;
  • Span Logs:Key-Value 形式表示,記錄簡單的、結構化的日誌,必須是字串型別,可選;
  • SpanContext :跨度上下文,在不同的 span 中傳遞,建立關係;
  • References t:引用的其它 Span;

span 之間如果是父子關係,則可以使用 SpanContext 繫結這種關係。父子關係有 ChildOfFollowsFrom 兩種表示,ChildOf 表示 父 Span 在一定程度上依賴子 Span,而 FollowsFrom 表示父 Span 完全不依賴其子Span 的結果。

一個 Span 的簡化資訊如下(不用理會欄位名稱大小寫):

{
                    "traceID": "790e003e22209ca4",
                    "spanID": "4b73f8e8e77fe9dc",
                    "flags": 1,
                    "operationName": "print-hello",
                    "references": [],
                    "startTime": 1611318628515966,
                    "duration": 259,
                    "tags": [
                        {
                            "key": "internal.span.format",
                            "type": "string",
                            "value": "proto"
                        }
                    ],
                    "logs": [
                        {
                            "timestamp": 1611318628516206,
                            "fields": [
                                {
                                    "key": "event",
                                    "type": "string",
                                    "value": "WriteLine"
                                }
                            ]
                        }
                    ]
}

OpenTracing API

在 OpenTracing API 中,有三個主要物件:

  • Tracer
  • Span
  • SpanContext

Tracer可以建立Spans並瞭解如何跨流程邊界對它們的後設資料進行Inject(序列化)和Extract(反序列化)。它具有以下功能:

  • 開始一個新的 Span
  • Inject一個SpanContext到一個載體
  • Extract一個SpanContext從載體

由起點程式建立一個 Tracer,然後啟動程式發起請求,每個動作產生一個 Span,如果有父子關係,Tracer 可以將它們關聯起來。當請求完成後, Tracer 將跟蹤資訊推送到 Jaeger-Collector中。

詳細請查閱文件:https://opentracing.io/docs/overview/tracers/

Jaeger-Tracer

SpanContext 是在不同的 Span 中傳遞資訊的,SpanContext 包含了簡單的 Trace id、Span id 等資訊。

我們繼續以下圖作為示例講解。

A 建立一個 Tracer,然後建立一個 Span,代表自己 (A),再建立兩個 Span,分別代表 B、C,然後通過 SpanContext 傳遞一些資訊到 B、C;B 和 C 收到 A 的訊息後,也建立一個 Tracer ,用來 Tracer.extract(...) ;其中 B 沒有後續,可以直接返回結果;而 C 的 Tracer 繼續建立兩個 Span,往 D、E 傳遞 SpanContext。

Dapper

這個過程比較複雜,筆者講不好,建議讀者參與 OpenTracing 的官方文件。

詳細的 OpenTracing API,可以通過程式語言編寫相應服務時,去學習各種 API 的使用。

.NET Core 筆者寫了一篇,讀者有興趣可以閱讀:【.NET Core 中的日誌與分散式鏈路追蹤】https://www.cnblogs.com/whuanle/p/14256858.html

相關文章