全文約8500字,閱讀時長約10分鐘。
智慧作業最近上線「個性化手冊」(簡稱個冊)功能,一份完整的個性化手冊分為三部分:
•學情分析:根據學生階段性的學習和考試情況進行學情分析、歸納、總結,彙總學情資料;•精準推薦:推薦演算法基於學情資料結合知識圖譜進行精準練習題推薦;•錯題回顧:錯題的階段性回顧複習。
第一部分學情分析的PDF由Node.js加工,與Java後端透過訊息佇列RabbitMQ進行資料互動,本文簡單記錄一下Node.js批次加工PDF服務的架構模式,以及基於現階段發現的問題,梳理未來的迭代規劃和演進方向。
業務特徵
個冊三個部分的PDF資料來源不同,生產邏輯獨立由不同的服務生產,最終將三份PDF合併為一份,還要支援班級所有學生批次生產和壓縮打包,所以這個功能在技術角度最主要的特徵就是環節多、耗時長:
•環節多意味著在各個服務之間存在較多的網路通訊和資料互動,核心挑戰在於如何設計低耦合、高可用的服務架構;•耗時長一方面體現在多個環節的總耗時,另一方面體現在三個PDF生產服務各自的加工耗時。
基於以上業務特徵,PDF加工服務架構設計的一個大方向就是將長耗時任務非同步處理,各服務之間邏輯解耦,透過訊息佇列進行資料互動。
技術選型
服務端生成PDF通常有兩種方案:
•第一種是使用 pdfkit[1] 之類的工具透過程式碼繪製,這種方案最大的問題是可渲染的內容型別有限,定製化不足;•第二種是建立 headless browser用html渲染後擷取pdf,這種方案的架構相對複雜,但是可以支援所有web端的內容型別。
個冊第一部分學情分析的某一頁長這個樣子:
僅這一頁就涵蓋文字、表格、圖表以及各種自定義圖案,內容型別多樣並且後續迭代可能增加更多定製化內容,第一種方案的侷限性很難滿足需求,所以最終選定 headless browser 方案。
具體到 headless browser 的技術選型就非常有限了,可選的無非就是 Selenium/PhantomJS 這類老招牌,或者 Puppeteer/Playwright 這類新玩家。
嚴格來說Selenium只是一種類似按鍵精靈的工具,可透過程式碼在瀏覽器中模擬人的操作,本身並不是瀏覽器,所以需要搭配第三方瀏覽器使用,比如PhantomJS。
Selenium/PhantomJS 的最大的優點就是生態健全,支援多種程式語言,有相對繁榮的技術社群;缺點就是穩定性和效能較差,Selenium的穩定性出了名的糟糕,PhantomJS五年前就停止維護了。這哥倆通常用在對穩定性要求不是很高的場景,比如爬蟲。
與之形成鮮明對比的,Puppeteer/Playwright 最大的優點就是穩定性高,效能更優;缺點就是對程式語言的支援有限,生態和技術社群相對沒那麼健全。
個冊的業務特徵一是對穩定性和效能要求很高;二是不要求跨瀏覽器(Playwright支援瀏覽器型別更豐富)。最終綜合考慮API易用性、穩定性、效能、社群、風險等因素,在 Puppeteer 和 Playwright 之間選擇了 Puppeteer。既然選定了 Puppeteer,配套的自然就是 Node.js了。
Puppeteer 和 Playwright 的對比可以參考這篇文章:Playwright vs Puppeteer: Core Differences[2]。
這個需求是我第一次使用Puppeteer,還沒完全摸透,下文涉及到Puppeteer相關的方案如果有問題,歡迎討論指點。
實現方案
智慧教育的分層架構如下:
Node.js PDF服務是本次需求新增的,為了方便分離部署和最佳化,PDF服務單獨建立一個服務,不涉及Node.js接入層的改動。下圖是個冊PDF加工的完整流程:
每個環節的具體流程不細講,Node.js PDF加工服務的細節下文詳解。與Node.js PDF服務相關最關鍵的是與Java後端的資料互動流程。Java後端與Node.js PDF服務透過 RabbitMQ 訊息佇列進行資料互動,建立兩個佇列:
這部分沒啥好講的,Node.js與Java之間按照約定的資料規範組裝資料即可,下面詳細介紹一下Node.js加工pdf的具體邏輯。
這一版個冊的第一部分學情分析控制在3頁,早期規劃的個冊PDF大約25頁左右,技術調研和架構設計都是基於這個預期進行的,所以現在這套模式多少有點殺雞用牛刀的意思,不過前期打好基礎給後續迭代留些空間也是好事。
單份PDF加工流程
為了更方便理解,在介紹pdf加工流程之前,有必要先簡要一下Node.js PDF服務的架構,以及與PDF加工邏輯最相關的 worker角色。
Node.js PDF服務架構最核心的三個角色:
•Scheduler:負責輪詢排程,發起任務;•Executor:負責任務前置和後置相關邏輯,包括worker pool管理、worker 排程、MQ任務佇列訊息拉取、MQ回傳佇列訊息傳送等;•Worker:負責實質執行任務,包括pdf渲染、生產、上傳OSS;
三者的關係如下所示:
Scheduler和 Executor的具體邏輯以及三個角色之間的排程邏輯下文再詳解,PDF檔案的實質生產邏輯都集中在 Worker中,流程如下:
圖中「傳送訊息至MQ回傳佇列」實質是由Executor執行,此處畫出方便理解完整流程。
預啟動
圖中虛線部分的預啟動是在啟動 Node.js 服務之前執行的邏輯,預啟動完成之後 Node.js 服務被拉起,所以預啟動的耗時是一次性的。
預啟動過程執行兩個動作:
•讀取磁碟中的html檔案內容,寫入記憶體,為後續環節「載入網頁」提供資料;•建立 Puppeteer browser 例項。
上圖中只畫出pdf加工邏輯相關的預啟動工作,實際上預啟動還包含一些其他邏輯,比如建立 MQ 連線通道。
冷啟動(廢棄)
雖然冷啟動在後來開發過程中被廢棄,但透過這個事情發現自己的不足,還是值得記錄一下的。
最初之所以設想冷啟動環節,是因為嘗試用 worker 模擬多執行緒。每個worker會建立一個browser例項和多個page例項(目前是3個),如下所示:
這樣做的目的是將每個worker的負載上限固定,便於伺服器資源規模預估,避免伺服器某個節點負載過高,進而也可以避免k8s叢集pod的縱向伸縮。
k8s縱向伸縮的取捨見仁見智,我個人不太建議使用。
如果任務佇列長時間為空會觸發快取清理邏輯,銷燬browser和page例項以節省伺服器資源,再次發起任務會觸發冷啟動。冷啟動執行兩件事情:
•連結/建立browser例項•建立page例項
另外增加一個標識位_mounted代表冷啟動是否完成,程式碼如下:
public async run(){
if(!this._mounted){
// 觸發冷啟動
this._mount();
}
// ...其他邏輯
}
private async _mount(){
if(!this._browser?.isConnected()){
// 連結browser
this._browser = await puppeteer.connect({
browserWSEndpoint: this._wsEndpoint
});
}
// 建立page例項
if(isEmpty(this._pages)){
for(let i =0;i<this._opts.maxPageCount;i++){
const ctx = await this._browser.newPage();
this._pages.push({
ctx,
busy: false
});
}
}
this._mounted = true;
}
乍看起來似乎沒啥問題,但實際跑一跑程式碼會發現,在任務排程密集的時候,run函式短時間內被呼叫多次(具體的排程策略下文講解),worker會觸發多次冷啟動,雖然不影響業務邏輯,但會引起伺服器資源暴漲,這是因為冷啟動會建立新的browser和page例項,但是舊例項並沒有被清理,仍然在執行任務。
冷啟動被呼叫多次的根本原因是Node.js不是多執行緒,如下圖所示,假設冷啟動耗時20ms,在此期間再次呼叫run函式,標識位_mounted還未被設定為true,就會又觸發一次冷啟動。
有沒有解法?
當然有。多執行緒程式設計解決競態最常用的就是:加鎖。既然想模擬多執行緒那就徹底一點,把鎖邏輯也加上唄。
worker本身是有“鎖”的,每個worker有3個page例項,只有當存在空閒例項(busy為false)時run函式才可以執行,但是這個鎖機制並不能避免多次冷啟動問題,因為冷啟動完成之前page例項還未被建立。
可能會有人說,那就加個限制,page例項不存在時也不讓run函式執行不就得了?這麼做的話run函式永遠都不會被執行啊大聰明。
既然worker已有的鎖不行,那就再加個冷啟動鎖,冷啟動之前鎖定,冷啟動之後解鎖。這麼做當然是可以的,但是會增加邏輯複雜度,worker有兩種鎖,對後期迭代維護無疑是埋雷。
其實之所以有冷啟動無非就是為了省點記憶體,用時間換空間,一個browser例項+3個空白page例項總共100m左右的記憶體,這年頭記憶體這麼便宜,為了省這點空間把邏輯搞那麼複雜完全得不償失。什麼叫過度設計,這就是過度設計。
所以後來索性把冷啟動過程幹掉了,browser和page例項的建立放在worker初始化邏輯裡。
public async init() {
/**
* 儘量禁用掉不需要的功能,提高效能
*/
this._browser = await puppeteer.launch({
headless: true,
args: [
'--incognito',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
'--single-process'
]
});
this._wsEndpoint = this._browser.wsEndpoint();
// _mount函式邏輯不改動,呼叫_mount函式放在初始化邏輯中
await this._mount();
}
載入網頁
網頁透過page.setContent(html)函式載入本地html檔案,與透過page.goto(url)載入遠端URL相比,既節省了部署網頁的伺服器資源,同時速度也更快:
上文提到過,本地html檔案在預啟動階段提前從磁碟讀取存放於記憶體,執行時無需實時讀取。所以檔案IO的耗時不算在pdf加工邏輯總耗時中,而載入遠端URL只能在執行時執行,會增長pdf加工的總時長。
另外,載入的本地html檔案中不能存在靜態資源引用,比如js和css必須全部以行內<script>
或<style>
標籤的形式、圖片必須是base64形式嵌入。這是因為預啟動階段從磁碟只讀取html檔案的文字內容,不會進行解析。所以在工程化方面,要求網頁的構建產出只能有一個完完整整的html檔案。
等待網頁載入完成後再執行下一步的渲染邏輯,程式碼如下:
await page.setContent(htmlContent, {
waitUntil: ['load'] // 等待網頁loaded
});
渲染
網頁中的渲染程式碼掛載到window物件一個render函式,Node.js層透過呼叫這個函式注入渲染資料,render函式返回一個Promise,如果渲染成功返回資料的status為1,程式碼如下:
// 等待 window.render 函式準備就緒
await page.waitForFunction('!!window.render');
const renderResult = await page.evaluate(`window.render(${renderData})`);
if(renderResult.status === 1){
// 渲染成功
}else{
// 渲染失敗
}
上文提到等待網頁loaded之後再執行渲染,但是上述程式碼中在呼叫render函式之前,需要等待window.render函式準備就緒,這是因為網頁中部分邏輯在window.onload觸發之後進行,為了確保呼叫render函式不報錯,必須等待render函式準備就緒。
擷取pdf&上傳OSS
呼叫page.pdf擷取pdf資料,注意這一步只拿資料不儲存到磁碟,減少不必要的檔案IO,程式碼如下:
const buf = await page.pdf({
format: 'A4'
});
上述程式碼拿到一個二進位制Buffer物件,最後將這個Buffer直接上傳至OSS,這一步使用的OSS Node.js SDK。
截止到此,一份PDF檔案就加工完成了,但還沒算完,需要將加工結果通知Java後端,向MQ佇列中傳送一條約定好的規範資料,這一步是由外層的 Executor 執行,下面就講一講其他兩個角色,Scheduler 和 Executor, 在完整架構中的承擔的具體工作。
批次加工輪詢排程策略
主動 or 被動
RabbitMQ通常有兩種使用形式,一種是釋出訂閱,消費者訂閱某個佇列,MQ服務主動推送訊息給消費者,消費者被動接收,這種是最常用的模式。另一種是消費者不訂閱,而是從MQ服務主動拉取訊息,這種模式下MQ其實充當了儲存角色,跟資料庫、redis快取服務類似。
Node.js pdf加工服務的業務特徵,第一是耗時長;第二是叢集消費,不論是考慮容災還是效率,生產環境肯定不能是單節點伺服器。
如果採用釋出訂閱模式,最大的優點是時效性高,Node.js服務的架構也更簡單,只需要訂閱任務佇列,訊息來了就幹活,非常適合實時性高、耗時短的任務處理。但是在長耗時任務場景,會有兩個隱患:
1.消費者服務負載不可控。如果消費者不控制並行的任務數量上限無節制地消費,會造成伺服器負載無限增長,如果是物理機有可能把機器打爆,如果部署在容器叢集會觸發動態擴容相對還好一些,但同樣會存在重複消費、訊息丟失以及擴容過程中由於網路延遲造成的穩定性風險;而如果消費者設定並行的任務數量上限雖然會避免這個問題,但是這個上限並不會限制RabbitMQ push訊息,消費者並行任務當達到上限後,RabbitMQ仍然會不斷push訊息,就會造成消費者服務產生訊息堆積;2.叢集中多個服務節點均衡消費不可控。有可能會出現某個節點長時間高負載執行甚至產生訊息堆積,但同時可能其他節點閒的冒煙兒。雖然可以配置RabbitMQ採用相對合理的分配策略,但也不能保證絕對均衡,同時也增強了消費者服務與MQ直接的耦合性。 以上這些問題雖然在短耗時任務場景也存在,但是在長耗時場景會被進一步放大。比如訊息堆積問題,考慮到穩定性,消費者也不能就放任訊息排大隊不管不顧,好歹你得搞個任務排隊和消費排程邏輯吧,相當於在消費者服務上又弄了個微型任務佇列,這不是脫褲子放屁嗎?
如果是主動拉取模式,前文講了每個Node.js服務的worker規模是固定的,進而負載上限也就固定了,Node.js可以根據worker的忙閒情況自主決定是否拉取新訊息進行消費。叢集中所有服務節點都是相同的負載上限和排程策略,理論上可以實現絕對的均衡消費。另外,由於消費者是基於執行緒空閒排程消費,所以不會產生訊息堆積,訊息存放在MQ任務佇列中更穩定,總比在消費者服務上排隊好。
基於以上考慮,最終選定主動拉取模式。
不過主動拉取模式也並非沒有問題,它的優點恰恰也是缺點。首先是時效性不高,Node.js PDF服務根據預先配置的worker規模進行排程消費,可承載的並行任務數量上限是固定的,不管上游多著急,下游的Node.js服務都按照自己的節奏不緊不慢地幹活;然後同樣因為這個原因,所以在高併發場景下無法充分發揮出Node.js非阻塞單執行緒的高併發優勢。
具體到業務場景,個冊功能並非實時任務,短時併發不會很大,耗時瓶頸也並不在Node.js服務,這套模式在可預見的中短期內足夠了。
不過考慮到未來的不確定性,開發時仍然留了個口子,增加了一個開關切換排程模式,後續如果需要改成釋出訂閱模式只需簡單修改即可。
chatGPT大熱之後我向chatGPT 3.5提了幾個相關問題,結果與我們的方案是一致的,可惜這時候已經開發完了,要是早點問就能省點腦子了。
基於事件驅動的輪詢排程
主動拉取模式下,消費者需要結合自身負載能力設計合理的排程策略,輪詢從MQ拉取訊息。常見的輪詢策略一般按照分工有兩類角色:負責發起執行呼叫的排程者和負責執行邏輯的執行者,
排程者發起一次呼叫執行結束後設定定時器發起下次呼叫,依次迴圈,如下示意程式碼:
// 排程者
function async shcedule(){
await exec();
setTimeout(shcedule, 3000);
}
// 執行者
async function exec(){
await worker1();
await worker2();
}
async function worker1(){
// ...
}
async function worker2(){
// ...
}
這是前端最常用的輪詢方式,比如在輪詢請求後端介面查詢任務狀態。
執行者函式對排程者來說是黑盒,比如上述程式碼中,schedule函式必須等待exec函式執行完成之後再發起下次排程。exec依次呼叫worker1和worker2函式,真實場景下worker1和worker2通常在邏輯上存在一定的耦合或者時序依賴,兩者的分工必然是不同的。所以這種常規輪詢的侷限性在於,如果worker1和worker2功能完全一樣,沒有任何關係和依賴,那麼每次執行exec函式,worker1都會有一段時間的空閒,降低了並行處理的效率。
稍微改一下呢?比如:
async function exec(){
await Promise.all([worker1(), worker2()]);
}
這樣會讓worker2不用等待worker1完成之後再執行,並行處理效率略有提升,但需要等待兩個worker中耗時最長的一個執行完才能發起下次排程,仍然有一定程度的時間浪費,除非兩個worker的耗時完全一致才不浪費。
Node.js PDF 服務就是這種場景,上文提到了三個角色:Scheduler、Executor、Worker。同時還有一個worker pool的概念,每個worker負責的工作是一樣的,互相之間沒有任何耦合和依賴,常規的輪詢策略顯然效率不夠高。
改進的思路很簡單:任何一個worker執行結束之後都會通知Scheduler立即發起下次排程,不用等待所有worker都執行完成。基於這個思路,再稍微加上一點點細節,最終落地的基於事件驅動的輪詢排程策略如下圖所示:
介紹幾個概念:
•tick:輪詢間隔,初始值為0,即發起下次排程的延時。在任務空閒時段每次排程會遞增tick,有最大值限制,目前的配置是1分鐘;•常態輪詢:正常情況下的輪詢機制,對應圖中綠色框部分;•異常容錯:一些異常情況的處理策略,對應圖中紅色框部分。
為了架構的整潔性,以上輪詢策略中的所有事件的觸發和響應都分別收歸到Executor和Scheduler中,worker只負責幹活啥都不管,這樣每個角色的程式碼都更易迭代和維護。
常態輪詢
常態輪詢涉及兩個事件:
•worker_idle事件:代表有空閒worker,對應圖中綠色六邊形。任意worker執行完一次任務後,不論任務是否成功都會觸發此事件。Scheduler監聽到此事件後會重置tick為初始值,並在tick之後發起下次排程;•queue_empty事件:代表任務佇列為空,對應圖中灰色六邊形。Node.js從MQ任務佇列拉取訊息為空時觸發此事件。Scheduler監聽到此事件後會遞增tick,並在tick之後發起下次排程。
queue_empty事件還會觸發一個額外邏輯:快取清理。當任務佇列長時間為空,tick已經達到最大值,會在整點時刻觸發快取清理動作,Node.js服務會銷燬並重建Puppeteer browser和page例項,這樣做的目的主要是為了空閒時段節省記憶體,同時也避免瀏覽器長時間執行可能崩潰的風險(機率較低),保障下次任務執行成功。
異常容錯
異常容錯涉及兩個事件:
•mq_svc_err事件:代表RabbitMQ服務錯誤,一般是掛了,對應圖中紅色六邊形。當Node.js與RabbitMQ服務通訊失敗時觸發此事件;•no_idle_worker事件:代表無空閒worker是發起了任務排程,對應圖中橘色六邊形。正常情況下不會觸發此事件,除非排程邏輯有bug,為了服務健壯性,即便此事件被觸發也不會導致服務崩潰。
完整的排程流程並不複雜,圖中已經比較清晰就不再贅述了,需要特別注意的是,只有常態輪詢的事件才會觸發排程,異常容錯的事件都只會影響 tick 的增減,不會觸發排程。異常容錯的事件時並不會打斷輪詢,相反,異常容錯的目的就是為了在發生異常後能夠讓邏輯重新回到常態輪詢,所有事件共同配合組成完整閉環:
•no_idle_worker事件雖然不會觸發排程,但此事件發生時說明所有worker都忙,只要有一個worker完成任務都會觸發worker_idle事件回到常態輪詢,觸發下次排程。•mq_svc_err事件,只有兩種場景會觸發此事件,一是嘗試從任務佇列拉取訊息,二是嘗試向回傳佇列傳送訊息。第一個場景失敗會觸發兩個事件,一個是mq_svc_err另一個是queue_empty。第二個場景,按前文所述,傳送回傳佇列本身就是一次pdf加工流程中的一環,不論是否成功都會觸發worker_idle事件。所以這兩種場景最終都會回到常態輪詢,觸發下次排程。
以下是所有事件的簡要彙總:
工程化
以上便是Node.js PDF服務的架構設計和落地形態,下面簡單說幾點工程化方面需要注意的點。
除錯模式
Node.js 服務在輪詢狀態下不方便開發除錯,所以增加了一個開關切換輪詢模式,關閉狀態下有以下不同:
•Scheduler不監聽相關事件•Node.js服務提供一個介面,呼叫此介面會發起一次任務排程。如下:
/**
* 執行單條任務
*/
@Post('/task/exec')
@HttpCode(HttpStatus.OK)
@DebugOnly()
public async execTask(){
return this._executorService.runSingle();
}
除錯模式的主要目的是方便前後端進行聯調資料,只在開發環境下可用。上述程式碼中的裝飾器DebugOnly程式碼如下:
import { SetMetadata } from '@nestjs/common';
export const DEBUG_ONLY = Symbol('DEBUG_ONLY');
/**
* 裝飾器:禁止記錄日誌
* @example
* ```typescript
* @DisableLogging()
* ```
*/
export const DebugOnly = () => SetMetadata(DEBUG_ONLY, true);
然後開發一個guard中介軟體攔截非開發環境下的介面請求,關鍵程式碼如下:
export class DebugOnlyGuard implements CanActivate{
constructor(
private readonly _configService: ConfigService<IBootstrapOptions>,
private readonly _reflector: Reflector
){}
canActivate(
ctx: ExecutionContext,
): boolean{
const env = this._configService.get<ENV>('env');
const isDebugOnly = this._reflector.get<boolean>(DEBUG_ONLY, ctx.getHandler()) || false;
if(isDebugOnly && env !== ENV.DEVELOPMENT){
return false;
}
return true;
}
}
構建
構建方面只需要注意一點:執行渲染的網頁需要把所有靜態資源以行內的形式編譯進html檔案。
另外,網頁的程式碼與Node.js程式碼在同一倉庫,不用submodule或monorepo,k8s構建映象時拉取一次即可。
monorepo一般用在存在邏輯關聯的多個模組之間,透過import方式引入,我們的需求中,執行渲染的網頁對於Node.js來說是一種資源,而不是邏輯模組,monorepo並不適合。
部署
部署模式本身與常規的Node.js服務沒啥區別,主要精力花費在定製容器映象上。
我司的k8s叢集容器的基礎映象缺少很多Puppeteer依賴的底層庫,大概十幾個,光安裝這些底層庫就需要2-3分鐘,如果每次構建映象都臨時安裝就且得等了。另外,基礎映象中還缺少網頁渲染所需的字型,同樣需要在構建映象是複製到容器中。
為了減少每次釋出時映象構建的耗時,我們定製了一個Puppeteer專用的映象,如下:
FROM node:16
# 複製字型
COPY ./fonts/myfont.ttc /usr/share/fonts/myfont.ttc
# 安裝 Puppeteer 依賴的底層庫
RUN ["apt-get", "update"]
RUN ["apt-get", "install", "-y", "wget", "unzip", "fontconfig", "locales", "gconf-service", "libasound2", "libatk1.0-0", "libc6", "libcairo2", "libcups2", "libdbus-1-3", "libexpat1", "libfontconfig1", "libgcc1", "libgconf-2-4", "libgdk-pixbuf2.0-0", "libglib2.0-0", "libgtk-3-0", "libnspr4", "libpango-1.0-0", "libpangocairo-1.0-0", "libstdc++6", "libx11-6", "libx11-xcb1", "libxcb1", "libxcomposite1", "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxrandr2", "libxrender1", "libxss1", "libxtst6", "ca-certificates", "fonts-liberation", "libappindicator1", "libnss3", "libgbm-dev", "lsb-release", "xdg-utils", "wget"]
與基礎映象相比,使用定製映象的構建耗時大約減少了45%,如下圖:
監控告警
服務上線之後梳理了一些異常事件配置告警,詳情如下:
以上告警會推送至工作群,專人跟進修復。
問題&規劃
服務上線至今約半個月時間內主要發現了兩個問題:
•渲染耗時上下限差距巨大;•伺服器記憶體使用率存在異常尖刺。
渲染耗時
開發初期加工一份pdf的平均耗時約6s,最佳化之後最佳耗時能達到250ms左右,但最長耗時仍然有接近3s的任務,上下限差距巨大。細節原因還沒完全定位清楚,比如渲染資料的體積與渲染耗時成正比,這點可以理解,但渲染只是完整pdf加工任務中的一環,其他環節也存在非常大的耗時波動,比如下面兩條任務的對比:
task-fulfilled是任務總耗時,是下面幾個事件耗時+一些其他邏輯的耗時總和。透過對比可以看出不僅是渲染耗時(render-success),其他幾個環節也存在非常巨大的耗時波動。
所以,後續的一項工作就是嘗試定位每個環節耗時產生波動具體原因,進一步最佳化總耗時。
記憶體尖刺
伺服器的CPU曲線相對平緩符合預期,但是在某些時刻記憶體有非常明顯的尖刺,比如下圖中18點10分左右:
每個PDF的渲染資料體可能不同,記憶體佔用率有波動很正常,但這麼明顯的尖刺就有問題了。透過查日誌發現,這個時間段的某條任務觸發了create-tmp-page事件,此事件只會在一種情況下發生:當worker所有page都忙時被呼叫執行任務,會臨時建立一個page例項執行此條任務。
與上文種的no_idle_worker事件一樣,建立臨時page例項同樣也是基於服務健壯性考慮,當出現異常時保障邏輯正常執行,這相當於超過了服務負載理論上限,必然會引起記憶體增長。理論上這種情況不會發生,既然發生了,說明排程邏輯有bug。
所以,後續的一項工作就是定位引起create-tmp-page的原因並修復。
總結
以上便是Node.js批次加工PDF的服務架構設計和最終落地形態,透過訊息佇列與其他服務進行資料互動,基於事件驅動的輪詢排程策略主動拉取佇列訊息。這個服務對於前端團隊來說實現了兩個突破:
•首次使用了訊息佇列。我個人認為,前端工程師雖然寫Node.js服務端,但是難以取代後端,最核心的兩個點就是資料庫和非同步任務。這兩點也是我認為後端工程師的核心競爭力。雖然這次Node.js使用了訊息佇列,但也只停留在使用的層面,對內在機制和技術原理連邊都沒摸著,所以也並沒有搶後端的飯碗。這是好事,專業的人做專業的事,前端的核心工作仍然是聚焦在應用層互動邏輯上;•首個複雜功能零bug,測試階段Node.js的PDF加工邏輯零bug(渲染邏輯有少數bug)。不過上文也寫了,透過監控日誌能看出來排程邏輯肯定是bug的,測試沒測出來是因為有健全的兜底邏輯保障功能正常執行,這其實也是一種可借鑑的經驗:程式碼沒bug不太可能,能夠保證出現bug時功能正常就很考驗編碼能力。
References
[1]
pdfkit: https://pdfkit.org/[2]
Playwright vs Puppeteer: Core Differences: https://www.browserstack.com/guide/playwright-vs-puppeteer