關於Node.js后端架構的一點后知后覺


前言

上周有幸和淘寶前端團隊的七念老師做了一些NodeJS方面上的交流(實際情況其實是他電話面試了我╮(╯-╰)╭),我們主要聊到了我參與維護的一個線上NodeJS服務,關於它的現狀和當下的不足。他向我提出的一些問題帶給了我很大啟發,盡管回答的不是很好。問題大意是,對於你意識到的這些不足,你將嘗試怎樣去改進它們?甚至,如果給你一個機會來重新設計這個系統服務,你將如何做?相比現在有什么的改進?

為什么說這些問題對我產生了啟發,是因為這些問題是我不曾考慮過的。或者說考慮過,但沒有這么嚴肅的考慮過。這里的“嚴肅”指的是具體到線上,細節,容災容錯等方面。而在電話之后我重新嘗試回答這些問題的過程中又收獲了不少新的知識。

這篇文章與以往的文章不同,並不是闡述某一個問題的最佳解決方案,也不會落實到具體的代碼上。而是分享在探尋答案過程中收獲的心得、留下的困惑還有一點個人的經驗。至於這些能否拿來回答最初的那些問題我沒有十足的把握,也許能,但肯定不是最佳答案。因為后端架構實在一個很有深度的話題,也是一個極其成熟的技術方向。即使有了理論方面的積累,面對千變萬化的業務需求難免還是靈活的對方案進行改進,而無論是理論還是實踐經驗都是我欠缺的。

這段話本來應該是寫在結尾,感覺順嘴也就掛在了開頭。

最后,本文的部分內容和圖片參考自圖書Node.js design patterns的第七章內容Scalability and Architectural Patterns。其實書中該章中的大部分內容也並非原創,但是它做了很好的匯總和遷移,具體我會在之后說明。所以如有雷同,不是巧合。

正文

一個怎樣的后端服務才能算得上優秀?或者放低身段說合格?再把這個問題翻譯翻譯,優秀或者合格的標准是什么?

假設現在需要你用NodeJS搭建一個http服務,我猜測你會借助express框架用不到10行的代碼完成這項工作。不能說這么做是錯的,但這樣簡易的程序是脆弱的:一旦部署上線之后,可能瞬間就被大量涌入的請求擊垮,更不要提各種潛在的漏洞危險。退一步說,即使線上程序經過了這一關考驗,如果你要更新程序怎么辦?不得不讓用戶中斷訪問一段時間?

在我看來,后端服務務必要滿足兩條特性:

  • 能容錯(Fault tolerant)
  • 可擴展(Scalability)

當然還有一些其他特性也很重要,比如程序要健壯,接口設計要友好,程序修改起來要靈活等等。但容錯性和拓展性才是正常運行的基本保障,至少保證了你的服務是可用的,永遠是可用的。而無論實現服務的代碼如何優雅,它都是為業務服務的,一旦用戶無法訪問你的服務了,再優美的代碼也無濟於事。所以接下來的問題就是,我們后端程序的架構如何的設計以保證滿足這兩條特性呢?

首先我們說說拓展性(Scalability)。

按照書中的說法,拓展性划分為三類,如下圖所示:

  • X軸方向:純粹的對服務實例進行拓展,例如為了響應更多的請求
  • y軸方向:為服務添加新的功能,功能性拓展
  • z軸方向:按照業務數據對服務進行拓展(這里沒搞懂,不知道這么說是否准確)

而通常實際的拓展過程中多維度是同時進行的,例如增添了新的功能也就意味着有跟多的流量進入,也就是意味着需要增加新的服務實例。

實例拓展

我們先談第一類X軸拓展,增加服務的實例。增加服務實例也分為兩類,橫向拓展(horizontal scaling)和縱向拓展(vertical scaling),橫向表示利用更多的機器,縱向表示在同一台機器上挖掘它的潛力。但其實橫向和縱向兩者解決問題的思路的差異並不大。

從小到大,先說縱向拓展。

我們都知道NodeJS程序是以單進程形式運行,32位機器上最多也只有1GB內存的實用權限(在64位機器上最大的內存權限擴大到1.7GB)。而目前絕大部分線上服務器的CPU都是多核並且至少16GB起,如此以來Node程序便無法充分發揮機器的潛力。同時NodeJS自己也意識到了這一點,所以它允許程序創建多個子進程用於運行多個實例。

具體技術細節涉及到Cluster模塊,詳情可以查看NodeJS相關文檔: https://nodejs.org/api/cluster.html

下圖就是對以上所說多進程模式原理的圖解:

簡單來說,首先我們有一個主進程master,但master主進程並不實際的處理業務邏輯,但除了業務邏輯以外事情它都做:它是manager,負責啟動子進程,管理子進程(如果子進程掛了要及時重啟),它也扮演router,也就是對該程序的訪問請求首先到達主進程,再由主進程分配請求給子進程worker。而子進程才負責處理業務邏輯。

在這個機制下有兩條細節需要我們定奪如何處理。

如何把外界的請求平均的分配給不同的worker處理?這里的平均不是指數量上的平均(因為單條請求處理的工作量可能不同),而是既不能讓某個子進程太閑,也不能讓某個子進程太忙,保證它們始終處於工作的狀態即可。這也是我們常說的負載均衡(load-balancing)。 默認情況下Cluster模塊采用的是round robin負載均衡算法,說白了就是依次按順序把請求派給列表上的子進程,派到結尾之后又重頭開始。

這個算法只能保證每個子進程收到的請求個數是平均的,和隨機算法類似。但如果某個子進程遇到問題,處理變得遲緩了,而后續的請求又源源不斷的分配過來,那么這個子進程的壓力就大了,這就略顯不公了。除此之外我們還要考慮到超時,重做等機制的建立。所以主進程master作為路由時不僅僅是轉發請求,還要能智能的分配請求。

另一個問題是狀態共享問題,假如某個用戶第一次訪問該服務時是分配給了線程A上的實例A處理,並且用戶在這個實例上進行了登陸,而沒有過幾秒鍾之后當用戶第二次訪問時分配給了線程B上的實例B處理,如果此時用戶在A上的登陸狀態沒有共享給其他實例的話,那么用戶不得不重新登陸一次,這樣的用戶體驗是無法接受的。如下圖所示

這個問題的解決辦法是把狀態進行共享:

也可以新增一個模塊用於記錄用戶第一次訪問的實例,並在之后當用戶訪問服務時始終訪問該實例

主進程-子進程的模式思路不僅適用於縱向拓展,還適用於橫向拓展。當單台機器已經無法滿足你需求的時候,你可以把單實例子進程的概念拓展為單台機器:我們將在多台機器上部署多個進行實例,用戶的訪問請求也並非直接到達它們,而是先到達前方的代理機器,它也是負責負載均衡的機器,負責將請求轉發給部署了應用實例的機器。這樣的模式我們也通常稱為反向代理模式:

我們仍然能對這個模式持續改進,例如動態的啟動或者關閉機器上的實例用於節省資源,甚至想辦法移除負載平衡這一環節用於提高通訊的效率。在這里就不延伸開了去了,具體可以參考Node.js design patterns這本書中的內容。

最后在這里要說一件很重要的事情。上面說的負載平衡也好,反向代理也好,都不是新的技術。相反,都是非常非常成熟,有着相當多經驗積累的技術。然而為什么我們接觸起來卻感覺如此的新鮮和陌生?我想原因大概是NodeJS程序員大多是由前端工程師轉化而來,而大家此前都只專注於前端代碼而很少接觸后端知識。然而如果你從入行開始就是一個Java程序員或者運維工程師,相信你對這一切早就耳熟能詳並且手到擒來。

幾年前看到過一篇文章,(很可惜現在找不到了,如果有哪位同學知道篇文章的麻煩告知一下謝謝),記錄的是一位技術人員針對網站訪問量增大而做的一系列技術改進。文章的后半部分我記不得了,但是前半部分遇到的問題和改進的思路和我們是一模一樣的:請求驟增,增加實例機器和解決session共享問題。我想說的是,雖然NodeJS是新技術,但是我們解決問題的思路和方案可以來自傳統軟件行業,並且它們在這方面比我們有經驗的多。所以我們在學習NodeJS,在尋找一些問題的解決方案時,不要局限於NodeJS本身,而是應該開闊眼界,跨語言包容的去汲取知識。

功能拓展

你也許會問新增功能有什么難點?每個程序員的日常就是不斷的進行功能迭代。但在這里我們希望解決一個問題,就是既然我們無法保證功能不會出錯,那我們有沒有辦法保證當一個功能出錯之后不會影響整個程序的正常運行?這也是我們所說的容錯性。

道理都懂,我們都明白程序需要容錯,所以try/catch是從編碼上解決這個問題。但問題是try/catch不是萬能的,萬無一失的程序也是不存在的,所以我們要換個思路解決這個問題,我們允許程序出錯,但是要及時把錯誤隔離,並且不再影響程序的運行。這個就要從架構上解決這個問題。例如使用微服務(Microservices)架構。

在介紹微服務架構之前,我們要了解其它架構為什么沒法滿足我們的要求。例如我們常用的單體(monolithic)架構。單體架構這個詞你可能不熟悉,但幾乎我們每天都在和它打交道,大部分的后端服務都歸屬於單體架構,對它的解釋我翻譯Martin Fowler的描述:

企業級應用通常分為三個部分:用戶界面(包含運行在用戶瀏覽器上的html頁面和javascript腳本),數據庫(通常是包含許多表的關系數據庫),和服務端應用。服務端應用將會處理http請求,執行業務邏輯,從數據庫中取得數據,生成html視圖返回給瀏覽器。這樣的服務端應用就被稱為單體(monolith)——單個具有邏輯性的執行過程。任何針對系統的修改都會導致重新構建和部署一個新版本的服務端應用。

(注:以上這段描述摘自Martin Fowler的文章Microservices,我認為這是對微架構描述最全面的文章,如果想對這一小節做更深入的了解可以把這篇文章細讀。 這也是我讀到的Martin Fowler所寫的文章中最通俗的文章。個人認為Martin Fowler的文章讀起來比較晦澀,John Resig緊隨其后)

單體架構是一種很自然的搭建應用的方式,它符合我們對業務處理流程的認知。但單體應用也存在問題:任何一處,無論大小的修改都會導致整個應用被重新構建和重新部署。隨着應用規模和復雜性的不斷增大,參與維護的人數增多,每一輪迭代修改的模塊增多,對上線來說是極大的考驗,對於內部單個模塊的拓展也是極為不利的。例如當圖片壓縮請求劇增時,需要新增圖片壓縮模塊的實例,但實際上不得不擴展整個單體應用的實例。

微服務架構解決的就是這一系列問題。顧名思義,微服務架構下軟件是由多個獨立的服務組成。這些服務相互獨立互不干預。以拆分上面所說的單體應用為例,我們可以把處理HTTP請求的模塊和負責數據庫讀寫的模塊分離出來成為獨立的服務,這兩個模塊從功能上看是沒有任何交集。這樣的好處就是,我們可以獨立的部署,拓展,修改這些服務。例如應用需要添加新的接口時,我們只需要修改處理HTTP請求的服務,只公開這部分代碼給修改者,只上線這部分服務,拓展時也只需要新添這部分服務的實例。

微服務和我們通常編寫的模塊(以文件為單位,以命名空間為單位)相比更加獨立,更像是一個五臟俱全的“小應用”,如果你讀完了我之前推薦的Martin Fowler關於微服務的文章的話,你會對這點更深有感觸:微服務除了在運維上獨立以外,它還可以擁有獨立的數據庫,還應該配備獨立的團隊維護。它甚至可以允許使用其他的語言進行開發,只要對外接口正常即可。

當然微服務也存在不足,例如如何將諸多的微服務在大型架構中組織起來,如何提高不同服務之間的通信效率都是需要在實際工作中解決的問題。

微服務說到底還是解耦思想的實踐。從這個意義上來說,React下的Flux架構某種意義上也屬於微服務。如果你了解Flux的起源的話,Flux架構其實來源於后端的CQRS,即Command Query Responsibility Segregation,命令與查詢職責分離,也就是將數據的讀操作和寫操作分離開。這么設計的理由有很多,舉例說一點:在許多業務場景中,數據的讀和寫的次數是不平衡,可能上千次的讀操作才對應一次寫操作,比如機票余票信息的查詢和更新。所以把讀和寫操作分開能夠有針對性的分別優化它們。例如提高程序的scalability,scalability意味着我們能夠在部署程序時,給讀操作和寫操作部署不同數量的線上實例來滿足實際的需求。

如果你也有Unity編程經驗的話會對解耦更有感觸,在Unity中我們已經不能稱之為解耦,而是自治,這是Unity的設計模式。舉個例子,屏幕上少則可能有十幾個游戲元素,例如玩家、敵人還有子彈。你必須為它們編寫“死亡”的規則,“誕生”的規則,交互的規則。因為你根本無法預料玩家在何時何種位置發射出子彈,也無法預料子彈何時在什么位置碰撞上什么狀態敵人。所以你只能讓它們在規則下自由發揮。這和微服務有異曲同工之妙:獨立,隔離,自治。

總結

實話實說,這篇文章里沒有干貨,全都是舶來品。但舶來品不是一個貶義詞,它是我們學習知識和解決問題的第一手材料。我還是想重申一遍,在后端領域來說Node.js是一個新人,我們應該學習前輩的經驗。借用許多年前奔馳廣告的一句話:經典是對經典的繼承,經典是對經典的背叛。只有站在前人的肩膀上,我們才有可能創新,看的更遠。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM