智慧作業最近上線「個性化手冊」(簡稱個冊)功能,一份完整的個性化手冊分為三部分:
- 學情分析:根據學生階段性的學習和考試情況進行學情分析、歸納、總結,彙總學情資料;
- 精準推薦:推薦演算法基於學情資料結合知識圖譜進行精準練習題推薦;
- 錯題回顧:錯題的階段性回顧複習。
第一部分學情分析的PDF由Node.js加工,與Java後端透過訊息佇列RabbitMQ進行資料互動,本文簡單記錄一下Node.js批次加工PDF服務的架構模式,以及基於現階段發現的問題,梳理未來的迭代規劃和演進方向。
業務特徵
個冊三個部分的PDF資料來源不同,生產邏輯獨立由不同的服務生產,最終將三份PDF合併為一份,還要支援班級所有學生批次生產和壓縮打包,所以這個功能在技術角度最主要的特徵就是環節多、耗時長:
- 環節多意味著在各個服務之間存在較多的網路通訊和資料互動,核心挑戰在於如何設計低耦合、高可用的服務架構;
- 耗時長一方面體現在多個環節的總耗時,另一方面體現在三個PDF生產服務各自的加工耗時。
基於以上業務特徵,PDF加工服務架構設計的一個大方向就是將長耗時任務非同步處理,各服務之間邏輯解耦,透過訊息佇列進行資料互動。
技術選型
服務端生成PDF通常有兩種方案:
- 第一種是使用 pdfkit 之類的工具透過程式碼繪製,這種方案最大的問題是可渲染的內容型別有限,定製化不足;
- 第二種是建立 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。
這個需求是我第一次使用Puppeteer,還沒完全摸透,下文涉及到Puppeteer相關的方案如果有問題,歡迎討論指點。
實現方案
智慧教育的分層架構如下:
Node.js PDF服務是本次需求新增的,為了方便分離部署和最佳化,PDF服務單獨建立一個服務,不涉及Node.js接入層的改動。下圖是個冊PDF加工的完整流程:
每個環節的具體流程不細講,Node.js PDF加工服務的細節下文詳解。與Node.js PDF服務相關最關鍵的是與Java後端的資料互動流程。Java後端與Node.js PDF服務透過 RabbitMQ 訊息佇列進行資料互動,建立兩個佇列:
佇列 | 生產者 | 消費者 | 說明 |
---|---|---|---|
任務佇列 | Java後端 | Node.js PDF服務 | Java 向佇列中傳送個冊渲染資料,Node.js 消費 |
回傳佇列 | Node.js PDF服務 | Java後端 | Node.js 向佇列中傳送pdf加工結果資料,Java 消費 |
這部分沒啥好講的,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相比,既節省了部署網頁的伺服器資源,同時速度也更快:
時間消耗 | 執行時機 | 效能瓶頸 | 其他 | |
---|---|---|---|---|
遠端URL |
|
執行時 | 網路IO | 非同步下載html引用的靜態資源會增加額外耗時 |
本地html |
|
預啟動階段 | 檔案IO+常駐記憶體 |
上文提到過,本地html檔案在預啟動階段提前從磁碟讀取存放於記憶體,執行時無需實時讀取。所以檔案IO的耗時不算在pdf加工邏輯總耗時中,而載入遠端URL只能在執行時執行,會增長pdf加工的總時長。
另外,載入的本地html檔案中不能存在靜態資源引用,比如js和css必須全部以行內