Node.js的可伸縮性

文章翻譯子Scaling Node.js Applicationshtml

你應該知道全部關於Node.js的可伸縮性node

scalability.png

可伸縮性並非擴展Node.js應用的第三方包的性能,它是Node.js(Javascript運行時環境)的核心功能。Node.js取名爲節點(Node),這強調Node.js應用能夠經過相互通訊的分佈式節點向外提供服務。linux

你是否將你的Node.js應用部署在多個微服務上?你是否爲生產環境cpu的每一個內核啓動一個Node.js進程?你是否對已經啓動的Node.js進程作負載均衡?你是否知道Node.js的內置模塊能夠幫助你實現上述功能?redis

Node.js集羣(cluster)模塊不只爲充分利用服務器cpu性能提供一種開箱即用的解決方案,並且能夠提高Node.js進程的性能,還能夠在零停機的狀況下重啓服務器。這邊文章不只涵蓋上述全部內容,並且還有更多不爲人知的知識。算法

可伸縮策略

對應用作負載均衡,能夠加強應用的可伸縮性,但這並非惟一的緣由。負載均衡還能夠加強應用的可用性,提升對Node.js應用的生命力(不會由於單個Node.js進程阻塞,而致使Node.js應用死亡)。數據庫

克隆

提升應用可伸縮性的最簡單方式是將應用克隆不少次,與克隆後的應用一塊兒分擔外部的數據請求(負載均衡)。這種策略不會增長開發的時間,可是很高效。使用Node.js的cluster(集羣)模塊,可使開發者經過最小的開發量對單進程的服務實現克隆策略。apache

分解

根據應用的功能或者服務對程序進行分解,從而提供應用的伸縮性。這就意味着將會有多個應用程序,這些應用程序可能由不一樣的代碼構成、鏈接不一樣數據庫和對外提供不一樣API接口。windows

這個策略一般是將多個微服務聯合在一塊兒,其中「微」的字面意思是服務儘量的小。在現實場景下,服務的大小並非最重要的。可是各個服務必須是高內聚、低耦合的。數組

這種策略實施起來並不容易,可能長期存在不可預測的風險,可是在開發中充分運用這一特性依舊是很是必要的。緩存

切割

將應用根據切割成多個實例,每一個實例僅僅負責一部分應用的數據。這種策略也叫作數據庫的水平分區或水平分片。數據分區在操做前須要進行一次查表,經過查詢的結果,調用相應的分區或分片。例如:根據用戶的國家或語言切割用戶,在每次調用數居前,要去查詢用戶的國家或者用戶的語言。

實現可伸縮性的大型應用,最終都會使用上述三種策略。Node.js能夠很容易實現上面三種策略,可是在這篇文章中僅僅集中在克隆策略以及對克隆應用的Node.js內置工具作一些探索。

請注意你在閱讀本篇文章前須要對Node.js的子進程有一些必要的瞭解。若是尚未深刻理解,我推薦你閱讀我另一篇文章:

你應該知道的Node.js子進程

集羣模塊

集羣模塊充分挖掘服務器多核cpu的物理性能,實現負載均衡。它使用子進程模塊的fork方法,衍生出與服務器內核數量的子進程。當有外部向主進程多個請求時,集羣模塊將請求均勻分配給衍生子進程。

集羣模塊是Node.js爲開發者提供的在單服務器加強應用伸縮性的的「幫助器」。若是你的服務器有足夠的物理資源?若是對服務器增添物理資源的成本小於添加多臺服務器?集羣模塊將是快速克隆應用的最好選擇。

即使小服務器也會有多核cpu?即便你不擔憂你的Node.js服務的負載?你都應該使用集羣模塊來提升服務的伸縮性以及加強服務的容錯能力。對於進程管理工具PM2來講,只要在PM2命令後添加一個參數,就能夠上述的功能。

本文將着重介紹如何使用Node.js原生模塊實現負載均衡:

集羣模塊的工做原理很簡單。開發者先建立一個主進程,而後經過fork方法衍生出多個工做進程,當請求數據時主進程控制子進程的調度。每一個工做進程都是應用的一個實例,全部的請求都由主進程分配給子進程處理。

master.png

主進程使用輪詢調度算法(roud-robin algorithm),對子進程分配請求的任務。除了Windows,全部平臺都支持集羣模塊。開發者還能夠在全局自定義的調度算法。

輪詢調度算法(roud-robin-algorithm)虛擬全部可用進程首尾相接造成圓,第一個請求分配給圓上第一個子進程,第二個請求分配給圓上第二個子進程,以此類推。當圓上最後一個子進程分配請求後,調度算法將從圓上第一個進程開始分配請求任務。

輪詢調度算法是最簡單和最實用的調度算法,可是還有其它選擇。最有特點的算法是能夠根據任務的優先權選擇負載最小的進程或響應最快的進程。

對HTTP服務作負載均衡

下面是對Node.js的hello-word代碼作一些簡單修改,在響應請求前作大量計算的代碼:

// server.js
const http = require('http');
const pid = process.pid;

http.createServer((req, res) => {
  for (let i=0; i<1e7; i++); // simulate CPU work
  res.end(`Handled by process ${pid}`);
}).listen(8080, () => {
  console.log(`Started process ${pid}`);
});
複製代碼

爲了驗證咱們建立的平衡器是否能夠工做,將進程pid放到HTTP響應對象中,根據進程的pid決定哪一個子進程處理請求。

使用cluster模塊將主進程克隆成多個子進程前,先測算Node.js主進程服務每秒鐘能夠處理的請求數量並將測算的值做爲性能基準。咱們可使用Apache benchmarking tool。當啓動上面的node.js服務後,執行下面的ab命令:

ab -c200 -t10 http://localhost:8080/

這條命令在10秒鐘向服務器併發請求200次。

benchmark.png

在個人設備上,單節點服務器每秒處理51次請求。因爲不一樣設備的性能表現並不同,這也僅僅是個簡單的測試,並非百分之百正確。可是做爲性能基準能夠與對服務作集羣后的性能做對比。

保留上面的server.js文件,咱們建立新的文件cluster.js做爲主進程:

// cluster.js
const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  const cpus = os.cpus().length;

  console.log(`Forking for ${cpus} CPUs`);
  for (let i = 0; i<cpus; i++) {
    cluster.fork();
  }
} else {
  require('./server');
}
複製代碼

在cluster.js中,咱們首先引用了cluster模塊和os模塊。使用os.cpus()獲取運行服務器的cpu數量。

集羣模塊提供一個布爾類型的isMaster變量肯定cluster.js文件是否是做爲主進程。程序第一次執行這個文件時,isMaster變量是true,cluster.js文件將會做爲主進程。這種狀況下,主進程將會衍生出與cpu數量相等的子進程數。

當在主進程中執行cluster.fork函數後,在子進程中將會再次執行當前文件(cluster.js)。在子進程中執行cluster.js時,變量isMaster的值爲false,此時子進程中存在另一個值爲true的變量isWorker。

子進程運行的應用是真正向外提供服務的程序。在那裏須要咱們寫真正的服務邏輯,例如上面的例子中,我引用的server.js是真正響應請求的代碼。

Node.js集羣模塊實現應用的伸縮性的代碼基本就是這樣。經過集羣模塊開發者能夠充分利用服務器的物理性能。要測試集羣模塊,能夠運行cluster.js:

cluster.png

個人機器的cpu是8核的,所以Node.js開啓了8個進程。**注意這裏的進程與普通Node.js進程是不徹底相同的,**每一個進程都有獨立的事件循環機制和內存空間。

當咱們屢次請求服務時,這些請求將會被分配給不一樣的進程進行處理。因爲集羣模塊在選擇子進程處理請求時會作一些優化,所以主進程並不會嚴格按照順序輪詢子進程響應請求,可是請求的負載將會被分配給不一樣的子進程上。

咱們可使用與上面同樣的ab命令,測試集羣模塊負載均衡的性能:

advanced-performance

經過集羣模塊優化後,部署在我機器上的服務每秒鐘能夠處理181次請求。而只使用Node.js主進程的服務每秒鐘僅僅能夠處理51次請求。咱們僅僅對這個簡單應用修改了幾行代碼就讓性能翻了不少倍。

向全部子應用廣播消息

注意這裏所說的子應用是指子進程中的應用

因爲集羣模塊是使用child_process.fork衍生子進程,這樣就能夠經過主進程與子進程之間的通訊管道,實現主應用與子應用的通訊。

根據上面server.js/cluster.js例子,使用cluster.workers獲取子應用的集合。這是指向全部子應用的引用,能夠獲取全部子應用的信息,只要使用for循環子應用就能夠向全部的子應用廣播消息。例如:

Object.values(cluster.workers).forEach(worker => {
  worker.send(`Hello Worker ${worker.id}`);
});
複製代碼

經過Object.values獲取cluster.workers中的全部子應用對象,而後for each便利這些子應用,最後使用send函數向全部子應用廣播消息。

在子應用中(例子指的是server.js),對全局進程對象註冊message事件,能夠獲取來自主進程發送的消息。例如:

process.on('message', msg => {
  console.log(`Message from master: ${msg}`);
});
複製代碼

下面是對cluster/server作兩個額外測試的結果:

aditional-performance

能夠看出兩點:

  • 每一個子應用都從主應用那裏獲取了信息
  • 子應用並非按順序分配外部請求的

接下來咱們對示例代碼作更接近實際應用的修改:請求服務獲取數據庫中user表中的數據。經過mock函數返回數據表中用戶數,每次調用mock函數都會返回當前cout變量的平方值:

// **** Mock DB Call
const numberOfUsersInDB = function() {
  this.count = this.count || 5;
  this.count = this.count * this.count;
  return this.count;
}
// ****
複製代碼

爲了不屢次請求數據庫,咱們每一個隔一段時間作一次數據庫緩存,例如10秒鐘。然而我並不想衍生的8個子應用每隔10秒鐘分別向數據庫請求一次。咱們能夠在主應用中向數據庫發起請求,而後將請求獲得的數據經過通訊接口傳遞給8個子應用。

在主應用中,咱們能夠像下面使用forEach函數向8個應用廣播主應用請求的數據:

// Right after the fork loop within the isMaster=true block
const updateWorkers = () => {
  const usersCount = numberOfUsersInDB();
  Object.values(cluster.workers).forEach(worker => {
    worker.send({ usersCount });
  });
};

updateWorkers();
setInterval(updateWorkers, 10000);
複製代碼

當第一次調用updateWorkers函數後,setInternval函數每隔10秒調用一次updateWorkers函數。這樣主應用就能夠每隔10秒都會訪問一次數據庫,而後經過通訊管道傳輸向子應用廣播訪問請求數據庫的數據。

在服務端的代碼中,咱們經過註冊message事件獲取主應用傳輸的usersCount值。使用全局變量緩存usersCount數據,這樣就能夠隨時使用usesCount變量。

例以下面代碼:

const http = require('http');
const pid = process.pid;

let usersCount;

http.createServer((req, res) => {
  for (let i=0; i<1e7; i++); // simulate CPU work
  res.write(`Handled by process ${pid}\n`);
  res.end(`Users: ${usersCount}`);
}).listen(8080, () => {
  console.log(`Started process ${pid}`);
});

process.on('message', msg => {
  usersCount = msg.usersCount;
});
複製代碼

當有外部請求時,將usersCount做爲響應對象。若是如今要測試集羣,在剛開始的10秒內你得到的users count數據爲25.下一個10秒內你得到的users count數據爲625。

所以很是感謝主進程與子進程的信息管道,讓集羣有了通訊基礎。

提高服務的可用性

在服務器上僅僅部署一個實例服務對象會存在下面的缺點:若是服務的實例對象崩潰了,服務必需要在重啓後才能繼續對外提供服務。即使進程能夠自動重啓服務,這也意味着在服務崩潰後和重啓前存在一個時間段。

另外重啓服務部署新的代碼也會存在一樣的問題。只要是僅僅經過一個實例(節點),服務停機的時間就會影響應用的可用性。

若是服務有多個實例(節點),程序就能夠經過簡單幾行代碼加強服務的可用性。

在setTimeout函數中設置隨機的時間後調用process.exit函數,模擬服務進程隨機崩潰:

// In server.js
setTimeout(() => {
  process.exit(1) // death by random timeout
}, Math.random() * 10000);
複製代碼

若是做爲服務的子應用崩潰了,主應用經過在cluster對象上註冊exit事件獲取子應用退出的信息。當子應用退出程序時,主應用在註冊事件的回調函數中從新衍生出一個新的子應用。例如:

// Right after the fork loop within the isMaster=true block
cluster.on('exit', (worker, code, signal) => {
  if (code !== 0 && !worker.exitedAfterDisconnect) {
    console.log(`Worker ${worker.id} crashed. ` +
                'Starting a new worker...');
    cluster.fork();
  }
});
複製代碼

最好在上面代碼的基礎上加上一個條件,在子進程在開發者手動斷開鏈接或是被主進程故意殺死的狀況下,主進程不會從新衍生新的進程。例如,主進程根據負載模式發現應用使用太多的資源,它可能會主動殺死一些子進程。在這種場景下,變量existedAfterDisconnect的值是true。以下面的程序:

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
    const spus = os.cpus().length;
    for (let i=0; i< cpus; i++) {
        cluster.fork();
    }
    
    cluster.on('exit', (worker, code, signal) => {
        if (code !== 0 && !worker.existedAfterDisconnect) {
            console.log('Worker ${worker.id} crashed. ' + 'Starting a new worker ....' );
        
            cluster.fork();
        }
    });
} else {
    require('./server')
}
複製代碼

部署上面的代碼,子進程在隨機時間內會崩潰,主進程當即衍生出新的子進程以提升應用的可用性。因爲一些請求不可避免的要面對子進程的崩潰,所以程序不可能對全部的請求作響應。開發者能夠經過ab命令測量應用的可用性:

availability.png

經過測試百分之九十九的請求均可以獲得響應。僅僅經過簡單的幾行代碼,開發者就不在擔憂程序的崩潰。主進程就像眼睛同樣替開發者盯住進程的運行情況。

重啓零停機

當咱們須要部署新的代碼,如何實現零停機重啓服務?

當不少子進程正在運行,不是將它們所有重啓,一次僅僅重啓一個子進程。這樣就能夠保證在子進程重啓時,其它的子進程仍然能夠處理外部請求。

使用Node.js內置模塊cluster能夠很容易實現上述示例。若是主進程一旦啓動,咱們不想在此重啓主進程。在這種狀況下,咱們須要向主進程發送命令,讓這條命令指揮它重啓子進程。在linux系統上可使用下面的方式實現:先在主進程上監聽SIGUSR2事件,開發者可使用"kill 進程的pid"命令觸發主進程監聽的SIGUSR2事件。實現以下:

// In Node
process.on('SIGUSR2', () => { ... });


// To trigger that
$ kill -SIGUSR2 PID
複製代碼

經過上述方式,能夠在不殺死主進程的狀況下,經過命令引導主進程工做。因爲SIGUSR2信號是用戶命令,所以這條命令很是適合向主進程傳遞信號。若是你對爲何不使用SIGUSR1信號有疑問?這是由於Node.js使用SIGUSR1信號作debugger調試,不是使用它主要是避免發生衝突。

然而不幸的是在windows系統上並不支持上述的進程信號,咱們必須經過其它方式引導主進程。我這裏有一些替代方案。例如,1.使用標準的輸入或套接字輸入。 2. 監聽進程的pid文件的存在和刪除事件。這裏爲了讓示例更簡單,我僅僅假設Node.js服務是部署在Linux系統上。

Node.js服務在Windows系統上能夠很好的工做,可是我認爲將服務部署在Linux系統上是更安全的選擇。這不可是因爲Node.js自身的緣由,並且許多生產環境的工具在Linux系統上更加穩定。以上僅僅是一家之言,你能夠徹底忽略

順便說一下,在最近的Windows系統上能夠安裝Linux系統。我在Windows的子linux系統上測試過,並無明顯的性能改進。若是正在使用的生產環境是Windows系統,你能夠查一下Bash on Windows,而後試一試Windows中子Linux的系統表現。

讓咱們再次回到最上面的例子上,當主進程接收到SIGUSR2的信號時,這就意味着是時候重啓子進程了,而且要求每次僅僅重啓一個子進程。

在開始任務前,須要使用cluster.workers函數獲取當前子進程的引用,並將它保存在數組中:

const workers = Object.values(cluster.workers);

而後,向restartWorker函數中傳遞將要重啓的子進程在子、進程數組中的序號。而後在函數中遞歸調用restartWorker函數,傳遞的參數是當前進程的序號加一,這樣就能夠實現按順序重啓子進程。下面是我使用的restartWorker函數代碼:

const restartWorker = (workerIndex) => {
  const worker = workers[workerIndex];
  if (!worker) return;

  worker.on('exit', () => {
    if (!worker.exitedAfterDisconnect) return;
    console.log(`Exited process ${worker.process.pid}`);
    
    cluster.fork().on('listening', () => {
      restartWorker(workerIndex + 1);
    });
  });

  worker.disconnect();
};

restartWorker(0);
複製代碼

在restartWorker函數中,咱們獲取子進程的引用後重啓子進程。因爲程序須要按照子進程在子進程數組中的位置遞歸調用restartWorker函數,所以程序須要一個終止遞歸的條件。當程序全部子進程都已經重啓後,調用return結束函數。使用worker.disconnect函數終止子進程,可是在重啓下一個子進程前須要衍生出新的子進程代替正在終止的子進程。

對當前進程註冊exit事件,當前進程退出時,觸發該事件。可是開發者必須確保退出子進程的行爲是因爲調用disconnect函數。若是exitedAfetrDisconnect變量是false,說明子進程不是因爲調用disconnect函數致使的。這是直接調用return,再也不繼續往下處理。若是exitedAfetrDisconnect變量是true,程序繼續往下執行而且衍生出新的子進程代替正在退出的進程。

當衍生新的子進程後,程序繼續重啓子進程數組中下一個進程。可是衍生的子進程函數並非同步的,所以不能在調用衍生函數後直接重啓下一個子進程。然而,程序能夠在衍生函數後註冊listening事件。當新的衍生進程正常工做後觸發listening事件,而後程序就能夠按順序重啓子進程數組中下一個進程。

爲了測試上述代碼,咱們應該先獲取主進程的pid,而後將它做爲SIGUSR2信號的參數。

console.log(Master PID: ${process.pid});

啓動集羣服務,獲取主進程的PID,而後使用kill -SIGUSR2 PID命令重啓子進程。在重啓子進程期間,使用ab命令測試程序的性能表現。測試結果發現沒有丟失任何一個請求:

reatart-process

在生產環境下,我一般使用進程檢測器(PM2)。PM2提供許多監測Node.js應用的命令,使用PM2處理各類任務都很是簡單。例如:只要在命令參數後面添加-i,就能夠對應用使用集羣功能:

pm2 start server.js -i max

若是須要實現零停機重啓子進程,可使用下面命令:

pm2 reload all

共享狀態和粘滯負載均衡

事物老是有利有弊。對Node.js應用作負載均衡時,應用必然會失去單進程所具備的許多特性。就像其它開發語言都要面對如進程間共享數據這樣的進程安全問題。在咱們這裏,就是多個子進程共享數據的問題。

例如啓動集羣后,因爲每一個子應用都有本身的內存,所以不能將應用數據緩存在子應用的內存中。若是開發者將數據緩存在子應用的內存中,其它子應用將沒有權限訪問這些數據。

若是須要緩存集羣應用的數據,開發者須要使用獨立的實體,這個實體能夠向全部子應用提供讀/寫數據的API。這個實體能夠是數據服務、若是你喜歡使用內存緩存,可使用redis數據服務或者建立一個提供讀/寫API的Node.js進程,幫助子進程相互通訊。

儘管如此,也不要認爲使用獨立的數據服務是集羣的弊端。使用獨立的緩存服務是經過分解策略加強Node.js應用的可伸縮性的一種方式。即使Node.js應用部署在單核服務器上,也推薦開發者使用這種方式實現數據分離。

除了數據緩存,狀態通訊也會存在問題。例如在集羣應用中,某個子服務對外部請求作狀態標記後,並不能確保當該外部客戶端再次發送請求時,該請求能夠分配給與上次相同的子服務上。所以在子服務上建立程序的狀態標記並非好選擇。

最多見的問題就是用戶的認證:

假設集羣服務將外部請求分配給子應用A:

在子應用A上標記了該用戶的狀態。然而,當同一個用戶再次請求服務時,主進程可能會將請求任務分配給沒有標記用戶狀態的子進程。這時將用戶認證的對話保存在一個子進程中,程序將不能工做。

這個問題其實有不少中解決方式:可能將用戶的認證狀態保存在共享數據庫(如Redis)。然而使用這種策略須要對代碼作出改動,所以並不總會選擇這種方案。

注意:若是開發者不想經過修改代碼在共享數據庫中存儲請求信息,其它的方式會很低效。這裏能夠考慮粘滯負載均衡方案。因爲許多負載均衡器都支持這種策略,所以能夠很輕鬆完成對應的代碼。原理其實很簡單:若是用戶的會話信息保存在子進程A上,在主進程會存儲會話與進程的信息。

當相同用戶再次請求服務時,程序會在主進程的索引表上查詢存儲該用戶會話信息的進程,而後將請求任務分配給該子進程。這裏咱們並無對請求實現負載均衡,若是修改程序受限的場景下,這也是不錯的選擇。

Node.js集羣模塊並不支持粘滯負載均衡,可是許多負載均衡器的默認配置是支持它的。

這就是關於這個主題的所有內容,感謝您的閱讀。

相關文章
相關標籤/搜索