[轉載]淺析海量使用者的分散式系統設計

雲霏霏發表於2016-11-21

我們常常會聽說,某個網際網路應用的伺服器端系統多麼牛逼,比如QQ拉、微信拉、淘寶拉。那麼,一個網際網路應用的伺服器端系統,到底牛逼在什麼地方?為什麼海量的使用者訪問,會讓一個伺服器端系統變得更復雜?本文就是想從最基本的地方開始,探尋伺服器端系統技術的基礎概念。

 

承載量是分散式系統存在的原因

當一個網際網路業務獲得大眾歡迎的時候,最顯著碰到的技術問題,就是伺服器非常繁忙。當每天有1000萬個使用者訪問你的網站時,無論你使用什麼樣的伺服器硬體,都不可能只用一臺機器就承載的了。因此,在網際網路程式設計師解決伺服器端問題的時候,必須要考慮如何使用多臺伺服器,為同一種網際網路應用提供服務,這就是所謂“分散式系統”的來源。

然而,大量使用者訪問同一個網際網路業務,所造成的問題並不簡單。從表面上看,要能滿足很多使用者來自網際網路的請求,最基本的需求就是所謂效能需求:使用者反應網頁開啟很慢,或者網遊中的動作很卡等等。而這些對於“服務速度”的要求,實際上包含的部分卻是以下幾個:高吞吐、高併發、低延遲和負載均衡。

高吞吐,意味著你的系統,可以同時承載大量的使用者使用。這裡關注的整個系統能同時服務的使用者數。這個吞吐量肯定是不可能用單臺伺服器解決的,因此需要多臺伺服器協作,才能達到所需要的吞吐量。而在多臺伺服器的協作中,如何才能有效的利用這些伺服器,不致於其中某一部分伺服器成為瓶頸,從而影響整個系統的處理能力,這就是一個分散式系統,在架構上需要仔細權衡的問題。

高併發是高吞吐的一個延伸需求。當我們在承載海量使用者的時候,我們當然希望每個伺服器都能盡其所能的工作,而不要出現無謂的消耗和等待的情況。然而,軟體系統並不是簡單的設計,就能對同時處理多個任務,做到“儘量多”的處理。很多時候,我們的程式會因為要選擇處理哪個任務,而導致額外的消耗。這也是分散式系統解決的問題。

低延遲對於人數稀少的服務來說不算什麼問題。然而,如果我們需要在大量使用者訪問的時候,也能很快的返回計算結果,這就要困難的多。因為除了大量使用者訪問可能造成請求在排隊外,還有可能因為排隊的長度太長,導致記憶體耗盡、頻寬佔滿等空間性的問題。如果因為排隊失敗而採取重試的策略,則整個延遲會變的更高。所以分散式系統會採用很多請求分揀和分發的做法,儘快的讓更多的伺服器來出來使用者的請求。但是,由於一個數量龐大的分散式系統,必然需要把使用者的請求經過多次的分發,整個延遲可能會因為這些分發和轉交的操作,變得更高,所以分散式系統除了分發請求外,還要儘量想辦法減少分發的層次數,以便讓請求能儘快的得到處理。

由於網際網路業務的使用者來自全世界,因此在物理空間上可能來自各種不同延遲的網路和線路,在時間上也可能來自不同的時區,所以要有效的應對這種使用者來源的複雜性,就需要把多個伺服器部署在不同的空間來提供服務。同時,我們也需要讓同時發生的請求,有效的讓多個不同伺服器承載。所謂的負載均衡,就是分散式系統與生俱來需要完成的功課。

由於分散式系統,幾乎是解決網際網路業務承載量問題,的最基本方法,所以作為一個伺服器端程式設計師,掌握分散式系統技術就變得異常重要了。然而,分散式系統的問題,並非是學會用幾個框架和使用幾個庫,就能輕易解決的,因為當一個程式在一個電腦上執行,變成了又無數個電腦上同時協同執行,在開發、運維上都會帶來很大的差別。

 

分散式系統提高承載量的基本手段

1.分層模型(路由、代理)

使用多臺伺服器來協同完成計算任務,最簡單的思路就是,讓每個伺服器都能完成全部的請求,然後把請求隨機的發給任何一個伺服器處理。最早期的網際網路應用中,DNS輪詢就是這樣的做法:當使用者輸入一個域名試圖訪問某個網站,這個域名會被解釋成多個IP地址中的一個,隨後這個網站的訪問請求,就被髮往對應IP的伺服器了,這樣多個伺服器(多個IP地址)就能一起解決處理大量的使用者請求。

然而,單純的請求隨機轉發,並不能解決一切問題。比如我們很多網際網路業務,都是需要使用者登入的。在登入某一個伺服器後,使用者會發起多個請求,如果我們把這些請求隨機的轉發到不同的伺服器上,那麼使用者登入的狀態就會丟失,造成一些請求處理失敗。簡單的依靠一層服務轉發是不夠的,所以我們會增加一批伺服器,這些伺服器會根據使用者的Cookie,或者使用者的登入憑據,來再次轉發給後面具體處理業務的伺服器。

除了登入的需求外,我們還發現,很多資料是需要資料庫來處理的,而我們的這些資料往往都只能集中到一個資料庫中,否則在查詢的時候就會丟失其他伺服器上存放的資料結果。所以往往我們還會把資料庫單獨出來成為一批專用的伺服器。

至此,我們就會發現,一個典型的三層結構出現了:接入、邏輯、儲存。然而,這種三層結果,並不就能包醫百病。例如,當我們需要讓使用者線上互動(網遊就是典型) ,那麼分割在不同邏輯伺服器上的線上狀態資料,是無法知道對方的,這樣我們就需要專門做一個類似互動伺服器的專門系統,讓使用者登入的時候,也同時記錄一份資料到它那裡,表明某個使用者登入在某個伺服器上,而所有的互動操作,要先經過這個互動伺服器,才能正確的把訊息轉發到目標使用者的伺服器上。

 

 

又例如,當我們在使用網上論壇(BBS)系統的時候,我們發的文章,不可能只寫入一個資料庫裡,因為太多人的閱讀請求會拖死這個資料庫。我們常常會按論壇板塊來寫入不同的資料庫,又或者是同時寫入多個資料庫。這樣把文章資料分別存放到不同的伺服器上,才能應對大量的操作請求。然而,使用者在讀取文章的時候,就需要有一個專門的程式,去查詢具體文章在哪一個伺服器上,這時候我們就要架設一個專門的代理層,把所有的文章請求先轉交給它,由它按照我們預設的儲存計劃,去找對應的資料庫獲取資料。

根據上面的例子來看,分散式系統雖然具有三層典型的結構,但是實際上往往不止有三層,而是根據業務需求,會設計成多個層次的。為了把請求轉交給正確的程式處理,我們而設計很多專門用於轉發請求的程式和伺服器。這些程式我們常常以Proxy或者Router來命名,一個多層結構常常會具備各種各樣的Proxy程式。這些代理程式,很多時候都是通過TCP來連線前後兩端。然而,TCP雖然簡單,但是卻會有故障後不容易恢復的問題。而且TCP的網路程式設計,也是有點複雜的。——所以,人們設計出更好程式間通訊機制:訊息佇列。

 

 

儘管通過各種Proxy或者Router程式能組建出強大的分散式系統,但是其管理的複雜性也是非常高的。所以人們在分層模式的基礎上,想出了更多的方法,來讓這種分層模式的程式變得更簡單高效的方法。

 

2.併發模型(多執行緒、非同步)

當我們在編寫伺服器端程式是,我們會明確的知道,大部分的程式,都是會處理同時到達的多個請求的。因此我們不能好像HelloWorld那麼簡單的,從一個簡單的輸入計算出輸出來。因為我們會同時獲得很多個輸入,需要返回很多個輸出。在這些處理的過程中,往往我們還會碰到需要“等待”或“阻塞”的情況,比如我們的程式要等待資料庫處理結果,等待向另外一個程式請求結果等等……如果我們把請求一個挨著一個的處理,那麼這些空閒的等待時間將白白浪費,造成使用者的響應延時增加,以及整體系統的吞吐量極度下降。

所以在如何同時處理多個請求的問題上,業界有2個典型的方案。一種是多執行緒,一種是非同步。在早期的系統中,多執行緒或多程式是最常用的技術。這種技術的程式碼編寫起來比較簡單,因為每個執行緒中的程式碼都肯定是按先後順序執行的。但是由於同時執行著多個執行緒,所以你無法保障多個執行緒之間的程式碼的先後順序。這對於需要處理同一個資料的邏輯來說,是一個非常嚴重的問題,最簡單的例子就是顯示某個新聞的閱讀量。兩個++操作同時執行,有可能結果只加了1,而不是2。所以多執行緒下,我們常常要加很多資料的鎖,而這些鎖又反過來可能導致執行緒的死鎖。

因此非同步回撥模型在隨後比多執行緒更加流行,除了多執行緒的死鎖問題外,非同步還能解決多執行緒下,執行緒反覆切換導致不必要的開銷的問題:每個執行緒都需要一個獨立的棧空間,在多執行緒並行執行的時候,這些棧的資料可能需要來回的拷貝,這額外消耗了CPU。同時由於每個執行緒都需要佔用棧空間,所以在大量執行緒存在的時候,記憶體的消耗也是巨大的。而非同步回撥模型則能很好的解決這些問題,不過非同步回撥更像是“手工版”的並行處理,需要開發者自己去實現如何“並行”的問題。

非同步回撥基於非阻塞的I/O操作(網路和檔案),這樣我們就不用在呼叫讀寫函式的時候“卡”在那一句函式呼叫,而是立刻返回“有無資料”的結果。而Linux的epoll技術,則利用底層核心的機制,讓我們可以快速的“查詢”到有資料可以讀寫的連線\檔案。由於每個操作都是非阻塞的,所以我們的程式可以只用一個程式,就處理大量併發的請求。因為只有一個程式,所以所有的資料處理,其順序都是固定的,不可能出現多執行緒中,兩個函式的語句交錯執行的情況,因此也不需要各種“鎖”。從這個角度看,非同步非阻塞的技術,是大大簡化了開發的過程。由於只有一個執行緒,也不需要有執行緒切換之類的開銷,所以非同步非阻塞成為很多對吞吐量、併發有較高要求的系統首選。

 

int epoll_create(int size);//建立一個epoll的控制程式碼,size用來告訴核心這個監聽的數目一共有多大

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

 

3.緩衝技術

在網際網路服務中,大部分的使用者互動,都是需要立刻返回結果的,所以對於延遲有一定的要求。而類似網路遊戲之類服務,延遲更是要求縮短到幾十毫秒以內。所以為了降低延遲,緩衝是網際網路服務中最常見的技術之一。

早期的WEB系統中,如果每個HTTP請求的處理,都去資料庫(MySQL)讀寫一次,那麼資料庫很快就會因為連線數佔滿而停止響應。因為一般的資料庫,支援的連線數都只有幾百,而WEB的應用的併發請求,輕鬆能到幾千。這也是很多設計不良的網站人一多就卡死的最直接原因。為了儘量減少對資料庫的連線和訪問,人們設計了很多緩衝系統——把從資料庫中查詢的結果存放到更快的設施上,如果沒有相關聯的修改,就直接從這裡讀。

最典型的WEB應用緩衝系統是Memcache。由於PHP本身的執行緒結構,是不帶狀態的。早期PHP本身甚至連操作“堆”記憶體的方法都沒有,所以那些持久的狀態,就一定要存放到另外一個程式裡。而Memcache就是一個簡單可靠的存放臨時狀態的開源軟體。很多PHP應用現在的處理邏輯,都是先從資料庫讀取資料,然後寫入Memcache;當下次請求來的時候,先嚐試從Memcache裡面讀取資料,這樣就有可能大大減少對資料庫的訪問。

然而Memcache本身是一個獨立的伺服器程式,這個程式自身並不帶特別的叢集功能。也就是說這些Memcache程式,並不能直接組建成一個統一的叢集。如果一個Memcache不夠用,我們就要手工用程式碼去分配,哪些資料應該去哪個Memcache程式。——這對於真正的大型分散式網站來說,管理一個這樣的緩衝系統,是一個很繁瑣的工作。

因此人們開始考慮設計一些更高效的緩衝系統:從效能上來說,Memcache的每筆請求,都要經過網路傳輸,才能去拉取記憶體中的資料。這無疑是有一點浪費的,因為請求者本身的記憶體,也是可以存放資料的。——這就是促成了很多利用請求方記憶體的緩衝演算法和技術,其中最簡單的就是使用LRU演算法,把資料放在一個雜湊表結構的堆記憶體中。

而Memcache的不具備叢集功能,也是一個使用者的痛點。於是很多人開始設計,如何讓資料快取分不到不同的機器上。最簡單的思路是所謂讀寫分離,也就是快取每次寫,都寫到多個緩衝程式上記錄,而讀則可以隨機讀任何一個程式。在業務資料有明顯的讀寫不平衡差距上,效果是非常好的。

然而,並不是所有的業務都能簡單的用讀寫分離來解決問題,比如一些線上互動的網際網路業務,比如社群、遊戲。這些業務的資料讀寫頻率並沒很大的差異,而且也要求很高的延遲。因此人們又再想辦法,把本地記憶體和遠端程式的記憶體快取結合起來使用,讓資料具備兩級快取。同時,一個資料不在同時的複製存在所有的快取程式上,而是按一定規律分佈在多個程式上。——這種分佈規律使用的演算法,最流行的就是所謂“一致性雜湊”。這種演算法的好處是,當某一個程式失效掛掉,不需要把整個叢集中所有的快取資料,都重新修改一次位置。你可以想象一下,如果我們的資料快取分佈,是用簡單的以資料的ID對程式數取模,那麼一旦程式數變化,每個資料存放的程式位置都可能變化,這對於伺服器的故障容忍是不利的。

Orcale公司旗下有一款叫Coherence的產品,是在快取系統上設計比較好的。這個產品是一個商業產品,支援利用本地記憶體快取和遠端程式快取協作。叢集程式是完全自管理的,還支援在資料快取所在程式,進行使用者定義的計算(處理器功能),這就不僅僅是快取了,還是一個分散式的計算系統。

 

 

4.儲存技術(NoSQL)

相信CAP理論大家已經耳熟能詳,然而在互聯發展的早期,大家都還在使用MySQL的時候,如何讓資料庫存放更多的資料,承載更多的連線,很多團隊都是絞盡腦汁。甚至於有很多業務,主要的資料儲存方式是檔案,資料庫反而變成是輔助的設施了。

 

 

然而,當NoSQL興起,大家突然發現,其實很多網際網路業務,其資料格式是如此的簡單,很多時候根部不需要關係型資料庫那種複雜的表格。對於索引的要求往往也只是根據主索引搜尋。而更復雜的全文搜尋,本身資料庫也做不到。所以現在相當多的高併發的網際網路業務,首選NoSQL來做儲存設施。最早的NoSQL資料庫有MangoDB等,現在最流行的似乎就是Redis了。甚至有些團隊,把Redis也當成緩衝系統的一部分,實際上也是認可Redis的效能優勢。

NoSQL除了更快、承載量更大以外,更重要的特點是,這種資料儲存方式,只能按照一條索引來檢索和寫入。這樣的需求約束,帶來了分佈上的好處,我們可以按這條主索引,來定義資料存放的程式(伺服器)。這樣一個資料庫的資料,就能很方便的存放在不同的伺服器上。在分散式系統的必然趨勢下,資料儲存層終於也找到了分佈的方法。

 

分散式系統在可管理性上造成的問題

分散式系統並不是簡單的把一堆伺服器一起執行起來就能滿足需求的。對比單機或少量伺服器的叢集,有一些特別需要解決的問題等待著我們。

1.硬體故障率

所謂分散式系統,肯定就不是隻有一臺伺服器。假設一臺伺服器的平均故障時間是1%,那麼當你有100臺伺服器的時候,那就幾乎總有一臺是在故障的。雖然這個比方不一定很準確,但是,當你的系統所涉及的硬體越來越多,硬體的故障也會從偶然事件變成一個必然事件。一般我們在寫功能程式碼的時候,是不會考慮到硬體故障的時候應該怎麼辦的。而如果在編寫分散式系統的時候,就一定需要面對這個問題了。否則,很可能只有一臺伺服器出故障,整個數百臺伺服器的叢集都工作不正常了。

除了伺服器自己的記憶體、硬碟等故障,伺服器之間的網路線路故障更加常見。而且這種故障還有可能是偶發的,或者是會自動恢復的。面對這種問題,如果只是簡單的把“出現故障”的機器剔除出去,那還是不夠的。因為網路可能過一會兒就又恢復了,而你的叢集可能因為這一下的臨時故障,丟失了過半的處理能力。

如何讓分散式系統,在各種可能隨時出現故障的情況下,儘量的自動維護和維持對外服務,成為了編寫程式就要考慮的問題。由於要考慮到這種故障的情況,所以我們在設計架構的時候,也要有意識的預設一些冗餘、自我維護的功能。這些都不是產品上的業務需求,完全就是技術上的功能需求。能否在這方面提出對的需求,然後正確的實現,是伺服器端程式設計師最重要的職責之一。

 

2.資源利用率優化

在分散式系統的叢集,包含了很多個伺服器,當這樣一個叢集的硬體承載能力到達極限的時候,最自然的想法就是增加更多的硬體。然而,一個軟體系統不是那麼容易就可以通過“增加”硬體來提高承載效能的。因為軟體在多個伺服器上的工作,是需要有複雜細緻的協調工作。在對一個叢集擴容的時候,我們往往會要停掉整個叢集的服務,然後修改各種配置,最後才能重新啟動一個加入了新的伺服器的叢集。

由於在每個伺服器的記憶體裡,都可能會有一些使用者使用的資料,所以如果冒然在執行的時候,就試圖修改叢集中提供服務的配置,很可能會造成記憶體資料的丟失和錯誤。因此,執行時擴容在對無狀態的服務上,是比較容易的,比如增加一些Web伺服器。但如果是在有狀態的服務上,比如網路遊戲,幾乎是不可能進行簡單的執行時擴容的。

分散式叢集除了擴容,還有縮容的需求。當使用者人數下降,伺服器硬體資源出現空閒的時候,我們往往需要這些空閒的資源能利用起來,放到另外一些新的服務叢集裡去。縮容和叢集中有故障需要容災有一定類似之處,區別是縮容的時間點和目標是可預期的。

由於分散式叢集中的擴容、縮容,以及希望儘量能線上操作,這導致了非常複雜的技術問題需要處理,比如叢集中互相關聯的配置如何正確高效的修改、如何對有狀態的程式進行操作、如何在擴容縮容的過程中保證叢集中節點之間通訊的正常。作為伺服器端程式設計師,會需要花費大量的經歷,來對多個程式的叢集狀態變化,造成的一系列問題進行專門的開發。

 

3.軟體服務內容更新

現在都流行用敏捷開發模式中的“迭代”,來表示一個服務不斷的更新程式,滿足新的需求,修正BUG。如果我們僅僅管理一臺伺服器,那麼更新這一臺伺服器上的程式,是非常簡單的:只要把軟體包拷貝過去,然後修改下配置就好。但是如果你要對成百上千的伺服器去做同樣的操作,就不可能每臺伺服器登入上去處理。

伺服器端的程式批量安裝部署工具,是每個分散式系統開發者都需要的。然而,我們的安裝工作除了拷貝二進位制檔案和配置檔案外,還會有很多其他的操作。比如開啟防火牆、建立共享記憶體檔案、修改資料庫表結構、改寫一些資料檔案等等……甚至有一些還要在伺服器上安裝新的軟體。

如果我們在開發伺服器端程式的時候,就考慮到軟體更新、版本升級的問題,那麼我們對於配置檔案、命令列引數、系統變數的使用,就會預先做一定的規劃,這能讓安裝部署的工具執行更快,可靠性更高。

除了安裝部署的過程,還有一個重要的問題,就是不同版本間資料的問題。我們在升級版本的時候,舊版本程式生成的一些持久化資料,一般都是舊的資料格式的;而我們升級版本中如果涉及修改了資料格式,比如資料表結果,那麼這些舊格式的資料,都要轉換改寫成新版本的資料格式才行。這導致了我們在設計資料結構的時候,就要考慮清楚這些表格的結構,是用最簡單直接的表達方式,來讓將來的修改更簡單;還是一早就預計到修改的範圍,專門預設一些欄位,或者使用其他形式存放資料。

除了持久化資料以外,如果存在客戶端程式(如受擊APP),這些客戶端程式的升級往往不能和伺服器同步,如果升級的內容包含了通訊協議的修改,這就造成了我們必須為不同的版本部署不同的伺服器端系統的問題。為了避免同時維護多套伺服器,我們在軟體開發的時候,往往傾向於所謂“版本相容”的協議定義方式。而怎樣設計的協議才能有很好的相容性,又是伺服器端程式需要仔細考慮的問題。

 

4.資料統計和決策

一般來說,分散式系統的日誌資料,都是被集中到一起,然後統一進行統計的。然而,當叢集的規模到一定程度的時候,這些日誌的資料量會變得非常恐怖。很多時候,統計一天的日誌量,要消耗計算機執行一天以上的時間。所以,日誌統計這項工作,也變成一門非常專業的活動。

經典的分散式統計模型,有Google的Map Reduce模型。這種模型既有靈活性,也能利用大量伺服器進行統計工作。但是缺點是易用性往往不夠好,因為這些資料的統計和我們常見的SQL資料表統計有非常大的差異,所以我們最後還是常常把資料丟到MySQL裡面去做更細層面的統計。

由於分散式系統日誌數量的龐大,以及日誌複雜程度的提高。我們變得必須要掌握類似Map Reduce技術,才能真正的對分散式系統進行資料統計。而且我們還需要想辦法提高統計工作的工作效率。

 

解決分散式系統可管理性的基本手段

1.目錄服務(ZooKeeper)

分散式系統是一個由很多程式組成的整體,這個整體中每個成員部分,都會具備一些狀態,比如自己的負責模組,自己的負載情況,對某些資料的掌握等等。而這些和其他程式相關的資料,在故障恢復、擴容縮容的時候變得非常重要。

簡單的分散式系統,可以通過靜態的配置檔案,來記錄這些資料:程式之間的連線對應關係,他們的IP地址和埠,等等。然而一個自動化程度高的分散式系統,必然要求這些狀態資料都是動態儲存的。這樣才能讓程式自己去做容災和負載均衡的工作。

一些程式設計師會專門自己編寫一個DIR服務(目錄服務),來記錄叢集中程式的執行狀態。叢集中程式會和這個DIR服務產生自動關聯,這樣在容災、擴容、負載均衡的時候,就可以自動根據這些DIR服務裡的資料,來調整請求的傳送目地,從而達到繞開故障機器、或連線到新的伺服器的操作。

 

然而,如果我們只是用一個程式來充當這個工作。那麼這個程式就成為了這個叢集的“單點”——意思就是,如果這個程式故障了,那麼整個叢集可能都無法執行的。所以存放叢集狀態的目錄服務,也需要是分散式的。幸好我們有ZooKeeper這個優秀的開源軟體,它正是一個分散式的目錄服務區。

ZooKeeper可以簡單啟動奇數個程式,來形成一個小的目錄服務叢集。這個叢集會提供給所有其他程式,進行讀寫其巨大的“配置樹”的能力。這些資料不僅僅會存放在一個ZooKeeper程式中,而是會根據一套非常安全的演算法,讓多個程式來承載。這讓ZooKeeper成為一個優秀的分散式資料儲存系統。

由於ZooKeeper的資料儲存結構,是一個類似檔案目錄的樹狀系統,所以我們常常會利用它的功能,把每個程式都繫結到其中一個“分枝”上,然後通過檢查這些“分支”,來進行伺服器請求的轉發,就能簡單的解決請求路由(由誰去做)的問題。另外還可以在這些“分支”上標記程式的負載的狀態,這樣負載均衡也很容易做了。

目錄服務是分散式系統中最關鍵的元件之一。而ZooKeeper是一個很好的開源軟體,正好是用來完成這個任務。

 

2.訊息佇列服務(ActiveMQ、ZeroMQ、Jgroups)

兩個程式間如果要跨機器通訊,我們幾乎都會用TCP/UDP這些協議。但是直接使用網路API去編寫跨程式通訊,是一件非常麻煩的事情。除了要編寫大量的底層socket程式碼外,我們還要處理諸如:如何找到要互動資料的程式,如何保障資料包的完整性不至於丟失,如果通訊的對方程式掛掉了,或者程式需要重啟應該怎樣等等這一系列問題。這些問題包含了容災擴容、負載均衡等一系列的需求。

為了解決分散式系統程式間通訊的問題,人們總結出了一個有效的模型,就是“訊息佇列”模型。訊息佇列模型,就是把程式間的互動,抽象成對一個個訊息的處理,而對於這些訊息,我們都有一些“佇列”,也就是管道,來對訊息進行暫存。每個程式都可以訪問一個或者多個佇列,從裡面讀取訊息(消費)或寫入訊息(生產)。由於有一個快取的管道,我們可以放心的對程式狀態進行變化。當程式起來的時候,它會自動去消費訊息就可以了。而訊息本身的路由,也是由存放的佇列決定的,這樣就把複雜的路由問題,變成了如何管理靜態的佇列的問題。

一般的訊息佇列服務,都是提供簡單的“投遞”和“收取”兩個介面,但是訊息佇列本身的管理方式卻比較複雜,一般來說有兩種。一部分的訊息佇列服務,提倡點對點的佇列管理方式:每對通訊節點之間,都有一個單獨的訊息佇列。這種做法的好處是不同來源的訊息,可以互不影響,不會因為某個佇列的訊息過多,擠佔了其他佇列的訊息快取空間。而且處理訊息的程式也可以自己來定義處理的優先順序——先收取、多處理某個佇列,而少處理另外一些佇列。

但是這種點對點的訊息佇列,會隨著叢集的增長而增加大量的佇列,這對於記憶體佔用和運維管理都是一個複雜的事情。因此更高階的訊息佇列服務,開始可以讓不同的佇列共享記憶體空間,而訊息佇列的地址資訊、建立和刪除,都採用自動化的手段。——這些自動化往往需要依賴上文所述的“目錄服務”,來登記佇列的ID對應的物理IP和埠等資訊。比如很多開發者使用ZooKeeper來充當訊息佇列服務的中央節點;而類似Jgropus這類軟體,則自己維護一個叢集狀態來存放各節點今昔。

 

另外一種訊息佇列,則類似一個公共的郵箱。一個訊息佇列服務就是一個程式,任何使用者都可以投遞或收取這個程式中的訊息。這樣對於訊息佇列的使用更簡便,運維管理也比較方便。不過這種用法下,任何一個訊息從發出到處理,最少進過兩次程式間通訊,其延遲是相對比較高的。並且由於沒有預定的投遞、收取約束,所以也比較容易出BUG。

不管使用那種訊息佇列服務,在一個分散式伺服器端系統中,程式間通訊都是必須要解決的問題,所以作為伺服器端程式設計師,在編寫分散式系統程式碼的時候,使用的最多的就是基於訊息佇列驅動的程式碼,這也直接導致了EJB3.0把“訊息驅動的Bean”加入到規範之中。

 

3.事務系統

在分散式的系統中,事務是最難解決的技術問題之一。由於一個處理可能分佈在不同的處理程式上,任何一個程式都可能出現故障,而這個故障問題則需要導致一次回滾。這種回滾大部分又涉及多個其他的程式。這是一個擴散性的多程式通訊問題。要在分散式系統上解決事務問題,必須具備兩個核心工具:一個是穩定的狀態儲存系統;另外一個是方便可靠的廣播系統。

 

事務中任何一步的狀態,都必須在整個叢集中可見,並且還要有容災的能力。這個需求,一般還是由叢集的“目錄服務”來承擔。如果我們的目錄服務足夠健壯,那麼我們可以把每步事務的處理狀態,都同步寫到目錄服務上去。ZooKeeper再次在這個地方能發揮重要的作用。

如果事務發生了中斷,需要回滾,那麼這個過程會涉及到多個已經執行過的步驟。也許這個回滾只需要在入口處回滾即可(加入那裡有儲存回滾所需的資料),也可能需要在各個處理節點上回滾。如果是後者,那麼就需要叢集中出現異常的節點,向其他所有相關的節點廣播一個“回滾!事務ID是XXXX”這樣的訊息。這個廣播的底層一般會由訊息佇列服務來承載,而類似Jgroups這樣的軟體,直接提供了廣播服務。

雖然現在我們在討論事務系統,但實際上分散式系統經常所需的“分散式鎖”功能,也是這個系統可以同時完成的。所謂的“分散式鎖”,也就是一種能讓各個節點先檢查後執行的限制條件。如果我們有高效而單子操作的目錄服務,那麼這個鎖狀態實際上就是一種“單步事務”的狀態記錄,而回滾操作則預設是“暫停操作,稍後再試”。這種“鎖”的方式,比事務的處理更簡單,因此可靠性更高,所以現在越來越多的開發人員,願意使用這種“鎖”服務,而不是去實現一個“事務系統”。

 

 

 

4.自動部署工具(Docker)

由於分散式系統最大的需求,是在執行時(有可能需要中斷服務)來進行服務容量的變更:擴容或者縮容。而在分散式系統中某些節點故障的時候,也需要新的節點來恢復工作。這些如果還是像老式的伺服器管理方式,通過填表、申報、進機房、裝伺服器、部署軟體……這一套做法,那效率肯定是不行。

在分散式系統的環境下,我們一般都是採用“池”的方式來管理服務。我們預先會申請一批機器,然後在某些機器上執行服務軟體,另外一些則作為備份。顯然我們這一批伺服器不可能只為某一個業務服務,而是會提供多個不同的業務承載。那些備份的伺服器,則會成為多個業務的通用備份“池”。隨著業務需求的變化,一些伺服器可能“退出”A服務而“加入”B服務。

這種頻繁的服務變化,依賴高度自動的軟體部署工具。我們的運維人員,應該掌握這開發人員提供的部署工具,而不是厚厚的手冊,來進行這類運維操作。一些比較有經驗的開發團隊,會統一所有的業務底層框架,以期大部分的部署、配置工具,都能用一套通用的系統來進行管理。而開源界,也有類似的嘗試,最廣為人知的莫過於RPM安裝包格式,然而RPM的打包方式還是太複雜,不太符合伺服器端程式的部署需求。所以後來又出現了Chef為代表的,可程式設計的通用部署系統。

 

在虛擬機器技術出現之後,PaaS平臺為自動部署提供了強大的支援:如果我們是按某個PaaS平臺的規範來編寫的應用,可以完全把程式丟給平臺去部署,其承載量計算、部署規劃,都自動完成了。這方面的佼佼者是Google的AppEngine:我們可以直接用Eclipse開發一個本地的Web應用,然後上傳到AppEngine裡面,所有的部署就完成了!AppEngine會自動的根據對這個Web應用的訪問量,來進行擴容、縮容、故障恢復。

然而,真正有革命性的工具,是Docker的出現。雖然虛擬機器、沙箱技術早就不是什麼新技術,但是真正使用這些技術來作為部署工具的時間卻不長。Linux高效的輕量級容器技術,提供了部署上巨大的便利性——我們可以在各種庫、各種協作軟體的環境下打包我們的應用程式,然後隨意的部署在任何一個Linux系統上。

 

 

為了管理大量的分散式伺服器端程式,我們確實需要花很多功夫,其優化其部署管理的工作。統一伺服器端程式的執行規範,是實現自動化部署管理的基本條件。我們可以根據“作業系統”作為規範,採用Docker技術;也可以根據“Web應用”作為規範,採用某些PaaS平臺技術;或者自己定義一些更具體的規範,自己開發完整的分散式計算平臺。

 

5.日誌服務(log4j)

伺服器端的日誌,一直是一個既重要又容易被忽視的問題。很多團隊在剛開始的時候,僅僅把日誌視為開發除錯、排除BUG的輔助工具。但是很快會發現,在服務運營起來之後,日誌幾乎是伺服器端系統,在執行時可以用來了解程式情況的唯一有效手段。

儘管我們有各種profile工具,但是這些工具大部分都不適合在正式運營的服務上開啟,因為會嚴重降低其執行效能。所以我們更多的時候需要根據日誌來分析。儘管日誌從本質上,就是一行行的文字資訊,但是由於其具有很大的靈活性,所以會很受開發和運維人員的重視。

日誌本身從概念上,是一個很模糊的東西。你可以隨便開啟一個檔案,然後寫入一些資訊。但是現代的伺服器系統,一般都會對日誌做一些標準化的需求規範:日誌必須是一行一行的,這樣比較方便日後的統計分析;每行日誌文字,都應該有一些統一的頭部,比如日期時間就是基本的需求;日誌的輸出應該是分等級的,比如fatal/error/warning/info/debug/trace等等,程式可以在執行時調整輸出的等級,以便可以節省日誌列印的消耗;日誌的頭部一般還需要一些類似使用者ID或者IP地址之類的頭資訊,用於快速查詢定位過濾某一批日誌記錄,或者有一些其他的用於過濾縮小日誌檢視範圍的欄位,這叫做染色功能;日誌檔案還需要有“回滾”功能,也就是保持固定大小的多個檔案,避免長期執行後,把硬碟寫滿。

 

 

由於有上述的各種需求,所以開源界提供了很多遊戲的日誌元件庫,比如大名鼎鼎的log4j,以及成員眾多的log4X家族庫,這些都是應用廣泛而飽受好評的工具。

不過對比日誌的列印功能,日誌的蒐集和統計功能卻往往比較容易被忽視。作為分散式系統的程式設計師,肯定是希望能從一個集中節點,能蒐集統計到整個叢集日誌情況。而有一些日誌的統計結果,甚至希望能在很短時間內反覆獲取,用來監控整個叢集的健康情況。要做到這一點,就必須有一個分散式的檔案系統,用來存放源源不斷到達的日誌(這些日誌往往通過UDP協議傳送過來)。而在這個檔案系統上,則需要有一個類似Map Reduce架構的統計系統,這樣才能對海量的日誌資訊,進行快速的統計以及報警。有一些開發者會直接使用Hadoop系統,有一些則用Kafka來作為日誌儲存系統,上面再搭建自己的統計程式。

日誌服務是分散式運維的儀表盤、潛望鏡。如果沒有一個可靠的日誌服務,整個系統的執行狀況可能會是失控的。所以無論你的分散式系統節點是多還是少,必須花費重要的精力和專門的開發時間,去建立一個對日誌進行自動化統計分析的系統。

 

 

分散式系統在開發效率上造成的問題和解決思路

根據上文所述,分散式系統在業務需求的功能以為,還需要增加額外很多非功能的需求。這些非功能需求,往往都是為了一個多程式系統能穩定可靠執行而去設計和實現的。這些“額外”的工作,一般都會讓你的程式碼更加複雜,如果沒有很好的工具,就會讓你的開發效率嚴重下降。

1.微服務框架:EJB、WebService

當我們在討論伺服器端軟體分佈的時候,服務程式之間的通訊就難免了。然而服務程式間的通訊,並不是簡單的收發訊息就能完成的。這裡還涉及了訊息的路由、編碼解碼、服務狀態的讀寫等等。如果整個流程都由自己開發,那就太累人了。

 

 

所以業界很早就推出了各種分散式的伺服器端開發框架,最著名的就是“EJB”——企業JavaBean。但凡冠以“企業”的技術,往往都是分散式下所需的部分,而EJB這種技術,也是一種分散式物件呼叫的技術。我們如果需要讓多個程式合作完成任務,則需要把任務分解到多個“類”上,然後這些“類”的物件就會在各個程式容器中存活,從而協作提供服務。這個過程很“物件導向”。每個物件都是一個“微服務”,可以提供某些分散式的功能。

而另外一些系統,則走向學習網際網路的基本模型:HTTP。所以就有了各種的WebService框架,從開源的到商業軟體,都有各自的WebService實現。這種模型,把複雜的路由、編解碼等操作,簡化成常見的一次HTTP操作,是一種非常有效的抽象。開發人員開發和部署多個WebService到Web伺服器上,就完成了分散式系統的搭建。

 

 

不管我們是學習EJB還是WebService,實際上我們都需要簡化分散式呼叫的複雜程度。而分散式呼叫的複雜之處,就是因為需要把容災、擴容、負載均衡等功能,融合到跨程式呼叫裡。所以使用一套通用的程式碼,來為所有的跨程式通訊(呼叫),統一的實現容災、擴容、負載均衡、過載保護、狀態快取命中等等非功能性需求,能大大簡化整個分散式系統的複雜性。

一般我們的微服務框架,都會在路由階段,對整個叢集所有節點的狀態進行觀察,如哪些地址上執行了哪些服務的程式,這些服務程式的負載狀況如何,是否可用,然後對於有狀態的服務,還會使用類似一致性雜湊的演算法,去儘量試圖提高快取的命中率。當叢集中的節點狀態發生變化的時候,微服務框架下的所有節點,都能儘快的獲得這個變化的情況,從新根據當前狀態,重新規劃以後的服務路由方向,從而實現自動化的路由選擇,避開那些負載過高或者失效的節點。

有一些微服務框架,還提供了類似IDL轉換成“骨架”、“樁”程式碼的工具,這樣在編寫遠端呼叫程式的時候,完全無需編寫那些複雜的網路相關的程式碼,所有的傳輸層、編碼層程式碼都自動的編寫好了。這方面EJB、Facebook的Thrift,Google gRPC都具備這種能力。在具備程式碼生成能力的框架下,我們編寫一個分散式下可用的功能模組(可能是一個函式或者是一個類),就好像編寫一個本地的函式那樣簡單。這絕對是分散式系統下非常重要的效率提升。

 

 

2.非同步程式設計工具:協程、Futrue、Lamda

在分散式系統中程式設計,你不可避免的會碰到大量的“回撥”型API。因為分散式系統涉及非常多的網路通訊。任何一個業務命令,都可能被分解到多個程式,通過多次網路通訊來組合完成。由於非同步非阻塞的程式設計模型大行其道,所以我們的程式碼也往往動不動就要碰到“回撥函式”。然而,回撥這種非同步程式設計模型,是一種非常不利於程式碼閱讀的程式設計方法。因為你無法從頭到尾的閱讀程式碼,去了解一個業務任務,是怎樣被逐步的完成的。屬於一個業務任務的程式碼,由於多次的非阻塞回撥,從而被分割成很多個回撥函式,在程式碼的各處被串接起來。

更有甚者,我們有時候會選擇使用“觀察者模式”,我們會在一個地方註冊大量的“事件-響應函式”,然後在所有需要回撥的地方,都發出一個事件。——這樣的程式碼,比單純的註冊回撥函式更難理解。因為事件對應的響應函式,通常在發出事件處是無法找到的。這些函式永遠都會放在另外的一些檔案裡,而且有時候這些函式還會在執行時改變。而事件名字本身,也往往是匪夷所思難以理解的,因為當你的程式需要成千上百的事件的時候,起一個容易理解名符其實的名字,幾乎是不可能的。

為了解決回撥函式這種對於程式碼可讀性的破壞作用,人們發明了很多不同的改進方法。其中最著名的是“協程”。我們以前常常習慣於用多執行緒來解決問題,所以非常熟悉以同步的方式去寫程式碼。協程正是延續了我們的這一習慣,但不同於多執行緒的是,協程並不會“同時”執行,它只是在需要阻塞的地方,用Yield()切換出去執行其他協程,然後當阻塞結束後,用Resume()回到剛剛切換的位置繼續往下執行。這相當於我們可以把回撥函式的內容,接到Yield()呼叫的後面。這種編寫程式碼的方法,非常類似於同步的寫法,讓程式碼變得非常易讀。但是唯一的缺點是,Resume()的程式碼還是需要在所謂“主執行緒”中執行。使用者必須自己從阻塞恢復的時候,去呼叫Resume()。協程另外一個缺點,是需要做棧儲存,在切換到其他協程之後,棧上的臨時變數,也都需要額外佔用空間,這限制了協程程式碼的寫法,讓開發者不能用太大的臨時變數。

而另外一種改善回撥函式的寫法,往往叫做Future/Promise模型。這種寫法的基本思路,就是“一次性把所有回撥寫到一起”。這是一個非常實用的程式設計模型,它沒有讓你去徹底幹掉回撥,而是讓你可以把回撥從分散各處,集中到一個地方。在同一段程式碼中,你可以清晰的看到各個非同步的步驟是如何串接、或者並行執行的。

 

最後說一下lamda模型,這種寫法流行於js語言的廣泛應用。由於在其他語言中,定一個回撥函式是非常費事的:Java語言要設計一個介面然後做一個實現,簡直是五星級的費事程度;C/C++支援函式指標,算是比較簡單,但是也很容易導致程式碼看不懂;指令碼語言相對好一些,也要定義個函式。而直接在呼叫回撥的地方,寫回撥函式的內容,是最方便開發,也比較利於閱讀的。更重要的,lamda一般意味著閉包,也就是說,這種回撥函式的呼叫棧,是被分別儲存的,很多需要在非同步操作中,需要建立一個類似“會話池”的狀態儲存變數,在這裡都是不需要的,而是可以自然生效的。這一點和協程有異曲同工之妙。

 

 

不管使用哪一種非同步程式設計方式,其編碼的複雜度,都是一定比同步呼叫的程式碼高的。所以我們在編寫分散式伺服器程式碼的時候,一定要仔細規劃程式碼結構,避免出現隨意新增功能程式碼,導致程式碼的可讀性被破壞的情況。不可讀的程式碼,就是不可維護的程式碼,而大量非同步回撥的伺服器端程式碼,是更容易出現這種情況的。

雲服務模型:IaaS/PaaS/SaaS

在複雜的分散式系統開發和使用過程中,如何對大量伺服器和程式的運維,一直是一個貫穿其中的問題。不管是使用微服務框架、還是統一的部署工具、日誌監控服務,都是因為大量的伺服器,要集中的管理,是非常不容易的。這裡背後的原因,主要是大量的硬體和網路,把邏輯上的計算能力,切割成很多小塊。

隨著計算機運算能力的提升,出現的虛擬化技術,卻能把被分割的計算單元,更智慧的統一起來。其中最常見的就是IaaS技術:當我們可以用一個伺服器硬體,執行多個虛擬的伺服器作業系統的時候,我們需要維護的硬體數量就會成倍的下降。

而PaaS技術的流行,讓我們可以為某一種特定的程式設計模型,統一的進行系統執行環境的部署維護。而不需要再一臺臺伺服器的去裝作業系統、配置執行容器、上傳執行程式碼和資料。在沒有統一的PaaS之前,安裝大量的MySQL資料庫,曾經是消耗大量時間和精力的工作。

當我們的業務模型,成熟到可以抽象為一些固定的軟體時,我們的分散式系統就會變得更加易用。我們的計算能力不再是程式碼和庫,而是一個個通過網路提供服務的雲——SaaS,這樣使用者根本來維護、部署的工作都不需要,只要申請一個介面,填上預期的容量額度,就能直接使用了。這不僅節省了大量開發對應功能的事件,還等於把大量的運維工作,都交出去給SaaS的維護者——而他們做這樣的維護會更加專業。

 

 

在運維模型的進化上,從IaaS到PaaS到SaaS,其應用範圍也許是越來越窄,但使用的便利性卻成倍的提高。這也證明了,軟體勞動的工作,也是可以通過分工,向更專業化、更細分的方向去提高效率。

 原文地址:https://www.qcloud.com/community/article/161?utm_source=Community&utm_medium=article161&utm_campaign=qcloud

 

相關文章