Node.js 應用全鏈路追蹤技術——[全鏈路資訊獲取]

vivo網際網路技術發表於2021-09-06

全鏈路追蹤技術的兩個核心要素分別是 全鏈路資訊獲取 和 全鏈路資訊儲存展示

Node.js 應用也不例外,這裡將分成兩篇文章進行介紹;第一篇介紹 Node.js 應用全鏈路資訊獲取, 第二篇介紹 Node.js 應用全鏈路資訊儲存展示

一、Node.js 應用全鏈路追蹤系統

目前行業內, 不考慮 Serverless 的情況下,主流的 Node.js 架構設計主要有以下兩種方案:

  • 通用架構:只做 ssr 和 bff,不做伺服器和微服務;

  • 全場景架構:包含 ssr、bff、伺服器、微服務。

上述兩種方案對應的架構說明圖如下圖所示:

在上述兩種通用架構中,nodejs 都會面臨一個問題,那就是:

在請求鏈路越來越長,呼叫服務越來越多,其中還包含各種微服務呼叫的情況下,出現了以下訴求:

  • 如何在請求發生異常時快速定義問題所在;

  • 如何在請求響應慢的時候快速找出慢的原因;

  • 如何通過日誌檔案快速定位問題的根本原因。

我們要解決上述訴求,就需要有一種技術,將每個請求的關鍵資訊聚合起來,並且將所有請求鏈路串聯起來。讓我們可以知道一個請求中包含了幾次服務、微服務請求的呼叫,某次服務、微服務呼叫在哪個請求的上下文。

這種技術,就是Node.js應用全鏈路追蹤。它是 Node.js 在涉及到複雜服務端業務場景中,必不可少的技術保障。

綜上,我們需要Node.js應用全鏈路追蹤,說完為什麼需要後,下面將介紹如何做Node.js應用的全鏈路資訊獲取。

二、全鏈路資訊獲取

全鏈路資訊獲取,是全鏈路追蹤技術中最重要的一環。只有打通了全鏈路資訊獲取,才會有後續的儲存展示流程。

對於多執行緒語言如 Java 、 Python 來說,做全鏈路資訊獲取有執行緒上下文如 ThreadLocal 這種利器相助。而對於Node.js來說,由於單執行緒和基於IO回撥的方式來完成非同步操作,所以在全鏈路資訊獲取上存在天然獲取難度大的問題。那麼如何解決這個問題呢?

三、業界方案

由於 Node.js 單執行緒,非阻塞 IO 的設計思想。在全鏈路資訊獲取上,到目前為止,主要有以下 4 種方案:

  • domain: node api;

  • zone.js: Angular 社群產物;

  • 顯式傳遞:手動傳遞、中介軟體掛載;

  • Async Hooks:node api;

而上述 4 個方案中,  domain 由於存在嚴重的記憶體洩漏,已經被廢棄了;zone.js 實現方式非常暴力、API比較晦澀、最關鍵的缺點是 monkey patch 只能 mock api ,不能 mock language;顯式傳遞又過於繁瑣和具有侵入性;綜合比較下來,效果最好的方案就是第四種方案,這種方案有如下優點:

  • node 8.x 新加的一個核心模組,Node 官方維護者也在使用,不存在記憶體洩漏;

  • 非常適合實現隱式的鏈路跟蹤,入侵小,目前隱式跟蹤的最優解;

  • 提供了 API 來追蹤 node 中非同步資源的生命週期;

  • 藉助 async_hook 實現上下文的關聯關係;

優點說完了,下面我們就來介紹如何通過 Async Hooks 來獲取全鏈路資訊。

四、Async Hooks【非同步鉤子】

4.1  Async Hooks 概念

Async Hooks 是 Node.js v8.x 版本新增加的一個核心模組,它提供了 API 用來追蹤 Node.js 中非同步資源的生命週期,可幫助我們正確追蹤非同步呼叫的處理邏輯及關係。在程式碼中,只需要寫 import asyncHook from 'async_hooks' 即可引入 async_hooks 模組。

一句話概括:async_hooks 用來追蹤 Node.js 中非同步資源的生命週期。

目前 Node.js 的穩定版本是 v14.17.0 。我們通過一張圖看下 Async Hooks 不同版本的 api 差異。如下圖所示:

從圖中可以看到該 api 變動較大。這是因為從 8 版本到 14 版本,async_hooks 依舊還是 Stability: 1 - Experimental

**Stability: 1 - Experimental **:該特性仍處於開發中,且未來改變時不做向後相容,甚至可能被移除。不建議在生產環境中使用該特性。

但是沒關係,要相信官方團隊,這裡我們的全鏈路資訊獲取方案是基於 Node v9.x 版本 api 實現的。對於 Async Hooks api 介紹和基本使用, 大家可以閱讀官方文件,下文會闡述對核心知識的理解。

下面我們將系統介紹基於 Async Hooks 的全鏈路資訊獲取方案的設計和實現,下文統稱為 zone-context 。

4.2  理解 async_hooks 核心知識

在介紹 zone-context 之前,要對 async_hooks 的核心知識有正確的理解,這裡做了一個總結,有如下6點:

  • 每一個函式(不論非同步還是同步)都會提供一個上下文, 我們稱之為 async scope ,這個認知對理解 async_hooks 非常重要;

  • 每一個 async scope 中都有一個 asyncId ,它是當前 async scope 的標誌,同一個的 async scope 中 asyncId 必然相同,每個非同步資源在建立時, asyncId 自動遞增,全域性唯一;

  • 每一個 async scope 中都有一個 triggerAsyncId ,用來表示當前函式是由哪個 async scope 觸發生成的;

  • 通過 asyncId 和 triggerAsyncId 我們可以追蹤整個非同步的呼叫關係及鏈路,這個是全鏈路追蹤的核心;

  • 通過 async_hooks.createHook 函式來註冊關於每個非同步資源在生命週期中發生的 init 等相關事件的監聽函式;

  • 同一個 async scope 可能會被呼叫及執行多次,不管執行多少次,其 asyncId 必然相同,通過監聽函式,我們很方便追蹤其執行的次數、時間以及上下文關係。

上述6點知識對於理解 async_hooks 是非常重要的。正是因為這些特性,才使得 async_hooks 能夠優秀的完成Node.js 應用全鏈路資訊獲取。

到這裡,下面就要介紹 zone-context 的設計和實現了,請和我一起往下看。

五、zone-context

5.1  架構設計

整體架構設計如下圖所示:

核心邏輯如下:非同步資源(呼叫)建立後,會被 async_hooks 監聽到。監聽到後,對獲取到的非同步資源資訊進行處理加工,整合成需要的資料結構,整合後,將資料儲存到 invoke tree 中。在非同步資源結束時,觸發 gc 操作,對 invoke tree 中不再有用的資料進行刪除回收。

從上述核心邏輯中,我們可以知道,此架構設計需要實現以下三個功能:

  • 非同步資源(呼叫)監聽

  • invoke tree

  • gc

下面開始逐個介紹上述三個功能的實現。

5.2  非同步資源(呼叫)監聽

如何做到監聽非同步呼叫呢?

這裡用到了 async_hooks (追蹤 Node.js 非同步資源的生命週期)程式碼實現如下:

asyncHook
  .createHook({
    init(asyncId, type, triggerAsyncId) {
      // 非同步資源建立(呼叫)時觸發該事件
    },
  })
  .enable()

是不是發現此功能實現非常簡單,是的哦,就可以對所有非同步操作進行追蹤了。

在理解 async_hooks 核心知識中,我們提到了通過 asyncId 和 triggerAsyncId 可以追蹤整個非同步的呼叫關係及鏈路。現在大家看 init 中的引數,會發現, asyncId 和triggerAsyncId 都存在,而且是隱式傳遞,不需要手動傳入。這樣,我們在每次非同步呼叫時,都能在 init 事件中,拿到這兩個值。invoke tree 功能的實現,離不開這兩個引數。

介紹完非同步呼叫監聽,下面將介紹 invoke tree 的實現。

5.3 invoke tree 設計和非同步呼叫監聽結合

5.3.1 設計

invoke tree 整體設計思路如下圖所示:

具體程式碼如下:

interface ITree {  [key: string]: {    // 呼叫鏈路上第一個非同步資源asyncId    rootId: number    // 非同步資源的triggerAsyncId    pid: number    // 非同步資源中所包含的非同步資源asyncId    children: Array<number>  }} const invokeTree: ITree = {}

建立一個大的物件 invokeTree, 每一個屬性代表一個非同步資源的完整呼叫鏈路。屬性的key和value代表含義如下:

  • 屬性的 key 是代表這個非同步資源的 asyncId。

  • 屬性的 value 是代表這個非同步資源經過的所有鏈路資訊聚合物件,該物件中的各屬性含義請看上面程式碼中的註釋進行理解。

通過這種設計,就能拿到任何一個非同步資源在整個請求鏈路中的關鍵資訊。收集根節點上下文。

5.3.2 和非同步呼叫監聽結合

雖然 invoke tree 設計好了。但是如何在 非同步呼叫監聽的 init 事件中,將 asyncId 、 triggerAsyncId 和 invokeTree 關聯起來呢?

程式碼如下:

asyncHook
  .createHook({
    init(asyncId, type, triggerAsyncId) {
      // 尋找父節點
      const parent = invokeTree[triggerAsyncId]
      if (parent) {
        invokeTree[asyncId] = {
          pid: triggerAsyncId,
          rootId: parent.rootId,
          children: [],
        }
        // 將當前節點asyncId值儲存到父節點的children陣列中
        invokeTree[triggerAsyncId].children.push(asyncId)
      }
    }
  })
  .enable()

大家看上面程式碼,整個程式碼大致有以下幾個步驟:

  1. 當監聽到非同步呼叫的時候,會先去 invokeTree 物件中查詢是否含有 key 為 triggerAsyncId 的屬性;

  2. 有的話,說明該非同步呼叫在該追蹤鏈路中,則進行儲存操作,將 asyncId 當成 key , 屬性值是一個物件,包含三個屬性,分別是 pid、rootId、children , 具體含義上文已說過;

  3. 沒有的話,說明該非同步呼叫不在該追蹤鏈路中。則不進行任何操作,如把資料存入 invokeTree 物件;

  4. 將當前非同步呼叫 asyncId 存入到 invokeTree 中 key 為 triggerAsyncId 的 children 屬性中。

至此,invoke tree 的設計、和非同步呼叫監聽如何結合,已經介紹完了。下面將介紹 gc 功能的設計和實現。

5.4 gc

5.4.1 目的

我們知道,非同步呼叫次數是非常多的,如果不做 gc 操作,那麼 invoke tree 會越來越大,node應用的記憶體會被這些資料慢慢佔滿,所以需要對 invoke tree 進行垃圾回收。

5.4.2 設計

gc 的設計思想主要如下:當非同步資源結束的時候,觸發垃圾回收,尋找此非同步資源觸發的所有非同步資源,然後按照此邏輯遞迴查詢,直到找出所有可回收的非同步資源。

話不多說,直接上程式碼, gc 程式碼如下:

interface IRoot {
  [key: string]: Object
}
 
// 收集根節點上下文
const root: IRoot = {}
 
function gc(rootId: number) {
  if (!root[rootId]) {
    return
  }
 
  // 遞迴收集所有節點id
  const collectionAllNodeId = (rootId: number) => {
    const {children} = invokeTree[rootId]
    let allNodeId = [...children]
    for (let id of children) {
      // 去重
      allNodeId = [...allNodeId, ...collectionAllNodeId(id)]
    }
    return allNodeId
  }
 
  const allNodes = collectionAllNodeId(rootId)
 
  for (let id of allNodes) {
    delete invokeTree[id]
  }
 
  delete invokeTree[rootId]
  delete root[rootId]
}

gc 核心邏輯:用 collectionAllNodeId 遞迴查詢所有可回收的非同步資源( id )。然後再刪除 invokeTree 中以這些 id 為 key 的屬性。最後刪除根節點。

大家看到了宣告物件 root ,這個是什麼呢?

root 其實是我們對某個非同步呼叫進行監聽時,設定的一個根節點物件,這個節點物件可以手動傳入一些鏈路資訊,這樣可以為全鏈路追蹤增加其他追蹤資訊,如錯誤資訊、耗時時間等。

5.5 萬事具備,只欠東風

我們的非同步事件監聽設計好了, invoke tree 設計好了,gc 也設計好了。那麼如何將他們串聯起來呢?比如我們要監聽某一個非同步資源,那麼我們要怎樣才能把 invoke tree 和非同步資源結合起來呢?

這裡需要三個函式來完成結合,分別是 **ZoneContext **、 setZoneContext 、 getZoneContext。下面來一一介紹下這三個函式:

5.5.1 ZoneContext

這是一個工廠函式,用來建立非同步資源例項的,程式碼如下所示:

// 工廠函式
async function ZoneContext(fn: Function) {
  // 初始化非同步資源例項
  const asyncResource = new asyncHook.AsyncResource('ZoneContext')
  let rootId = -1
  return asyncResource.runInAsyncScope(async () => {
    try {
      rootId = asyncHook.executionAsyncId()
      // 儲存 rootId 上下文
      root[rootId] = {}
      // 初始化 invokeTree
      invokeTree[rootId] = {
        pid: -1, // rootId 的 triggerAsyncId 預設是 -1
        rootId,
        children: [],
      }
      // 執行非同步呼叫
      await fn()
    } finally {
      gc(rootId)
    }
  })
}

大家會發現,在此函式中,有這樣一行程式碼:

const asyncResource = new asyncHook.AsyncResource('ZoneContext') 

這行程式碼是什麼含義呢?

它是指我們建立了一個名為 ZoneContext 的非同步資源例項,可以通過該例項的屬性方法來更加精細的控制非同步資源。

執行 asyncResource.runInAsyncScope 方法有什麼用處呢?

呼叫該例項的 runInAsyncScope方法,在runInAsyncScope 方法中包裹要傳入的非同步呼叫。可以保證在這個資源( fn )的非同步作用域下,所執行的程式碼都是可追蹤到我們設定的 invokeTree 中,達到更加精細控制非同步呼叫的目的。在執行完後,進行gc呼叫,完成記憶體回收。

5.5.2 setZoneContext

用來給非同步呼叫設定額外的跟蹤資訊。程式碼如下:

function setZoneContext(obj: Object) {
  const curId = asyncHook.executionAsyncId()
  let root = findRootVal(curId)
  Object.assign(root, obj)
}

通過 Object.assign(root, obj) 將傳入的 obj 賦值給 root 物件中, key 為 curId 的屬性。這樣就可以給我們想跟蹤的非同步呼叫設定想要跟蹤的資訊。

5.5.3 getZoneContext

用來拿到非同步調的 rootId 的屬性值。程式碼如下:

function findRootVal(asyncId: number) {
  const node = invokeTree[asyncId]
  return node ? root[node.rootId] : null
}
function getZoneContext() {
  const curId = asyncHook.executionAsyncId()
  return findRootVal(curId)
}

通過給 findRootVal 函式傳入 asyncId 來拿到 root 物件中 key 為 rootId 的屬性值。這樣就可以拿到當初我們設定的想要跟蹤的資訊了,完成一個閉環。

至此,我們將 Node.js應用全鏈路資訊獲取的核心設計和實現闡述完了。邏輯上有點抽象,需要多去思考和理解,才能對全鏈路追蹤資訊獲取有一個更加深刻的掌握。

最後,我們使用本次全鏈路追蹤的設計實現來展示一個追蹤 demo 。

5.6 使用 zone-context

5.6.1 確定非同步呼叫巢狀關係

為了更好的闡述非同步呼叫巢狀關係,這裡進行了簡化,沒有輸出 invoke tree 。例子程式碼如下:

// 對非同步呼叫A函式進行追蹤
ZoneContext(async () => {
  await A()
})
 
// 非同步呼叫A函式中執行非同步呼叫B函式
async function A() {
  // 輸出 A 函式的 asyncId
  fs.writeSync(1, `A 函式的 asyncId -> ${asyncHook.executionAsyncId()}\n`)
  Promise.resolve().then(() => {
    // 輸出 A 函式中執行非同步呼叫時的 asyncId
    fs.writeSync(1, `A 執行非同步 promiseC 時 asyncId 為 -> ${asyncHook.executionAsyncId()}\n`)
    B()
  })
}
 
// 非同步呼叫B函式中執行非同步呼叫C函式
async function B() {
  // 輸出 B 函式的 asyncId
  fs.writeSync(1, `B 函式的 asyncId -> ${asyncHook.executionAsyncId()}\n`)
  Promise.resolve().then(() => {
    // 輸出 B 函式中執行非同步呼叫時的 asyncId
    fs.writeSync(1, `B 執行非同步 promiseC 時 asyncId 為 -> ${asyncHook.executionAsyncId()}\n`)
    C()
  })
}
 
// 非同步呼叫C函式
function C() {
  const obj = getZoneContext()
  // 輸出 C 函式的 asyncId
  fs.writeSync(1, `C 函式的 asyncId -> ${asyncHook.executionAsyncId()}\n`)
  Promise.resolve().then(() => {
    // 輸出 C 函式中執行非同步呼叫時的 asyncId
    fs.writeSync(1, `C 執行非同步 promiseC 時 asyncId 為 -> ${asyncHook.executionAsyncId()}\n`)
  })
}

輸出結果為:

A 函式的 asyncId -> 3
A 執行非同步 promiseA 時 asyncId 為 -> 8
B 函式的 asyncId -> 8
B 執行非同步 promiseB 時 asyncId 為 -> 13
C 函式的 asyncId -> 13
C 執行非同步 promiseC 時 asyncId 為 -> 16

只看輸出結果就可以推出以下資訊:

  • A 函式執行非同步呼叫後, asyncId 為 8 ,而 B 函式的 asyncId 是 8 ,這說明, B 函式是被 A 函式 呼叫;

  • B 函式執行非同步呼叫後, asyncId 為 13 ,而 C 函式的 asyncId 是 13 ,這說明, C 函式是被 B 函式 呼叫;

  • C 函式執行非同步呼叫後, asyncId 為 16 , 不再有其他函式的 asyncId 是 16 ,這說明, C 函式中沒有呼叫其他函式;

  • 綜合上面三點,可以知道,此鏈路的非同步呼叫巢狀關係為:A —> B -> C;

至此,我們可以清晰快速的知道誰被誰呼叫,誰又呼叫了誰。

5.6.2 額外設定追蹤資訊

在上面例子程式碼的基礎下,增加以下程式碼:

ZoneContext(async () => {
  const ctx = { msg: '全鏈路追蹤資訊', code: 1 }
  setZoneContext(ctx)
  await A()
})
 
function A() {
  // 程式碼同上個demo
}
 
function B() {
  // 程式碼同上個demo
  D()
}
 
// 非同步呼叫C函式
function C() {
  const obj = getZoneContext()
  Promise.resolve().then(() => {
    fs.writeSync(1, `getZoneContext in C -> ${JSON.stringify(obj)}\n`)
  })
}
 
// 同步呼叫函式D
function D() {
  const obj = getZoneContext()
  fs.writeSync(1, `getZoneContext in D -> ${JSON.stringify(obj)}\n`)
}

輸出以下內容:呈現程式碼巨集出錯:引數

'com.atlassian.confluence.ext.code.render.InvalidValueException'的值無效。

getZoneContext in D -> {"msg":"全鏈路追蹤資訊","code":1}

getZoneContext in C-> {"msg":"全鏈路追蹤資訊","code":1}

可以發現, 執行 A 函式前設定的追蹤資訊後,呼叫 A 函式, A 函式中呼叫 B 函式, B 函式中呼叫 C 函式和 D 函式。在 C 函式和 D 函式中,都能訪問到設定的追蹤資訊。

這說明,在定位分析巢狀的非同步呼叫問題時,通過 getZoneContext 拿到頂層設定的關鍵追蹤資訊。可以很快回溯出,某個巢狀非同步呼叫出現的異常,

是由頂層的某個非同步呼叫異常所導致的。

5.6.3 追蹤資訊大而全的 invoke tree

例子程式碼如下:

ZoneContext(async () => {
  await A()
})
async function A() {
  Promise.resolve().then(() => {
    fs.writeSync(1, `A 函式執行非同步呼叫時的 invokeTree -> ${JSON.stringify(invokeTree)}\n`)
    B()
  })
}
async function B() {
  Promise.resolve().then(() => {
    fs.writeSync(1, `B 函式執行時的 invokeTree -> ${JSON.stringify(invokeTree)}\n`)
  })
}

輸出結果如下:

A 函式執行非同步呼叫時的 invokeTree -> {"3":{"pid":-1,"rootId":3,"children":[5,6,7]},"5":{"pid":3,"rootId":3,"children":[10]},"6":{"pid":3,"rootId":3,"children":[9]},"7":{"pid":3,"rootId":3,"children":[8]},"8":{"pid":7,"rootId":3,"children":[]},"9":{"pid":6,"rootId":3,"children":[]},"10":{"pid":5,"rootId":3,"children":[]}}
 
B 函式執行非同步呼叫時的 invokeTree -> {"3":{"pid":-1,"rootId":3,"children":[5,6,7]},"5":{"pid":3,"rootId":3,"children":[10]},"6":{"pid":3,"rootId":3,"children":[9]},"7":{"pid":3,"rootId":3,"children":[8]},"8":{"pid":7,"rootId":3,"children":[11,12]},"9":{"pid":6,"rootId":3,"children":[]},"10":{"pid":5,"rootId":3,"children":[]},"11":{"pid":8,"rootId":3,"children":[]},"12":{"pid":8,"rootId":3,"children":[13]},"13":{"pid":12,"rootId":3,"children":[]}}

根據輸出結果可以推出以下資訊:

1、此非同步呼叫鏈路的 rootId (初始 asyncId ,也是頂層節點值) 是 3

2、函式執行非同步呼叫時,其呼叫鏈路如下圖所示:

3、函式執行非同步呼叫時,其呼叫鏈路如下圖所示:

從呼叫鏈路圖就可以清晰看出所有非同步呼叫之間的相互關係和順序。為非同步呼叫的各種問題排查和效能分析提供了強有力的技術支援。

六、總結

到這,關於Node.js 應用全鏈路資訊獲取的設計、實現和案例演示就介紹完了。全鏈路資訊獲取是全鏈路追蹤系統中最重要的一環,當資訊獲取搞定後,下一步就是全鏈路資訊儲存展示。

我將在下一篇文章中闡述如何基於 OpenTracing 開源協議來對獲取的資訊進行專業、友好的儲存和展示。

作者:vivo網際網路前端團隊-Yang Kun

相關文章