在這篇文章中,咱們將介紹關於開發 Node.js web 應用程序的一些最佳實踐,重點關注效率和性能,以便用更少的資源得到最佳結果。html
提升 web 應用程序吞吐量的一種方法是對其進行擴展,屢次實例化其以平衡在多個實例之間的傳入鏈接,接來下咱們要介紹的是如何在多個內核上或多臺機器上對 Node.js 應用程序進行水平擴展。web
在強制性規則中,有一些好的實踐能夠用來解決這些問題,像拆分 API 和工做進程、採用優先級隊列、管理像 cron 進程這樣的週期性做業,在向上擴展到 N 個進程 / 機器時,這不須要運行 N 次。redis
水平擴展是複製應用程序實例以管理大量傳入鏈接。 此操做能夠在單個多內核機器上執行,也能夠在不一樣機器上執行。算法
垂直擴展是提升單機性能,它不涉及代碼方面的特定工做。數據庫
在同一臺機器上的多進程編程
提升應用程序吞吐量的一種經常使用方法是爲機器的每一個內核生成一個進程。 經過這種方式,Node.js 中請求的已經有效的「併發」管理(請參見「事件驅動,非阻塞 I / O」)能夠相乘和並行化。後端
產生大於內核的數量的大量進程可能並很差,由於在較低級別,操做系統可能會平衡這些進程之間的 CPU 時間。api
擴展單機有不一樣的策略,但常見的概念是,在同一端口上運行多個進程,並使用某種內部負載平衡來分配全部進程 / 核上的傳入鏈接。緩存
下面所描述的策略是標準的 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 應用程序,以便從你的基礎架構(從單節點到多節點和多均衡器)得到儘量高的性能,但要當心:若是想在多進程環境中使用你的應用程序,必須作好準備,不然會遇到一些問題和不指望的行爲。
在向上擴展你的進程時,爲了不出現不指望的行爲,如今咱們來談談必須考慮到的一些方面。
從 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 緩存上的記錄。
#結 論
咱們在本文中介紹了一些有關擴展和性能的一些主題。 文中提供的建議能夠做爲指導,能夠根據你的項目的特定需求進行定製。