使用 Async-hooks 串聯 HTTP 請求上下文

開門見山,之前在寫業務要求的時候,有一個記錄慢查詢和慢請求的耗時並上報至 ES 的需求,同樣也會有其他類型的日誌需要記錄上報,起初並沒有統一字段,各種類型的日誌的字段比較鬆散,現在需要統一日誌字段。這個需求說起來也算是簡單,提取一些公用的字段,比如 type、timeTaken、data 等字段,另外還有一些共有的字段,比如 username、method、url 等,這些共有字段並不需要外部傳進來,只需從請求上下文中獲取即可。

如果上述方法能完美的解決需求,這篇專欄文章就可以馬上發佈了,可現實總是要比想象的複雜。

由於上報日誌的方法 (下文稱爲 sendLog) 已經被多處使用,且起初並沒有傳遞 req 對象,也就是說如果要使用上述方案,那麼必須給 sendLog 添加參數,這個 req 對象就必須在請求處理的 handler 裏面層層傳遞至 sendLog,對於一個已經上線了十個月的項目來說,這樣改造起來比較比較繁瑣,也不夠優(zhuang)雅(bi)~

那麼有沒有一種不用傳遞 req 對象且能方(you)便(ya)的獲取請求上下文的方法呢?

轉戰 Google 和 Stack Overflow 許久之後找到了一個 NPM 包express-http-context,此包每週有 30k+ 的下載量,看介紹裏面描述覺得足夠優雅好用,瞬間覺得這下應該穩了。

一頓常規操作之後,竟然跑起來了,不用傳遞 req 對象也能獲取到共有的參數,本地各種測試 case 各來一發,結果就是一個字——完美!這就叫專業,這就叫 Perfect!

本着負(neng)責(run)盡(jiu)職(xing)的態度,抓緊測試上線。先上一波測試環境,觀察一段時間之後沒問題,然後就發到生產環境了,觀察一段時間之後,發現日誌正常,CPU 和 RAM 也正常,關機下班。

背景

上述那麼一大堆就是背景。

新問題

發佈上線許久之後,某天從日誌裏發現,MySQL 慢查詢的日誌裏面的共有字段全是空的,問題是使用的姿勢不對?又或者是上下文丟失了?一頓搜索之後,發現這不是一個新鮮的問題,再一頓搜索之後發現,大多數項目的數據庫連接都先於 HTTP 服務啓動,導致回調裏面的 ScopeId 沒法和 HTTP 請求關聯,也就是說在 MySQL 的查詢回調裏面,無法獲取請求上下文。繼續搜索的時候在Context lost after mysql querymysql2 requests aren't being attached to root scope #946Async Hooks do not recognize execution contexts created when processing thenables #22360裏發現可以用 util.promisify 規避這個問題。

拆包

express-http-context內部並沒有多少代碼,邏輯簡單到就是給cls-hooked套了一個殼子,將它封裝成 Express 的中間件,下面簡單看下cls-hooked,核心邏輯在cls-hooked/blob/master/context.js裏面,大致是利用了 NodeJS v8 內置的async_hooks模塊的異步鉤子特性,基於此封裝了一個命名空間來管理請求上下文,並沒有發現什麼明顯的漏洞,百思不得解,一頓搜索之後在專欄上搜索到了一篇相關的文章——《使用 Async-hooks 集成 Envoy 和我們的 RPC 庫(二)》,這篇專欄裏提到的問題一和問題三應該就是上述問題的解釋:

在 async_hooks 的生命週期內,before、after是可能沒有的,祖先的destory可能會比後代早。

關於 async_hooks 的介紹可以參考本文末尾鏈接的文章。

瞭解到上述原因之後,單純的利用 async_hooks 的生命週期鉤子並不能確保上下文不會丟失,所以一個簡單的思路就是隻使用 init 鉤子函數來記錄調用棧,然後在一定時間後清除這些調用棧。

造輪子

大體思路上面已經提到,這裏簡單總結一下:

  1. 使用 async_hooks 的 init 方法來記錄調用棧。
  2. 找到某個特徵將一個請求的調用棧串起來。
  3. 定時清除調用棧。

第1步需要在 init 方法中記錄調用方和被調用方的映射關係即可,這些映射關係就組成了調用棧的依賴樹,一層一層的自上而下的調用下去。細節就是建立異步調用的 asyncId 和 executionAsyncId 之間的映射關係,type 參數是爲了標識異步資源的類型,主要是爲了區分 TCPWRAP 實例,__tm 是記錄調用棧的建立時間,以便後續清除。

asyncHooks.createHook({
  init (asyncId, type) {
    const executionAsyncId = asyncHooks.executionAsyncId()

    callstackMap[asyncId] = {
      id: executionAsyncId,
      isTCP: type === TCPWRAP_NAME,
      __tm: Date.now()
    }
  }
}).enable()

第3步用定時器去清除過期的調用棧即可。

第2步就是如何在茫茫如海的調用棧中將一個請求所有相關的調用棧串起來,最終請求都會經過這個中間件,如何在 Express 或者 Koa 這種框架的模型下組織請求上下文?寫個 Express 和 async_hooks 組合的例子,看看 init 方法的參數:

const fs = require('fs')
const asyncHooks = require('async_hooks')
const express = require('express')

asyncHooks.createHook({
  init (asyncId, type, triggerAsyncId) {
    const eid = asyncHooks.executionAsyncId()
    fs.writeSync(1, `${type}(${asyncId}): trigger: ${triggerAsyncId} execution: ${eid}\n`)
  }
}).enable()

const app = express()

app.all('*', (req, res) => {
  res.send('Hello World!')
})

app.listen(3001)

請求三次後查看打印如下:

TCPWRAP(6): trigger: 1 execution: 1
TickObject(7): trigger: 6 execution: 1
TCPWRAP(8): trigger: 6 execution: 0
TIMERWRAP(9): trigger: 6 execution: 6
Timeout(10): trigger: 6 execution: 6
HTTPPARSER(11): trigger: 6 execution: 6
HTTPPARSER(12): trigger: 6 execution: 6
TickObject(13): trigger: 6 execution: 6
TIMERWRAP(14): trigger: 12 execution: 12
Timeout(15): trigger: 12 execution: 12
TickObject(16): trigger: 8 execution: 12
WRITEWRAP(17): trigger: 8 execution: 12
TickObject(18): trigger: 12 execution: 12
TickObject(19): trigger: 12 execution: 12
TickObject(20): trigger: 16 execution: 16
TIMERWRAP(21): trigger: 16 execution: 16
TickObject(22): trigger: 19 execution: 19
TickObject(23): trigger: 20 execution: 20
TickObject(24): trigger: 20 execution: 20
TickObject(25): trigger: 20 execution: 20
TickObject(26): trigger: 8 execution: 8
TickObject(27): trigger: 8 execution: 8
TCPWRAP(28): trigger: 6 execution: 0
Timeout(29): trigger: 6 execution: 6
HTTPPARSER(30): trigger: 6 execution: 6
TickObject(31): trigger: 6 execution: 6
TIMERWRAP(32): trigger: 30 execution: 30
TickObject(33): trigger: 28 execution: 30
WRITEWRAP(34): trigger: 28 execution: 30
TickObject(35): trigger: 30 execution: 30
TickObject(36): trigger: 30 execution: 30
TickObject(37): trigger: 33 execution: 33
TickObject(38): trigger: 36 execution: 36
TickObject(39): trigger: 37 execution: 37
TickObject(40): trigger: 37 execution: 37
TickObject(41): trigger: 37 execution: 37
TickObject(42): trigger: 28 execution: 28
TickObject(43): trigger: 28 execution: 28
TCPWRAP(44): trigger: 6 execution: 0
Timeout(45): trigger: 6 execution: 6
HTTPPARSER(46): trigger: 6 execution: 6
TickObject(47): trigger: 6 execution: 6
TickObject(48): trigger: 44 execution: 46
WRITEWRAP(49): trigger: 44 execution: 46
TickObject(50): trigger: 46 execution: 46
TickObject(51): trigger: 46 execution: 46
TickObject(52): trigger: 48 execution: 48
TickObject(53): trigger: 51 execution: 51
TickObject(54): trigger: 52 execution: 52
TickObject(55): trigger: 52 execution: 52
TickObject(56): trigger: 52 execution: 52
TickObject(57): trigger: 44 execution: 44
TickObject(58): trigger: 44 execution: 44

其中第1-2行的日誌在我沒有請求的時候就已經有了,所以請求產生的日誌是從第三行開始的,仔細觀察就會發現,每一次請求都會有一個類型爲TCPWRAP的調用棧記錄,NodeJS 官方文檔裏這樣解釋:

TheTCPWRAPis the new connection from the client. When a new connection is made, theTCPWrapinstance is immediately constructed. This happens outside of any JavaScript stack. (AnexecutionAsyncId()of0means that it is being executed from C++ with no JavaScript stack above it.) With only that information, it would be impossible to link resources together in terms of what caused them to be created, sotriggerAsyncIdis given the task of propagating what resource is responsible for the new resource's existence.

簡而言之就是每接收到一個來自客戶端的請求,當這個請求連接建立的時候,這個 TCPWrap 實例就立刻被創建。

通過以上發現,我們可以利用 TCPWRAP 來標誌一個請求的開始,這個標誌也就可以作爲這個請求調用棧的根。下面就需要將 TCPWRAP 和中間件關聯起來,我們通過中間件執行時的 executionAsyncId 和 init 裏記錄的調用棧來向上尋找最近的 TCPWRAP 實例對應的 executionAsyncId,這個 TCPWRAP 實例對應 executionAsyncId 就是這個請求的根。

既然有了這個可以標誌根的 executionAsyncId,暫且叫它 rootId,那麼請求在進入下一個中間件的時候,都可以根據自身的 executionAsyncId 來向上尋找,直到找到 rootId。每次 set、get 的時候,都會根據對應的 rootId 找到對應的上下文,這樣就將一個請求的上下文串起來了。

由於 rootId 是在中間件執行的時候確定的,所以在 TCPWRAP 實例創建之後到中間件之間,不應該有任何異步的操作,簡而言之就是將該中間件放置在其餘含有異步操作的中間件的最前面,該中間件之前不應有任何包含異步操作的中間件

因爲從 TCPWRAP 實例創建到進入中間件是同步的,所以在進入中間件的時候,只需按照中間件對應的 executionAsyncId 向前遞歸查找最近的 TCPWRAP 實例對應的 asyncId 即可,也就找到了這次請求對應的 rootId。之後給 rootId 對應的調用棧初始化一個空的上下文,

// middleware logic
const middleware = () => {
  const executionAsyncId = asyncHooks.executionAsyncId()
  const rootId = findTCPWrapAsyncId(executionAsyncId)
  callstackMap[rootId].data = {}
  callstackMap[executionAsyncId] = {
    id: rootId,
    __tm: Date.now()
  }
}

// find rootId
const findTCPWrapAsyncId = asyncId => {
  if (callstackMap[asyncId]) {
    if (callstackMap[asyncId].type === TCPWRAP_NAME) {
      return asyncId
    }
    return findTCPWrapAsyncId(asyncId - 1)
  }
}

優化

總覺得第3步用定時器清除調用棧的操作不夠優雅,出於能儘量儘快的刪除無用調用棧的考慮,可否在某些時刻主動刪除,而不是一股腦的都在定時器裏刪除。

首先想到的就是利用前文所說的 destory 鉤子方法,但是考慮到上文提及到的問題,需要在 TCPWRAP 實例銷燬的時候清除對應調用棧。還有一種方法就是在 http.ServerResponse 的 finish 和 close 事件觸發的時候清除調用棧。

最後再使用定時器去刪除一些遊走在 http 請求之外的調用棧即可。

還沒完

上述邏輯上線之後,仍然有上下文丟失的情況發生,真是急禿了頭。從日誌裏發現,上下文丟失絕大多數發生在服務端異步請求超時之後,本地也可以模擬復現這個問題。許久之後在想明白,在客戶端請求超時或者用戶終止裏連接,上述優化操作在這個時候就會清除該請求的調用棧,但是這其中有一個很微妙的細節,客戶端超時之後,服務端調用其他服務可能還沒有結束,在這些調用結束之前,它所屬的請求的調用棧早已被清除,這些調用結束之後在操作上下文的話,上下文早已銷聲匿跡了,無處可尋。

所以上文提到的優化並不可靠,目前只能依賴定時器去清除調用棧,且要根據業務需要合理設置有效時間。

必須優化

還有沒有其他的優化辦法呢?當然有!既然 destory 鉤子方法不可靠,那就從其他可靠的地方下手,NodeJS 的請求裏面有兩個事件,分別是finishclose,二者必然會觸發一個。當然在這兩個事件直接觸發之後清除調用棧是可以的,不過仍然有一種情形需要妥善考慮,測試過程中發現 close 事件一般發生在客戶端終止請求,這種情形一般發生在服務端超時(亦或者是服務端處理時間過久),用戶失去耐心,關閉頁面或者刷新頁面導致的。這種情形下,close 事件觸發之後清除調用棧,內部的異步請求可能稍後纔有返回,這是再去獲取上下文就已經遲了。雖然這個優化點要比 destory 鉤子方法可靠一點,但也不是絕對可靠的,但是在大多數業務場景下,在 finish 事件之後清除調用棧是可靠的!

既然都不是絕對可靠的,那就將這些優化操作作爲可選項,默認關閉。在內部使用中,只開啓了 finish 事件之後清除調用棧,效果挺好。

具體邏輯和之前類似,所有擁有 rootId 的調用棧都會被記到 rootId 下,一旦 finish/close 事件觸發,如果需要清除調用棧的就將該 rootId 對應的調用棧一併清理掉即可。

End

上線觀察一段時間後,沒有出現過上下文丟失的情況了,內存使用也正常,服務性能也正常,目前唯一擔心的就是該中間件能否經得住大流量&複雜邏輯場景的考驗。

最後的產出就是一箇中間件http-request-context,總的代碼加上對 Express + Koa 中間件的封裝不足百行,歡迎使用、指正。

參考

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