Node 綁定全局 TraceID

問題描述

因爲Node.js的單線程模型的限制,咱們沒法設置全局 traceid 來聚合請求,即實現輸出日誌與請求的綁定。若是不實現日誌和請求的綁定,咱們難以判斷日誌輸出與對應用戶請求的對應關係,這對線上問題排查帶來了困難。javascript

例如,在用戶訪問 retrieveOne API 時,其會調用 retrieveOneSub 函數,若是咱們想在 retrieveOneSub 函數中輸出當前請求對應的學生信息,是繁瑣的。在 course-se 現有實現下,咱們針對此問題的解決方法是:java

  • 方案1:在調用 retrieveOneSub 函數的父函數,即 retrieveOne 內,對 paramData 進行解構,輸出學生相關信息,但該方案沒法細化日誌輸出粒度
  • 方案2:修改 retrieveOneSub 函數簽名,接收 paramData 爲其參數,該方案能確保日誌輸出粒度,但在調用鏈很深的狀況下,須要給各函數修改函數簽名,使其接收 paramData ,頗具工做量,並不太可行。
/** * 返回獲取一份提交的函數 * @param {ParamData} paramData * @param {Context} ctx * @param {string} id */
export async function retrieveOne(paramData, ctx, id) {
  const { subModel } = paramData.ce;
  const sub_asgn_id = Number(id);

  // 經過 paramData.user 獲取 user 相關信息,如 user_id ,
  // 但沒法細化日誌輸出粒度,除非修改 retrieveOneSub 的簽名,
  // 添加 paramData 爲其參數。
  const { user_id } = paramData.user;
  console.log(`${user_id} is trying to retreive one submission.`);
  // 調用了 retrieveOneSub 函數。
  const sub = await retrieveOneSub(sub_asgn_id, subModel);
  const submission = sub;
  assign(sub, { sub_asgn_id });
  assign(paramData, { submission, sub });
  return sub;
}

/** * 從數據庫獲取一份提交 * @param {number} sub_asgn_id * @param {SubModel} model */
async function retrieveOneSub(sub_asgn_id, model) {
  const [sub] = await model.findById(sub_asgn_id);
  if (!sub) {
    throw new ME.SoftError(ME.NOT_FOUND, '找不到該提交');
  }
  return sub;
}
複製代碼

Async Hooks

其實,針對以上的問題,咱們還能夠從 Node 的 Async Hooks 實驗性 API 方面入手。在 Node.js v8.x 後,官方提供了可用於監聽異步行爲的 Async Hooks(異步鉤子)API 的支持。數據庫

Async Scope

Async Hooks 對每個(同步或異步)函數提供了一個 Async Scope ,咱們可調用 executionAsyncId 方法獲取當前函數的 Async ID ,調用 triggerAsyncId 獲取當前函數調用者的 Async ID。bash

const asyncHooks = require("async_hooks");
const { executionAsyncId, triggerAsyncId } = asyncHooks;

console.log(`top level: ${executionAsyncId()} ${triggerAsyncId()}`);

const f = () => {
  console.log(`f: ${executionAsyncId()} ${triggerAsyncId()}`);
};

f();

const g = () => {
  console.log(`setTimeout: ${executionAsyncId()} ${triggerAsyncId()}`);
  setTimeout(() => {
    console.log(`inner setTimeout: ${executionAsyncId()} ${triggerAsyncId()}`);
  }, 0);
};

setTimeout(g, 0);
setTimeout(g, 0);
複製代碼

在上述代碼中,咱們使用 setTimeout 模擬一個異步調用過程,且在該異步過程當中咱們調用了 handler 同步函數,咱們在每一個函數內都輸出其對應的 Async ID 和 Trigger Async ID 。執行上述代碼後,其運行結果以下。異步

top level: 1 0
f: 1 0
setTimeout: 7 1       
setTimeout: 9 1       
inner setTimeout: 11 7
inner setTimeout: 13 9
複製代碼

經過上述日誌輸出,咱們得出如下信息:async

  • 調用同步函數,不會改變其 Async ID ,如函數 f 內的 Async ID 和其調用者的 Async ID 相同。
  • 同一個函數,被不一樣時刻進行異步調用,會分配至不一樣的 Async ID ,如上述代碼中的 g 函數。

追蹤異步資源

正如咱們前面所說的,Async Hooks 可用於追蹤異步資源。爲了實現此目的,咱們須要瞭解 Async Hooks 的相關 API ,具體說明參照如下代碼中的註釋。函數

const asyncHooks = require("async_hooks");

// 建立一個 AsyncHooks 實例。
const hooks = asyncHooks.createHook({
  // 對象構造時會觸發 init 事件。
  init: function(asyncId, type, triggerId, resource) {},
  // 在執行回調前會觸發 before 事件。
  before: function(asyncId) {},
  // 在執行回調後會觸發 after 事件。
  after: function(asyncId) {},
  // 在銷燬對象後會觸發 destroy 事件。
  destroy: function(asyncId) {}
});

// 容許該實例中對異步函數啓用 hooks 。
hooks.enable();

// 關閉對異步資源的追蹤。
hooks.disable();
複製代碼

咱們在調用 createHook 時,可注入 initbeforeafterdestroy 函數,用於追蹤異步資源的不一樣生命週期性能

全新解決方案

基於 Async Hooks API ,咱們便可設計如下解決方案,實現日誌與請求記錄的綁定,即 Trace ID 的全局綁定。ui

const asyncHooks = require("async_hooks");
const { executionAsyncId } = asyncHooks;

// 保存異步調用的上下文。
const contexts = {};

const hooks = asyncHooks.createHook({
  // 對象構造時會觸發 init 事件。
  init: function(asyncId, type, triggerId, resource) {
    // triggerId 即爲當前函數的調用者的 asyncId 。
    if (contexts[triggerId]) {
      // 設置當前函數的異步上下文與調用者的異步上下文一致。
      contexts[asyncId] = contexts[triggerId];
    }
  },
  // 在銷燬對象後會觸發 destroy 事件。
  destroy: function(asyncId) {
    if (!contexts[asyncId]) return;
    // 銷燬當前異步上下文。
    delete contexts[asyncId];
  }
});

// 關鍵!容許該實例中對異步函數啓用 hooks 。
hooks.enable();

// 模擬業務處理函數。
function handler(params) {
  // 設置 context ,可在中間件中完成此操做(如 Logger Middleware)。
  contexts[executionAsyncId()] = params;
  
  // 如下是業務邏輯。
  console.log(`handler ${JSON.stringify(params)}`);
  f();
}

function f() {
  setTimeout(() => {
    // 輸出所屬異步過程的 params 。
    console.log(`setTimeout ${JSON.stringify(contexts[executionAsyncId()])}`);
  });
}

// 模擬兩個異步過程(兩個請求)。
setTimeout(handler, 0, { id: 0 });
setTimeout(handler, 0, { id: 1 });
複製代碼

在上述代碼中,咱們先聲明瞭 contexts 用於存儲每一個異步過程當中的上下文數據(如 Trace ID),隨後咱們建立了一個 Async Hooks 實例。咱們在異步資源初始化時,設置當前 Async ID 對應的上下文數據,使得其數據爲調用者的上下文數據;咱們在異步資源被銷燬時,刪除其對應的上下文數據。spa

經過這種方式,咱們只需在一開始設置上下文數據,便可在其引起的各個過程(同步和異步過程)中,得到上下文數據,從而解決了問題。

執行上述代碼,其運行結果以下。根據輸出日誌可知,咱們的解決方案是可行的。

handler {"id":0}
handler {"id":1}
setTimeout {"id":0}
setTimeout {"id":1}
複製代碼

不過須要注意的是,Async Hooks 是實驗性 API存在必定的性能損耗,但 Node 官方正努力將其變得生產可用。所以,在機器資源足夠的狀況下,使用本解決方案,犧牲部分性能,換取開發體驗。

相關文章
相關標籤/搜索