【轉】Node深入淺出 章節總結(第九章 — 玩轉程序) 完結篇
為了看黑色背景,就轉了過來,眼睛看白色螢幕實在受不鳥!
本章總結將結合個人搭建 egg 引入公司的一些實踐來進行總結,希望能讓大家瞭解到程序管理和叢集分發的重要性。 閱讀完本章你應該理解以下幾點: 為什麼要使用多程序架構啟動服務; 經典的 Master-Worker(主從模式) 介紹到在 egg 中的應用; 如何使用 child_process 模組與控制代碼(Handle)傳遞實現主從模式架構,從而實現啟動多個服務監聽一個埠; 處理高併發場景的叢集健壯性問題; 使用 Cluster 模組 (本質是對 child_process 模組實現 Master-Work 架構過程的封裝) 快速實現程序叢集; 一、為什麼要使用多程序架構啟動服務 首先我們從兩個關鍵性指標開始考慮 CPU使用率; 記憶體佔用率; 首先我們知道 js 是單執行緒的,所以 NodeJs 也不以外,啟動服務時只能運用到單核,即一個 CPU,但如今大家的伺服器或pc普遍都已經是 4 cpu 了,這就導致了另外 3 個 cpu 資源的浪費,這導致 node 的計算能力不弱都不行,而且單執行緒最大的問題還有個健壯性問題,一旦有一個錯誤沒被捕獲到,有可能就會導致整個程序的崩潰,而服務自然就斷了。 在高併發的場景下,單 cpu 的計算量是遠遠不夠的,即使 node 全非同步的概念很擅長處理密集型 io,但也是寡不敵眾啊,高併發下仍會導致響應產生延遲與耽誤的情況,這時我們就要引入多程序架構來解決這個問題了,利用其它 cpu 同時進行計算處理 io,提高 cpu 的使用率,將併發請求分發到不同程序獨立處理響應,從而解決高併發下延遲請求佇列積累過長的問題。 多個 cpu 的有沒有缺點?當然有,多個 cpu 同時計算是需要記憶體的,這個大家都懂,我們每次計算的資料結構都會在記憶體中進行處理,新生代會被立刻釋放,需要儲存的會被移動到老生代中,前面的章節已經為大家介紹過這個過程了,我就不廢話了,所以榨乾 cpu 的同時,我們一定要關注記憶體的佔用率,在其中取得一個最佳平衡。這就像是演算法一樣,時間換空間 或者 空間換時間 選擇一個,等價交換。 二、經典的 Master-Worker (主從模式) 架構
由上圖我們可以很清晰的看到整個架構的過程,建立一個主程序,然後子程序通過 fork 主程序生成,此時便解決了不充分利用 cpu 的問題了,但是高併發問題仍然沒有解決哦,因為請求的分發並沒有做處理。 在這個架構中,主程序是不負責具體的業務處理的,而是專門負責管理(socket 的分發與程序間的通訊等)和排程(請求分發、程序間的通訊分發)工作,是最穩定的一個程序,而子程序則是專門用於進行業務處理的,穩定性也是比較差的,但多程序最大的好處就是健壯性,因為其中一個子程序的崩潰並不會影響到其他子程序繼續接受分發,從而保證了主程序服務的健壯性,而後續子程序崩潰後,我們可通過監聽程序的全域性異常,繼而重啟 fork 出新的子程序,崩潰多少次則重啟多少次,但是重啟是需要至少 30ms 的啟動時間的,記憶體也是需要預留至少 10MB,因為每個子程序都是獨立的 V8 例項。所以我們不能短時間內頻繁重啟,而這些就是後話了,後面再詳細向大家介紹。 三、如何使用 child_process 模組與控制代碼(Handle)傳遞實現實現主從模式架構,從而實現啟動多個服務監聽一個埠 通過 node 建立過服務的童鞋都應該知道,http 服務我們直接使用 http.createServer 即可建立並直接監聽某個埠,但是埠被佔用的情況下,如果仍然監聽就會引起 EADDRINUSE 異常,那麼程序間是如何知道判斷我們監聽了同一埠呢,答案就是 socket 的套接字,首先大家都需要知道 http 模組本質上是 net 模組封裝而來,本質上是 tcp 協議封裝的 http 協議,其中仍然是 socket 套接字,當監聽同一埠時,tcp 服務發現此時 socket 套接字的檔案描述符是不同的,此時就異常了,所以如果我們能夠使 2 個服務使用同一個 socket 套接字,那麼檔案描述符就相同了,這就解決了這個問題,由此我們就引申除了需要解決的 2 個問題 所有子程序如何與父程序一樣都是用一個 socket 套接字? 子程序如何監聽到父程序的 socket 的連線事件從而接收到父程序所分發的內容最傳遞給子程序啟動的 http 服務呢? A1:在程序間通訊中通過 IPC 管道將控制代碼傳送 socket 給子程序,從而使子程序也可以對這個 tcp 服務的 connection 進行監聽; A2:node 子程序通過監聽 message 事件便可接受到父程序所 send 的控制代碼,在接受到 socket 控制代碼後,便可監聽到父程序 tcp 服務的 connection 事件了,當父程序的 tcp 服務被訪問時,子程序監測並將本次的 socket 通過 emit 觸發 http.server 類 的 connection 事件; 在解決方案中,我們引申出了幾個具體名詞和方法的解析,在以下會對其進行詳解,並最終放上實現過程的程式碼; 程序間如何進行普通通訊? 程序間如何傳送 socket 控制代碼,控制代碼又是什麼東西,是怎麼傳送的? http.server 類繼承與 net.server 類,理論上具有 net 所有通過 EventEmitter 類所定義事件;
問題1:程序間如何進行普通通訊
// parent.js var cp = require('child_process') var child = cp.fork('./child.js') child.send('hello world') process.on('message', function (m) { console.log('parent 程序已接收到 child 程序的' + m) }) // child.js process.on('message', function (m) { console.log('child 程序已接收到 parent 程序的' + m) })
從 demo 中我們可以看出父程序執行時會複製啟動一個新的子程序,並且我們在父程序中向子程序傳送了 hello world,當我們執行 node parent.js 時,會發現命令窗打印出了 ‘child 程序已接收到 parent 程序的hello world’ ,此時我們已經實現了父程序 => 子程序的單向通訊了。
ps: emmmmmm,我就知道會有童鞋嘗試在子程序再次 fork parent.js 然後傳送訊息,之後再 parent.js 中打印出來= =你以為這是雙向通訊嗎,騷年,這是 fork 了一個新的程序再輸出到控制檯的而已,不信你自己啟動 node 後檢視過濾出正在執行的 node 程序列表,正確操作應是在父程序中取出 fork 的子程序再次監聽該子程序的 message 事件,子程序中直接使用 process.send 即可,這樣就實現了父子程序的雙向通訊了 父 => 子 => 父。
// parent.js
var cp = require('child_process')
var child = cp.fork('./child.js')
child.send('hello world')
child.on('message', function (m) {
console.log('parent 程序已接收到 child 程序的' + m)
})
// child.js
process.on('message', function (m) {
console.log('child 程序已接收到 parent 程序的' + m)
})
process.send('hello, I"m child process')
問題2:程序間如何傳送 socket 控制代碼,控制代碼又是什麼東西,是怎麼傳送的?
其中涉及到 2 個點,IPC 通道與 控制代碼(handle)
IPC通道:IPC全稱為 Inter-Process Communication ,即程序間通訊,在 node 中是利用 pipe 實現的,看到 pipe 大家應該會想到我們讀取與寫入檔案時的流操作也利用過管道,聯想是正確的,因為在程序通訊的過程中也確實這麼做的,在父程序 send 前,node 會自動將資訊通過 JSON.stringify包裝起來後才會 send ,之後寫入 IPC 通道,通過 IPC 通道到達子程序前進行 JSON.parse 後 got 到這次傳送的訊息給子程序接收,所以我們可以將 IPC 通道理解為訊息中間管道。
控制代碼:我們在問題 1 中進行的是普通訊息的傳遞,但通過文件我們可以看到其實 send 是有第二個引數的,第二個引數便是用來發送控制代碼的,控制代碼是一種可以用來標識資源的引用,它的內部包含了指向物件的檔案描述符,所以我們傳送的控制代碼本質上就傳送檔案描述符,上面我們有提到,埠就是因為 socket 套接字的檔案描述符而出現埠被佔用的異常,而這裡傳送的控制代碼便剛好解決了這個問題,控制代碼有以下幾種,當傳送的不為以下幾種時,子程序是無法接收到的。
net.Socket TCP套接字
net.Server TCP伺服器,再 TCP 服務上的應用層都可以繼承它,如 http.server 類
net.Native C++ 層面的 TCP 套接字或 IPC 管道
dgram.Socket UDP 套接字
dgram.Native C++ 層面的 UDP 套接字
上面我們提到過,只要子程序能接收到控制代碼,便可以使用父程序的 socket 監聽 connection 事件從而去 emit 到 http 服務的 request 事件(注意是 request 事件,不是 http.clientRequest 類,也不是 http.IncomingMessage 類,而是觸發 http.server 的請求事件,即 http.createServer 建立服務後自動監聽的事件),並獲取本次請求的 http.IncomingMessage 與 http.serverResponse 類所掛載的引用,下面是 node 使用 child_process 模組後一個埠同時啟動多個服務的 demo
在以上 demo 中,我們除了使用 tcp 服務代理 http 服務以外,還為了多程序的健壯性做到了以下:
子程序因某些異常而導致的退出重啟,可見 master.js 中 work 的 exit 事件回撥;
當主程序 master 異常退出時,自動退出所有子程序,可見 master.js 中對 process exit 事件的監聽;
在子程序中監聽當前子程序的全域性異常事件,若出現異常則手動關閉子程序傳遞過來控制代碼的 tcp 套接字,並在完成後退出子程序,觸發父程序中子程序的 exit 事件達到退出現有子程序重啟新的子程序的效果;
注意:windows 系統下會出現 curl "http://127.0.0.1:1337" 子程序 request 事件回撥中一直列印的為一個 pid 的情況,而在 macOs 或 linux 等有 unix 核心的系統中會是建立的多個子程序中的隨機一個 pid,這是 windows 下才會產生的問題,我們正常應是 unix 核心中的情況,因為 node 預設提供的機制是採用作業系統的搶佔式策略,閒著的程序對到來的請求進行搶奪,誰搶到算誰的。
書中重點提到:這種搶佔式策略是根據當前程序的繁忙度來進行搶佔的,繁忙度較低的程序會先搶佔到請求,而繁忙是由 CPU 與 I/O 兩部分構成,即我們一直提到了 CPU 佔用率與 記憶體 使用率,而本處定義影響搶佔的確只是 cpu 的繁忙度,這就導致了,搶佔到的程序有可能是 I/O 繁忙,而 cpu 卻空閒的情況,這導致該程序記憶體不足而使得後續可能無法繼續維持計算所需的記憶體(即高併發場景下容易造成延遲現象,因為沒有記憶體供給 I/O 處理),這也引起了負載不均衡的現象,此時我們必須想一個新的搶佔策略來實現 cpu 與 I/O 的均衡分配,而在node V0.11 增加的 Round-Robin(輪叫呼叫)便實現了這點,cluster 模組中啟用它的方式為(一般我們都預設啟動):
cluster.schedulingPolicy = cluster.SCHED_RR
四、處理高併發場景多程序叢集健壯性的問題
關於叢集的健壯性,有幾點需要注意的
程序異常是否能夠監控記錄並作出應對;
程序崩潰時能否保證中斷本次連線後重啟出新的子程序繼續工作,保證響應不會一直延遲,而是直接丟擲錯誤;
子程序重啟過於頻繁時是是否能預警;
對於以上幾點,個人總結就是小錯提交日誌自動重啟,大錯預警及時止損,在書中提供了這麼幾個思路。
1. 使用監控程序異常的 'uncaughtException' 事件,本事件為當前程序的全域性異常監控,當監控到異常時像 master 主程序傳送自殺指令並 ‘自殺’,由 master 統一管理程序的重啟,分別對應的程式碼為:
// worker.js
process.on('uncaughtException', function (err) {
// 錯誤日誌記錄
// logger.error(err)
process.send({ act: 'suicide' })
// 停止接受新的連線
worker.close(function () {
// 所有已有連線斷開後,退出程序
process.exit(1)
})
// 為了防止 tcp 長連線斷開時間過長,此處設定一個超時機制,超過一定時間如果當前 process 還存在,則手動退出
setTimeout(() => {
process.exit(1)
}, 5000)
})
// master.js 在建立程序的函式 createWorker 中插入監聽事件,當監聽到時,同步進行新子程序的建立與舊子程序的連線中斷與程序退出
worker.on('message', function (message) {
if (message.act === 'suicide') {
createWorker()
}
})
這 2 個事件的監控已經處理完了日誌監控與中斷連線退出異常程序並重啟的過程,原理很簡單,程序內全域性監控異常,有異常時直接中斷連線,保證所有連線中斷後退出程序,在這個過程進行的同時,通知 master 建立新程序,兩個任務同步進行以保證負載均衡不會被打破,新的請求仍可以立刻分發到新的子程序。
需注意文件已經有說明不要嘗試恢復舊程序的應用了,所以書中選擇直接退出舊程序,建立新程序
正確地使用 uncaughtException
2. 如果遇到業務異常導致子程序頻繁無限重啟該怎麼辦?
這種情況的發生比較少,一般極有可能是業務程式碼出錯,否則不會無限頻繁報異常,單純的記憶體分配不足只會造成延遲等待,如果cpu 負載運轉率過高,可能系統已經自動 kill 掉主程序了,子程序會全部被正常殺死(demo 中可看出非異常退出不會造成自動重啟,而是直接 emit 程序的 exit 事件),這都是正常的處理情況,此時收到預警的運維童鞋重啟服務即可。
但如果是業務模組的錯誤導致重啟頻率過高,這時我們檢視日誌發現一直是一個異常,且因為頻繁重啟查過限定後導致的主程序手動觸發退出,就要注意了,下面為完整的 createWorker 與判斷重啟頻率函式 isTooFrequently
// master.js
var fork = require('child_process').fork
var cpus = require('os').cpus()
var server = require('net').createServer()
server.listen(1337)
var limit = 10;
var during = 60000;
var restart = [];
var isTooFrequently = function () {
var time = Date.now()
var length = restart.push(time)
// restart 陣列永遠只取最後限定條數
if (length > limit) {
restart = restart.slice(-1 * limit)
}
// 最後的重啟次數大於或等於限定次數且最後一次重啟距離 limit 第一條的重啟時間如果小於單位時間,則代表單位時間內重啟太頻繁了
return restart.length >= limit && restart[restart.length - 1] - restart[0] < during
}
var workers = {}
var createWorker = function () {
// 檢測是否重啟太過頻繁
if (isTooFrequently()) {
process.emit('giveup', length, during)
return ;
}
var worker = fork(__dirname + '/worker.js')
worker.on('message', function (message) {
if (message.act === 'suicide') {
createWorker()
}
})
// 監聽子程序的退出,若退出則重新建立一個新的子程序
worker.on('exit', function () {
console.log('Worker ' + worker.pid + ' exited.')
delete workers[worker.pid]
createWorker()
})
// 傳送控制代碼
worker.send('server', server)
workers[worker.pid] = worker
console.log('Create worker. pid: ' + worker.pid)
}
for (var i = 0; i < cpus.length; i++) {
createWorker()
}
process.on('giveup', function (count, time) {
// logger.error(`子程序重啟過於頻繁自動退出主程序,請緊急處理,${time}ms內重啟次數為: ${count}`)
process.close(function () {
process.exit(1)
})
setTimeout(() => {
process.exit(1)
}, 5000)
})
// 監測主程序的退出,當主程序退出時,主動退出所有子程序
process.on('exit', function () {
for (var pid in workers) {
workers[pid].kill()
}
})
每次建立新的子程序時,會使用 isTooFrequently去做判斷,判斷是否頻率過高需要滿足 2 個條件
重啟的次數超過限定次數(存在重啟次數早已大於限定,但時間頻率未達到的情況,所以我們可以看到 restart 每次都會取最後 limit 的位長度,保證了未到達重啟次數限定時不可能算 “太頻繁”)
在超過限定次數的前提下,且滿足 restart 陣列中最後一位 - 第一位 的值小於初始設定的單位時間值 during(restart 陣列每次 push 的是重啟時的時間戳,時間戳相減即可得到 limit 次重啟的單位時間)
如果符合我們規定的頻率過高設定,則觸發自定義的 giveup 事件,書中程式碼未監聽 giveup 事件,結果上我們應該監聽 giveup 事件並寫入預警函式以及特別嚴重的錯誤提示,告知運維童鞋緊急處理,在本總結上面的 demo 中我已加入對 giveup 事件的監聽,並設定等待連線中斷的限定時間,process 是 EventEmitter 的例項,所以大家大可隨意使用訂閱釋出模式的事件訂閱與釋出,別再小白的問為什麼可以自定義訂閱事件名了~~~。
五、cluster模組的應用
在使用 child_process 模組實現主從模式的單機叢集架構時,我們會感覺比較繁瑣,要反覆進行主從程序間的通訊,並且訂閱很多自定義事件去做如異常監控、退出重啟等功能日誌,還要自己建立 tcp 服務,監聽與傳送控制代碼的繁雜的操作,對於一個經驗較少的工程師來說是很容易搞錯其中一步的,所以 Node 從 V0.8 版本開始新增了 Cluster 模組來解決這個問題,egg 也是使用 Cluster 模組建立的主從模式,並在 master => worker 中間加了一層代理層用於一些公共的業務處理,以求不用讓每個子程序都去執行重複程式碼。
以下是書中的兩種方式,書中不太推崇官方文件的建立方式,認為官方的建立方式比較繁瑣,需要使用者自己判斷是 master 還是 worker 程序,但我個人反而覺得官方的方式比較清晰、明瞭。
// 書中樸老師比較推崇的使用 setupMaster 的方式
var cluster = require('cluster')
cluster.setupMaster({
exec: 'worker.js'
})
var cpus = require('os').cpus()
for(var i = 0; i < cpus.length; i++) {
cluster.fork()
}
// 官方的方式
var cluster = require('cluster')
var http = require('http')
var numCPUs = require('os').cpus().length
if (cluster.isMaster) {
for (var i = 0; i< numCPUs; i++) {
cluster.fork()
}
cluster.on('exit', function (worker, code, signal) {
console.log(`worker ${worker.process.pid} died`)
})
} else {
console.log('current process pid is: ' + process.pid)
http.createServer(function (req, res) {
res.writeHead(200)
console.log(process.pid)
res.end('hello world\n')
}).listen(8000)
}
比較兩種方式的區別是,官方的方式需要我們自行判斷當前 cluster 建立的程序是 master 還是 worker,然後進行不同的事件監聽,個人認為官方的方式更讓我覺得清晰,比較貼近原本 child_process 模組與建立 tcp 服務傳送控制代碼的原理,會感受到整個過程是 “可控的” ,而樸老師的方式雖然非常簡潔,但是卻容易讓新手產生摸不著門路的困惑感。
基本 egg master => agent => worker 這一套多程序架構就跟我們上面介紹的建立 tcp 服務並使用 child_process fork 子程序的思想是一樣的,只是換成了 Cluster 模組來實現,免去了繁瑣的建立 tcp 服務與傳送控制代碼這個過程與監控 process exit 等過程,繼而全部由 cluster 模組的 api 與事件來實現,大家看完本章再去看 egg 的這節文件就會發現文件中寫的東西都非常清晰了。
egg —– 多程序模型和程序間通訊