如何創建高性能、可擴展的Node.js應用?

在這篇文章中,我們將介紹關於開發 Node.js web 應用程序的一些最佳實踐,重點關注效率和性能,以便用更少的資源獲得最佳結果。

提高 web 應用程序吞吐量的一種方法是對其進行擴展,多次實例化其以平衡在多個實例之間的傳入連接,接來下我們要介紹的是如何在多個內核上或多臺機器上對 Node.js 應用程序進行水平擴展。

在強制性規則中,有一些好的實踐可以用來解決這些問題,像拆分 API 和工作進程、採用優先級隊列、管理像 cron 進程這樣的週期性作業,在向上擴展到 N 個進程 / 機器時,這不需要運行 N 次。

水平擴展 Node.js 應用程序

水平擴展是複製應用程序實例以管理大量傳入連接。 此操作可以在單個多內核機器上執行,也可以在不同機器上執行。

垂直擴展是提高單機性能,它不涉及代碼方面的特定工作。

 在同一臺機器上的多進程

提高應用程序吞吐量的一種常用方法是爲機器的每個內核生成一個進程。 通過這種方式,Node.js 中請求的已經有效的“併發”管理(請參見“事件驅動,非阻塞 I / O”)可以相乘和並行化。

產生大於內核的數量的大量進程可能並不好,因爲在較低級別,操作系統可能會平衡這些進程之間的 CPU 時間。

擴展單機有不同的策略,但常見的概念是,在同一端口上運行多個進程,並使用某種內部負載平衡來分配所有進程 / 核上的傳入連接。

下面所描述的策略是標準的 Node.js 集羣模式以及自動的,更高級別的 PM2 集羣功能。

 原生集羣模式

原生 Node.js 羣集模塊是在單機上擴展 Node 應用程序的基本方法(請參閱 https://Node.js.org/api/clust...)。 你的進程的一個實例(稱爲“master”)是負責生成其他子進程(稱爲“worker”)的實例,每個進程對應一個運行你的應用程序的核。 傳入連接按照循環策略分發到所有 worker 進程,從而在同一端口上公開服務。

該方法的主要缺點是必須在代碼內部管理 master 進程和 worker 進程之間的差異,通常使用經典的 if-else 塊,不能夠輕易地修改進動態進程數。

下面的例子來自官方文檔:

const cluster = require(‘cluster’);
const http = require(‘http’);
const numCPUs = require(‘os’).cpus().length;

if (cluster.isMaster) {

 console.log(`Master ${process.pid} is running`);

 // Fork workers.
 for (let i = 0; i < numCPUs; i++) {
  cluster.fork();
 }

 cluster.on(‘exit’, (worker, code, signal) => {
  console.log(`worker ${worker.process.pid} died`);
 });

} else {

 // Workers can share any TCP connection
 // In this case it is an HTTP server
 http.createServer((req, res) => {
  res.writeHead(200);
  res.end(‘hello world\n’);
 }).listen(8000);

 console.log(`Worker ${process.pid} started`);

}

PM2 集羣模式

如果你在使用 PM2 作爲你的流程管理器(我也建議你這麼做),那麼有一個神奇的羣集功能可以讓你跨所有內核擴展流程,而無需擔心集羣模塊。 PM2 守護程序將承擔“master”進程的角色,它將生成你的應用程序的 N 個進程作爲 worker 進程, 並進行循環平衡。

通過這個方法,只需要按你爲單內核用途一樣地編寫你的應用程序(我們稍後再提其中的一些注意事項),而 PM2 將關注多內核部分。

在集羣模式下啓動你的應用程序後,你可以使用“pm2 scale”調整動態實例數,並執行“0-second-downtime”重新加載,進程重新串聯,以便始終至少有一個在線進程。

在生產中運行節點時,如果你的進程像很多其他你應該考慮的有用的東西一樣崩潰了,那麼 PM2 作爲進程管理器將負責重新啓動你的進程。

如果你需要進一步擴展,那麼你也許需要部署更多的機器。

 具有網絡負載均衡的多臺機器

跨多臺機器進行擴展的主要概念類似於在多內核上進行擴展,有多臺機器,每臺機器運行一個或多個進程,以及用於將流量重定向到每臺機器的均衡器。

一旦請求被髮送到特定的節點,剛纔所提到的內部均衡器發送該流量到特定的進程。

可以以不同方式部署網絡平衡器。 如果使用 AWS 來配置你的基礎架構,那麼一個不錯的選擇是使用像 ELB(Elastic Load Balancer,彈性負載均衡器)這樣的託管負載均衡器,因爲它支持自動擴展等有用功能,並且易於設置。

但是如果你想按傳統的方式來做,你可以自己部署一臺機器並用 NGINX 設置一個均衡器。 指向上游的反向代理的配置對於這個任務來說非常簡單。 下面是配置示例:

http {

 upstream myapp1 {
   server srv1.example.com;
   server srv2.example.com;
   server srv3.example.com;
 }

 server {
   listen 80;
   location / {
    proxy_pass http://myapp1;
   }
 }

}

通過這種方式,負載均衡器將是你的應用程序暴露給外部世界的唯一入口點。 如果擔心它成爲基礎架構的單點故障,可以部署多個指向相同服務器的負載均衡器。

爲了在均衡器之間分配流量(每個均衡器都有自己的 IP 地址),可以向主域添加多個 DNS“A”記錄,從而 DNS 解析器將在你的均衡器之間分配流量,每次都解析爲不同的 IP 地址。通過這種方式,還可以在負載均衡器上實現冗餘。

我們在這裏看到的是如何在不同級別擴展 Node.js 應用程序,以便從你的基礎架構(從單節點到多節點和多均衡器)獲得儘可能高的性能,但要小心:如果想在多進程環境中使用你的應用程序,必須做好準備,否則會遇到一些問題和不期望的行爲。

在向上擴展你的進程時,爲了避免出現不期望的行爲,現在我們來談談必須考慮到的一些方面。

讓Node.js 應用程序做好擴展準備

從 DB 中分離應用程序實例

首先不是代碼問題,而是你的基礎結構。

如果希望你的應用程序能夠跨不同主機進行擴展,則必須把你的數據庫部署在獨立的機器上,以便可以根據需要自由複製應用程序機器。

在同一臺機器上部署用於開發目的的應用程序和數據庫可能很便宜,但絕對不建議用於生產環境,其中的應用程序和數據庫必須能夠獨立擴展。 這同樣適用於像 Redis 這樣的內存數據庫。

無狀態

如果生成你的應用程序的多個實例,則每個進程都有自己的內存空間。 這意味着即使在一臺機器上運行,當你在全局變量中存儲某些值,或者更常見的是在內存中存儲會話時,如果均衡器在下一個請求期間將您重定向到另一個進程,那麼你將無法在那裏找到它。

這適用於會話數據和內部值,如任何類型的應用程序範圍的設置。對於可在運行時更改的設置或配置,解決方案是將它們存儲在外部數據庫(存儲或內存中)上,以使所有進程都可以訪問它們。

使用 JWT 進行無狀態身份驗證

身份驗證是開發無狀態應用程序時要考慮的首要主題之一。 如果將會話存儲在內存中,它們將作用於這單個進程。

爲了正常工作,應該將網絡負載均衡器配置爲,始終將同一用戶重定向到同一臺機器,並將本地用戶重定向到同一用戶始終重定向到同一進程(粘性會話)。

解決此問題的一個簡單方法是將會話的存儲策略設置爲任何形式的持久性,例如,將它們存儲在 DB 而不是 RAM 中。 但是,如果你的應用程序檢查每個請求的會話數據,那麼每次 API 調用都會進行磁盤讀寫操作(I / O),從性能的角度來看,這絕對不是好事。

更好,更快的解決方案(如果你的身份驗證框架支持)是將會話存儲在像 Redis 這樣的內存數據庫中。 Redis 實例通常位於應用程序實例外部,例如 DB 實例,但在內存中工作會使其更快。 無論如何,在 RAM 中存儲會話會在併發會話數增加時需要更多內存。

如果想採用更有效的無狀態身份驗證方法,可以看看 JSON Web Tokens。

JWT 背後的想法很簡單:當用戶登錄時,服務器生成一個令牌,該令牌本質上是包含有效負載的 JSON 對象的 base64 編碼,加上簽名獲得的哈希,該負載具有服務器擁有的密鑰。 有效負載可以包含用於對用戶進行身份驗證和授權的數據,例如 userID 及其關聯的 ACL 角色。 令牌被髮送回客戶端並由其用於驗證每個 API 請求。

當服務器處理傳入請求時,它會獲取令牌的有效負載並使用其密鑰重新創建簽名。 如果兩個簽名匹配,則可以認爲有效載荷有效並且不被改變,並且可以識別用戶。

重要的是要記住 JWT 不提供任何形式的加密。 有效負載僅用 base64 編碼,並以明文形式發送,因此如果需要隱藏內容,則必須使用 SSL。

被 jwt.io 借用的以下模式恢復了身份驗證過程:

在認證過程中,服務器不需要訪問存儲在某處的會話數據,因此每個請求都可以由非常有效的方式由不同的進程或機器處理。 RAM 中不保存數據,也不需要執行存儲 I / O,因此在向上擴展時這種方法非常有用。

S3 上的存儲

使用多臺機器時,無法將用戶生成的資產直接保存在文件系統上,因爲這些文件只能由該服務器本地的進程訪問。 解決方案是,將所有內容存儲在外部服務上,可以存儲在像 Amazon S3 這樣的專用服務上,並在你的數據庫中僅保存指向該資源的絕對 URL。

然後,每個進程 / 機器都可以以相同的方式訪問該資源。

使用 Node.js 的官方 AWS sdk 非常簡單,可以輕鬆地將服務集成到你的應用程序中。 S3 非常便宜並且針對此目的進行了優化。即使你的應用程序不是多進程的,它也是一個不錯的選擇。

 正確配置 WebSockets

如果你的應用程序使用 WebSockets 進行客戶端之間或客戶端與服務器之間的實時交互,則需要鏈接後端實例,以便在連接到不同節點的客戶端之間正確傳播廣播消息或消息。

Socket.io 庫爲此提供了一個特殊的適配器,稱爲 socket.io-redis,它允許你使用 Redis pub-sub 功能鏈接服務器實例。

爲了使用多節點 socket.io 環境,還需要強制協議爲“websockets”,因爲長輪詢(long-polling)需要粘性會話才能工作。

以上這些對於單節點環境來說也是好的實例。

效率和性能的其他良好實踐

接下來,我們將介紹一些可以進一步提高效率和性能的其他實踐。

Web 和 worker 進程

你可能知道,Node.js 實際上是單線程的,因此該進程的單個實例一次只能執行一個操作。 在 Web 應用程序的生命週期中,執行許多不同的任務:管理 API 調用,讀取 / 寫入 DB,與外部網絡服務通信,執行某種不可避免的 CPU 密集型工作等。

雖然你使用異步編程,但將所有這些操作委派給響應 API 調用的同一進程可能是一種非常低效的方法。

一種常見的模式是基於兩種不同類型的進程之間的職責分離,這兩種類型的進程組成了你的應用程序,通常是 Web 進程和 worker 進程。

Web 進程主要用於管理傳入的網絡呼叫,並儘快發送它們。 每當需要執行非阻塞任務時,例如發送電子郵件 / 通知、編寫日誌、執行觸發操作,其結果是不需要響應 API 調用,web 進程將操作委派給 worker 進程。

Web 和 worker 進程之間的通信可以用不同的方式實現。 一種常見且有效的解決方案是優先級隊列,如下一段所描述的 Kue 中實現的優先級隊列。

這種方法的一大勝利是,可以在相同或不同的機器上獨立擴展 web 和 worker 進程。

例如,如果你的應用程序是高流量應用程序,幾乎沒有生成的副作用,那麼可以部署比 worker 進程更多的 web 進程,而如果很少有網絡請求爲 worker 進程生成大量作業,則可以重新分發相應的資源。

Kue

爲了使 web 和 worker 進程相互通信,隊列是一種靈活的方法,可以讓你不必擔心進程間通信。

Kue 是基於 Redis 的 Node.js 的通用隊列庫,允許你以完全相同的方式放入在相同或不同機器上生成的通信進程。

任何類型的進程都可以創建作業並將其放入隊列,然後將 worker 進程配置爲選擇這些作業並執行它們。 可以爲每項工作提供許多選項,如優先級、TTL、延遲等。

你生成的 worker 進程越多,執行這些作業所需的並行吞吐量就越多。

Cron

應用程序通常需要定期執行某些任務。 通常,這種操作通過操作系統級別的 cron 作業進行管理,從你的應用程序外部調用單個腳本。

在新機器上部署你的應用程序時,用此方法就需要額外的工作,如果要自動部署,這會使進程感到不自在。

實現相同結果的更自在的方法是使用 NPM 上的可用 cron 模塊。 它允許你在 Node.js 代碼中定義 cron 作業,使其獨立於 OS 配置。

根據上面描述的 web / worker 模式,worker 進程可以創建 cron,它調用一個函數,定期將新作業放入隊列。

使用隊列使其更加乾淨,並可以利用 kue 提供的所有功能,如優先級,重試等。

當你有多個 worker 進程時會出現問題,因爲 cron 函數會同時喚醒每個進程上的應用程序,並將多次執行的同一作業放入隊列副本中。

爲了解決這個問題,有必要確定將執行 cron 操作的單個 worker 進程。

領導者選舉(Leader election)和 cron-cluster(cron 集羣)

這種問題被稱爲“領導者選舉”,對於這個特定的場景,有一個 NPM 包爲我們做了一個叫做 cron-cluster 的技巧。

它暴露了爲 cron 模塊提供動力的相同 API,但在設置過程中,它需要一個 redis 連接,用於與其他進程通信並執行領導者選舉算法。

使用 redis 作爲單一事實來源,所有進程都會同意誰將執行 cron,並且只有一份作業副本將被放入隊列中。 之後,所有 worker 進程都將有資格像往常一樣執行作業。

緩存 API 調用

服務器端緩存是提高 API 調用的性能和反應性的常用方法,但它是一個非常廣泛的主題,有很多可能的實現。

在像我們所描述的分佈式環境中,使用 redis 來存儲緩存的值可能是使所有節點表現相同的最佳方法。

緩存需要考慮的最困難的方面是失效。 快速而簡陋的解決方案只考慮時間,因此緩存中的值在固定的 TTL 之後刷新,缺點是不得不等待下一次刷新以查看響應中的更新。

如果你有更多的時間,最好在應用程序級別實現失效,在 DB 上值更改時手動刷新 redis 緩存上的記錄。

 #結 論 

我們在本文中介紹了一些有關擴展和性能的一些主題。 文中提供的建議可以作爲指導,可以根據你的項目的特定需求進行定製。

原文鏈接:https://www.jianshu.com/p/43d...

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