Node.js的可伸縮性

spursyy發表於2019-02-26

文章翻譯子Scaling Node.js Applications

你應該知道所有關於Node.js的可伸縮性

scalability.png

可伸縮性並不是擴充套件Node.js應用的第三方包的效能,它是Node.js(Javascript執行時環境)的核心功能。Node.js取名為節點(Node),這強調Node.js應用可以通過相互通訊的分散式節點向外提供服務。

你是否將你的Node.js應用部署在多個微服務上?你是否為生產環境cpu的每個核心啟動一個Node.js程式?你是否對已經啟動的Node.js程式做負載均衡?你是否知道Node.js的內建模組可以幫助你實現上述功能?

Node.js叢集(cluster)模組不僅為充分利用伺服器cpu效能提供一種開箱即用的解決方案,而且可以提升Node.js程式的效能,還可以在零停機的情況下重啟伺服器。這邊文章不僅涵蓋上述所有內容,而且還有更多鮮為人知的知識。

可伸縮策略

對應用做負載均衡,可以增強應用的可伸縮性,但這並不是唯一的原因。負載均衡還可以增強應用的可用性,提高對Node.js應用的生命力(不會因為單個Node.js程式阻塞,而導致Node.js應用死亡)。

克隆

提高應用可伸縮性的最簡單方式是將應用克隆很多次,與克隆後的應用一起分擔外部的資料請求(負載均衡)。這種策略不會增加開發的時間,但是很高效。使用Node.js的cluster(叢集)模組,可以使開發者通過最小的開發量對單程式的服務實現克隆策略。

分解

根據應用的功能或者服務對程式進行分解,從而提供應用的伸縮性。這就意味著將會有多個應用程式,這些應用程式可能由不同的程式碼構成、連線不同資料庫和對外提供不同API介面。

這個策略通常是將多個微服務聯合在一起,其中“微”的字面意思是服務儘可能的小。在現實場景下,服務的大小並不是最重要的。但是各個服務必須是高內聚、低耦合的。

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

切割

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

實現可伸縮性的大型應用,最終都會使用上述三種策略。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應用部署在單核伺服器上,也推薦開發者使用這種方式實現資料分離。

除了資料快取,狀態通訊也會存在問題。例如在叢集應用中,某個子服務對外部請求做狀態標記後,並不能確保當該外部客戶端再次傳送請求時,該請求可以分配給與上次相同的子服務上。因此在子服務上建立程式的狀態標記並不是好選擇。

最常見的問題就是使用者的認證:

Node.js的可伸縮性

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

Node.js的可伸縮性

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

這個問題其實有很多中解決方式:可能將使用者的認證狀態儲存在共享資料庫(如Redis)。然而使用這種策略需要對程式碼做出改動,因此並不總會選擇這種方案。

注意:如果開發者不想通過修改程式碼在共享資料庫中儲存請求資訊,其它的方式會很低效。這裡可以考慮粘滯負載均衡方案。由於許多負載均衡器都支援這種策略,因此可以很輕鬆完成對應的程式碼。原理其實很簡單:如果使用者的會話資訊儲存在子程式A上,在主程式會儲存會話與程式的資訊。

Node.js的可伸縮性

當相同使用者再次請求服務時,程式會在主程式的索引表上查詢儲存該使用者會話資訊的程式,然後將請求任務分配給該子程式。這裡我們並沒有對請求實現負載均衡,如果修改程式受限的場景下,這也是不錯的選擇。

Node.js叢集模組並不支援粘滯負載均衡,但是許多負載均衡器的預設配置是支援它的。

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

相關文章