全鍊路追蹤技術的兩個核心要素分别是 全鍊路資訊擷取 和 全鍊路資訊存儲展示。
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 {
// 調用鍊路上第一個異步資源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()
大家看上面代碼,整個代碼大緻有以下幾個步驟:
- 當監聽到異步調用的時候,會先去 invokeTree 對象中查找是否含有 key 為 triggerAsyncId 的屬性;
- 有的話,說明該異步調用在該追蹤鍊路中,則進行存儲操作,将 asyncId 當成 key , 屬性值是一個對象,包含三個屬性,分别是 pid、rootId、children , 具體含義上文已說過;
- 沒有的話,說明該異步調用不在該追蹤鍊路中。則不進行任何操作,如把資料存入 invokeTree 對象;
- 将目前異步調用 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 {
}
// 收集根節點上下文
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 開源協定來對擷取的資訊進行專業、友好的存儲和展示。