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


本系列共有三篇:

.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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章