如何編寫穩定的Node.js服務 原 薦

近一年沒發文章了,因爲事情很多。

之前用Golang寫過一個計劃工作任務的調度系統,當時的思路,所有任務以JSON發佈(更新),然後要執行的程序(處理邏輯)包含在任務的URL中進行處理,可參考這個《GoTasks》。爲何沒考慮將任務的處理邏輯放在Golang中進行處理呢?主要有幾個顧慮:

  1. Golang的Goroutine,在當時的版本是語言內部自己管理和調度的,當時版本沒有明確的接口去進行管理。而根據服務運行監控的監控結果,服務實際運行中,Goroutine數量被實際調用和預留數量是非常龐大的。
  2. Golang屬於靜態編譯語言,如果要將任務的處理邏輯發佈在項目中,碰到更新、細節調整,每次都要build還是比較麻煩的,最好的做法是golang調用腳本,但這些已經超出當時做那個項目的設想範圍了。

機緣巧合,2017年又一次碰到了類似的需求,但因爲單個任務的數據量非常大,最好能在取得原始數據後,進行相應的數據分析和拆解,然後再進行存儲,而且拆解完的數據,會成爲另一個計算的基礎,進而觸發另一個任務的執行。這就要求,必須在工作任務(worker)中,可以直接編寫處理邏輯。

最初還是想用golang再續前緣的,但因爲日常工作(前端打包環境)已經經常接觸到Node.js了,就尋思着,爲何不用Node.js來實現一套呢?

ChildProcess模式

Node.js作爲腳本運行環境,本身速度並不差。異步模式,也提供了非阻塞的I/O處理。不過,不過,異步並不代表不存在計算阻塞的問題。

什麼是計算阻塞呢?我們寫了一段程序,從上至下,中間不存在異步、多進程的執行,中間也許有一塊操作,需要循環N次,進行一些操作,取得下一部分程序所需的數據。在這裏,循環N次,只是計算阻塞的一種現象的描述,任何併發、順序式、密集型計算,都可能造成計算阻塞。

在傳統語言,特別是靜態編譯型語言,在計算阻塞方面所佔用的計算時間消耗,小到幾乎可以忽略(比如用Java循環10萬次所需要的運行時間消耗,是PHP和Node.js這種腳本級語言直流口水的)。大家幾乎不會察覺,也完全感受不到。

即使在PHP環境下,這種阻塞,也幾乎可以略過,因爲終歸PHP中調用較多的core類庫和函數,底層實現還是C和C++的(很多還是宏和Alias),當然,那些某某框架,另當別論(所以始終我對composer的機制還是有所保留)。

但在Node.js環境下,問題就很赤裸裸。首先,node.js中,JS調用的多數是另一個用戶編寫的函數,或者npm庫中的另一個用戶寫的函數。其次,是npm繁殖的速度,以及各個類庫相互調用之深。所以在Node.js中,計算阻塞,會成爲一個很突出的問題。

特別當設計一個服務,需要精確的時間控制時,計算阻塞直接帶來的問題是,造成每一個時間循環的延遲和超時,問題相當明顯(最初用Golang做GoTasks之前,其實是先用Node.js做了一個原型,也碰到過這個問題,但當時立刻就放棄了Node.js的方案)。

經過多番嘗試和總結,解決這種計算阻塞的最佳模式,就是ChildProcess模式(下簡稱CP模式)。

其實從Node.js最早公佈時,就存在CP模塊,CP提供同步和異步兩種模式,主進程和子進程之間也有完整的通信機制。但使用思路,並不是如多數的分享文章,即一開始就直接創建多個CP,因爲無論你初始化多少個Process,只要他們執行程序是一樣的,計算阻塞的問題都依然存在,依然的得不到解決。

解決的思路應該是,主進程,只負責主調度,當遇有計算阻塞的可能時,以CP異步模式調度出一個新的進程,進行密集型運算,最終將異步處理結果方式取回到主程序。CP處理完畢,立刻退出釋放掉。

是的,解決思路本質上其實還是Node.js的異步機制,但不是以主進程require某個module的方式進行,而是異步出一個子進程,只是調用Module的思路進行一種轉換。

Node.js的CP模式,有以下的顯著優點:

  1. 子進程是系統進程,可編程控制,比Goroutine的黑箱更透明(也許Golang現在已經提供API了吧)。
  2. 子進程,計算性能更靈活,你需要掌握的是調度任務的數量,就能充分控制CPU運算所佔用的比例。
  3. 穩定性極佳,這個任務系統,從發佈上線,丟在那裏大半年沒人管,從未間斷過。
  4. 子進程可以幾乎完全共享主進程的node_modules,也包括項目內的所有源代碼。
  5. Node.js的CP接口更標準,完善,傳統語言處理系統進程之間的通信,oh my god,你需要學習系統底層系統進程之間通信的內容。

use Promise but not foreach

雖然Node.js是提倡異步,誠然你的代碼也的確是使用異步函數。但,當你面臨需要處理大量的循環,每個循環都需要進行相關的異步I/O操作時,請使用Promise,而不是foreach。

這種場景很常見,比如:

let rows = [/**假設這裏有很多記錄**/];

rows.forEach(async (row, i) => {
    let query = await db.query('....', [row.id]); // 查詢是否有重複的記錄
    if (query.rows.count > 0) { // 如果有
       let isUpdate = await db.update('....', [row]); // 更新操作
    } else {
       let isInsert = await db.insert('....', [row]); // 插入操作
    }
});

這個代碼表面上是沒有問題的,但執行上是存在I/O的性能問題的。在實際執行的時候,forEach的循環,會在一個瞬間執行完畢,因爲所要執行的操作都是異步的,循環並不會阻塞在每一行數據處理中。這就帶來一個問題,如果rows數量上萬,就會在一瞬間發起10000個I/O異步操作,執行的結果,你會看到靠前的操作先回來,可是越靠後的操作,返回越慢(也可能造成I/O產生阻塞,靠後的異步操作掛起或者操作失敗)。因爲雖然你調用的是異步方法,但你沒有控制循環的過程。

這種情況下,最好的處理方式是,是使用Promise(或尾遞歸)來處理每一行,等該段處理完畢,再開始下一個(實際程序,你可以根據實際情況控制一次處理的數量)。

let rows = [ /**假設這裏有很多記錄**/ ];

let handleRow = (row) => {
	return new Promise(async (resolve) => {
		let isSuccess = false
		if (typeof row !== 'undefined' || row !== null) {
			let query = await db.query('....', [row.id]); // 查詢是否有重複的記錄
			if (query.rows.count > 0) { // 如果有
				isSuccess = await db.update('....', [row]); // 更新操作
			} else {
				isSuccess = await db.insert('....', [row]); // 插入操作
			}
		}
		resolve(isSuccess);
	});
};

// Promise 模式
let promiseStart = () => {
	if (row.length > 0) {
		const row = rows.shift();
		handleRow(row).then(isSuccess => {
			promiseStart();
		});
	} else {
		// complete
	}
};

promiseStart();

use async/await

使用async/await,能大大簡化異步回調函數的嵌套問題。呃,特別是涉及到數據庫事務提交,多段數據庫查詢,乃至包含其他的異步I/O操作,異步回調的嵌套簡直就是噩夢。

不過使用async/await,記得用try ... catch 來捕獲異步的異常,不然……

結尾

關於如何編寫穩定的Node.js服務,其實還有很多細節,本文先分享一些易於總結的經驗,以後再分享其他方面的經驗。

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