服務的擴充套件性

發表於2016-01-06

在編寫一個應用時,我們常常考慮的是該應用應該如何實現特定的業務邏輯。但是在逐漸發展出越來越多的使用者後,這些應用常常會暴露出一系列問題,如不容易增大容量,容錯性差等等。這常常會導致這些應用在市場的擴充過程中無法快速地響應使用者的需求,並最終失去商業上的先機。

通常情況下,我們將應用所具有的用來避免這一系列問題的特徵稱為非功能性需求。相信您已經能夠從字面意義上理解這個名詞了:功能性需求用來提供對業務邏輯的支援,而非功能性需求則是一系列和業務邏輯無關,卻可能影響到產品後續發展的一系列需求。這些需求常常包括:高可用性(High Avalibility),擴充套件性(Scalability),維護性(Maintainability),可測試性(Testability)等等。

而在這些非功能性需求中,擴充套件性可能是最有趣的一種了。因此在本文中,我們將對如何編寫一個具有高可擴充套件性的應用進行講解。

什麼是擴充套件性

假設我們編寫了一個Web應用,並將其置於共有云上以向使用者提供服務。該應用的創意非常新穎,並在短時間內就吸引了大量的使用者。但是由於我們在編寫該應用時並沒有期望它來處理這麼多使用者的請求,因此它的執行速度越來越慢,甚至可能出現服務沒有響應的情況。頻繁發生這種事情的結果就是,使用者將無法忍受該應用經常性地當機,並將尋找其它類似應用來獲得類似的服務。

該應用所缺少的能夠根據負載來對處理能力進行適當擴充套件的能力便是應用的擴充套件性,而其衡量的標準則是處理能力擴充套件的簡單程度。如果您的應用在新增了更多記憶體後就能執行得更好,或者通過新增一個額外的服務例項就能解決服務例項過載的問題,那麼我們就可以說該應用的擴充套件性非常好。如果為了處理更多的負載而不得不重寫整個應用,那麼應用的開發者就需要在多多注意應用的擴充套件性了。

較好的擴充套件性不僅可以省卻您重寫應用的麻煩,更重要的是,它會幫助您在市場的爭奪中獲得先機。試想一下,如果您的應用已經出現了處理能力不夠的苗頭,卻沒有適當的解決方案來提高整個系統的處理能力,那麼您能做的事情只能是重新編寫一個具有更高處理能力的具有同一個功能的應用。在該段時間內,您的應用的處理能力顯得越來越捉襟見肘。而體現在客戶層面上的,則是您的應用的響應速度越來越慢,甚至有時都無法正常工作。在新應用上線之前,您的應用將逐漸地流失客戶。而這些流失的客戶則很有可能變成類似軟體的忠實客戶,從而使得您的產品失去了市場競爭的先機。反過來,如果您的應用具有非常良好的擴充套件性,而您的競爭對手並沒有跟上使用者的增長速度,那麼的應用就有了完全超越甚至壓制競爭對手的可能。

當然,一個成功的應用不應該僅僅擁有高擴充套件性,而是應該在一系列非功能性需求上都做得很好。例如您的應用不應該有太多的Bug,也不應該有特別嚴重的Bug,以避免由於這些Bug導致您的使用者無法正常使用應用。同時您的應用需要擁有較好的使用者體驗,這樣才能讓這些使用者非常容易地熟悉您的應用,併產生使用者粘性。

當然,這些非功能性需求並不僅僅侷限在使用者的角度。例如從開發團隊的角度來講,一個軟體的可測試性常常決定了測試組的工作效率。如果一個應用需要在幾十臺機器上逐一安裝部署,那麼每次測試人員對新版本的驗證都需要幾個小時甚至成天的時間才能準備完畢。測試組也就很自然地成為了該軟體開發組中效率最為低下的一部分。為此我們就需要招入大量的測試人員,大大地增加了應用的整體開銷。

總的來說,一個應用所具有的非功能性需求非常多,如完整性(Completeness),正確性(Correctness),可用性(Availability),可靠性(Reliability),安全(Security),擴充套件性(Scalability),效能(Performance)等等。而這些需求都會對如何分析,設計以及編碼提出一定的要求。不同的非功能性需求所提出的要求常常會發生衝突。而到底哪個非功能性需求更為重要則需要根據您所編寫的應用型別來決定。例如在編寫一個大規模Web應用的時候,擴充套件性,安全以及可用性較為重要,而對於一個實時應用來說,效能以及可靠性則佔據上風。在這篇文章中,我們的討論將主要集中在擴充套件性上。因此其所提出的一系列建議可能會對其它的非功能性需求產生較大的影響。而到底如何取捨則需要讀者根據應用的實際情況自行決定。

應用的擴充套件方法

好的,讓我們重新回到擴充套件性這個話題上來。導致一個軟體需要擴充套件的最根本原因實際上還是其所需要面對的吞吐量。在使用者的一個請求到達時,服務例項需要對它進行處理並將其轉化為對資料的操作。在這個過程中,服務例項以及資料庫都需要消耗一定的資源。如果使用者的請求過多從而導致應用中的某個組成所無法應對,那麼我們就需要想辦法提高該組成的資料處理能力。

提高資料處理能力的方法主要分為兩類,那就是縱向擴充套件及橫向擴充套件。而這兩種方法所對應的操作就是Scale Up以及Scale Out。

縱向擴充套件表示在需要處理更多負載時通過提高單個系統處理能力的方法來解決問題。最簡單的情況就是為該系統提供更為強大的硬體。例如如果資料庫所在的伺服器例項只有2G記憶體,進而導致了資料庫不能高效地執行,那麼我們就可以通過將該伺服器的記憶體擴充套件至8G來解決這個問題:

上圖所展示的就是通過新增記憶體進行縱向擴充套件,以解決資料庫所在服務例項IO過高的情況:當執行資料庫服務的伺服器所包含的記憶體不能載入資料庫中所儲存的最為常見的資料時,其會不斷地從硬碟中讀取持久化到磁碟中的記憶體頁面,從而導致資料庫的效能大幅下降。而在將伺服器的記憶體擴充套件到8G的情況下,那些常用資料就能夠長時間地駐留在記憶體中,從而使得資料庫所在服務例項的磁碟IO迅速回復正常。

除了通過硬體方法來提高單個服務例項的效能之外,我們還可以通過優化軟體的執行效率來完成應用的縱向擴充套件。最簡單的示例就是,如果原有的服務實現只能使用單執行緒來處理資料,而不能同時利用伺服器例項中所包含的多個CPU核心,那麼我們可以通過將演算法更改為多執行緒來充分利用CPU的多核計算能力,成倍地提高服務的執行效率。

但是縱向擴充套件並非總是最正確的選擇。影響我們選擇的最常見因素就是硬體的成本。我們知道,硬體的價格通常與該硬體所處的定位有關。如果一個硬體是當前市場上的主流配置,那麼由於它已經大量出貨,因此平攤的研發成本在每件硬體中已經變得非常小。反過來,如果一個硬體是剛剛投入市場的高階產品,那麼每件硬體所包含的研發成本將會非常多。因此縱向擴充套件的投入效能比曲線常常如下所示:

也就是說,在單個例項優化到一定程度以後,再花費大量的時間和金錢來對單個例項的效能進行提高已經沒有太多的意義了。在這個時候,我們就需要考慮橫向擴充套件,也就是使用多個服務例項來一起提供服務。

就以一個線上的影象處理服務為例。由於影象處理是一個非常消耗資源的計算過程,因此單個伺服器常常無法滿足大量使用者所傳送的請求:

就像上圖中所展示的那樣,雖然我們的伺服器已經安裝了4個CPU,但是在單個伺服器例項提供服務的情況下,CPU使用率還是一直處於警戒線之上。如果我們再在應用中新增一個相同的伺服器來共同處理使用者的請求,那麼每臺伺服器的負載將會降到原有負載的一半左右,從而使得CPU使用率保持在警戒線之下。

在這種情況下,該服務所提供的一系列其它功能也隨之得到了擴充。例如對處理結果進行儲存的功能的效能也將變成原來的兩倍。只是由於我們暫時並不需要這種擴充,因此該部分效能的增強實際上是毫無用處的,甚至造成了服務資源的浪費:

從上圖中可以看到,在沒有橫向擴充套件之前,橙色組成的負載已經達到了90%,接近單個服務例項的極限。為了解決這個問題,我們再引入一個伺服器例項來分擔工作。但是這樣會導致其它幾個本來資源利用率就已經不高的組成的利用率降得更低。而更為正確的擴充套件方式則是隻擴充套件橙色組成:

從上面的講解中可以看出,橫向擴充套件實際上包含了很多種方式。相應地,《The Art of Scalability》一書則介紹了一個橫向擴充套件所需要遵守的AKF擴充套件模型。根據AKF擴充套件模型,橫向擴充套件實際上包含了三個維度,而橫向擴充套件解決方案則是這三個維度上所做工作的結合:

上圖中展示了AKF擴充套件模型的最通用的表示形式。在該圖中,原點O表示的是應用例項並沒有能力執行任何橫向擴充套件,而只能通過縱向擴充套件來提高它的服務能力。如果您的系統朝著某個座標軸的方向前進,那麼它就將得到一定程度的橫向擴充套件能力。當然,這三個座標軸並不互斥,因此您的應用可能同時擁有XYZ三個軸向的擴充套件能力:

現在就讓我們來看一下AKF擴充套件模型中各個座標軸的意義。首先要講解的就是X軸。在AKF擴充套件模型中,X軸表示的是應用可以通過部署更多的服務例項來解決擴充套件性的問題。在這種情況下,原本需要少量服務例項處理的大量負載就可以通過新新增的其它服務例項分擔,從而擴大了系統容量,降低了單個服務例項的壓力。

我們剛剛提到過,一個服務的擴充套件性可以同時由多個軸向的擴充套件性共同組成,因此在該服務中,這種X軸方向的擴充套件性不僅僅存在於服務這個層次上,更可以由子服務,甚至服務組成的擴充套件性來共同完成:

請注意上圖中的橙色方塊。在該服務中,橙色方塊作為一個子服務來向整個服務提供特定功能。在需要擴充套件時,我們可以通過新增一個新的橙色子服務例項來解決橙色服務負載過大的問題。因此就整個服務而言,其X軸的橫向擴充套件能力並不是通過重新部署整套服務來完成的,而是對獨立的子服務進行擴容。

相信您會問:既然只通過新增新的服務或子服務例項就能夠完成對服務容量的擴充,那麼我們還需要其它兩個軸向的橫向擴充套件能力麼?

答案是肯定的。首先,最為現實的問題就是服務執行場景的約束。例如在對服務進行X軸橫向擴充套件的時候,我們常常需要一個負載平衡服務。在《企業級負載平衡簡介》一文中我們已經說過,負載平衡伺服器常常具有一定的效能限制。因此橫向擴充套件並非全無止境。除此之外,我們也看到了橫向擴充套件有時是使用在子服務上的,而將一個大的服務分割為多個子服務,本身也是沿著其它軸向的橫向擴充套件。

Y軸橫向擴充套件的意義則在於將所有的工作根據資料的型別或業務邏輯進行劃分。而就一個Web服務而言,Y軸橫向擴充套件所做的最主要工作就是將一個Monolith服務劃分為一系列子服務,從而使不同的子服務獨立工作並擁有獨立地進行橫向擴充套件的能力。這一方面可以將原本一個服務所處理的所有請求分擔給一系列子服務例項來執行,更可以讓您根據應用的實際執行情況來對某個成為系統瓶頸的子服務進行X軸橫向擴充套件,避免由於對整個服務進行X軸橫向擴充套件所造成的資源浪費:

這種組織各個子服務的方式被稱為Microservice。使用Microservice組織子服務還可以幫助您實現一系列其它非功能性需求,如高可用性,可測試性等等。具體內容詳見《Microservice架構模式簡介》一文。

相較而言,執行Y軸擴充套件要比執行X軸擴充套件困難一些。但是其常常會使得其它一系列非功能性需求具有更高的質量。

而在Z軸上的橫向擴充套件可能是大家所最不熟悉的情況。其表示需要根據使用者的某些特性對使用者的請求進行劃分。例如使用基於DNS的負載平衡。

當然,到底您的服務需要實現什麼程度的X,Y,Z軸擴充套件能力則需要根據服務的實際情況來決定。如果一個應用的最終規模並不大,那麼只擁有X軸擴充套件能力,或者有部分Y軸擴充套件能力即可。如果一個應用的增長非常迅速,並最終演變為對吞吐量有極高要求的應用,那麼我們就需要從一開始就考慮這個應用在X,Y,Z軸的擴充套件能力。

服務的擴充套件

好了,介紹了那麼多理論知識,相信您已經迫不及待地想要了解如何令一個應用具有良好的擴充套件性了吧。那好,讓我們首先從服務例項的擴充套件性說起。

我們已經在前面介紹過,對服務進行擴充套件主要有兩種方法:橫向擴充套件以及縱向擴充套件。對於服務例項而言,橫向擴充套件非常簡單:無非是將服務分割為眾多的子服務並在負載平衡等技術的幫助下在應用中新增新的服務例項:

上圖展示了服務例項是如何按照AKF擴充套件模型進行橫向擴充套件的。在該圖的最頂層,我們使用了基於DNS的負載平衡。由於DNS擁有根據使用者所在位置決定距離使用者最近的服務這一功能,因此使用者在DNS查詢時所得到的IP將指向距離自己最近的服務。例如一個處於美國西部的使用者在訪問Google時所得到的IP可能就是64.233.167.99。這一功能便是AKF擴充套件模型中的Z軸:根據使用者的某些特性對使用者的請求進行劃分。

接下來,負載平衡伺服器就會根據使用者所訪問地址的URL來對使用者的請求進行劃分。例如使用者在訪問網頁搜尋服務時,服務叢集需要使用左邊的虛線方框中的服務例項來為使用者服務。而在訪問圖片搜尋服務時,服務叢集則需要使用右邊虛線方框中的服務例項。這則是AKF擴充套件模型中的Y軸:根據資料的型別或業務邏輯來劃分請求。

最後,由於使用者所最常使用的服務就是網頁搜尋,而單個服務例項的效能畢竟有限,因此服務叢集中常常包含了多個用來提供網頁搜尋服務的服務例項。負載平衡伺服器會根據各個服務例項的能力以及服務例項的狀態來對使用者的請求進行分發。而這則是沿著AKF擴充套件模型中的X軸進行擴充套件:通過部署具有相同功能的服務例項來分擔整個負載。

可以看到,在負載平衡伺服器的幫助下,對應用例項進行橫向擴充套件是非常簡單的事情。如果您對負載平衡功能比較感興趣,請檢視我的另一篇博文《企業級負載平衡簡介》。

相較於服務的橫向擴充套件,服務的縱向擴充套件則是一個常常被軟體開發人員所忽視的問題。橫向擴充套件誠然可以提供近乎無限的系統容量,但是如果一個服務例項本身的效能就十分低下,那麼這種無限的橫向擴充套件常常是在浪費金錢:

就像上圖中所展示的那樣,一個應用當然可以通過部署4臺具有同樣功能的伺服器來為使用者提供服務。在這種情況下,搭建該服務的開銷是5萬美元。但是由於應用實現本身的質量不高,因此這四臺伺服器的資源使用率並不高。如果一個肯於動腦的軟體開發人員能夠仔細地分析服務例項中的系統瓶頸並加以改正,那麼公司將可能只需要購買一臺伺服器,而員工的個人能力及薪水都會得到提升,並可能得到一筆額外的嘉獎。如果該員工為應用所新增的縱向擴充套件性足夠高,那麼該應用將可以在具有更高效能的伺服器上執行良好。也就是說,單個服務例項的縱向擴充套件性不僅僅可以充分利用現有硬體所能提供的效能,以輔助降低搭建整個服務的花費,更可以相容具有更強資源的伺服器。這就使得我們可以通過簡單地調整伺服器設定來完成對整個服務的增強,如新增更多的記憶體,或者使用更高速的網路等方法。

現在就讓我們來看看如何提高單個服務例項的擴充套件性。在一個應用中,服務例項常常處於核心位置:其接受使用者的請求,並在處理使用者請求的過程中從資料庫中讀取資料。接下來,服務例項會通過計算將這些資料庫中得到的資料糅合在一起,並作為對使用者請求的響應將其返回。在整個處理過程中,服務例項還可能通過服務端快取取得之前計算過程中已經得到的結果:

也就是說,服務例項在執行時常常通過向其它組成傳送請求來得到執行時所需要的資料。由於這些請求常常是一個阻塞呼叫,服務例項的執行緒也會被阻塞,進而影響了單個執行緒在服務中執行的效率:

從上圖中可以看到,如果我們使用了阻塞呼叫,那麼在呼叫另一個組成以獲得資料的時候,呼叫方所在的執行緒將被阻塞。在這種情況下,整個執行過程需要3份時間來完成。而如果我們使用了非阻塞呼叫,那麼呼叫方在等待其它組成的響應時可以執行其它任務,從而使得其在4份時間內可以處理兩個任務,相當於提高了50%的吞吐量。

因此在編寫一個高吞吐量的服務實現時,您首先需要考慮是否應該使用Java所提供的非阻塞IO功能。通常情況下,由非阻塞IO組織的服務會比由阻塞IO所編寫的服務慢,但是其在高負載的情況下的吞吐量較非阻塞IO所編寫的服務高很多。這其中最好的證明就是Tomcat對非阻塞IO的支援。

在較早的版本中,Tomcat會在一個請求到達時為該請求分配一個獨立的執行緒,並由該執行緒來完成該請求的處理。一旦該請求的處理過程中出現了阻塞呼叫,那麼該執行緒將掛起直至阻塞呼叫返回。而在該請求處理完畢後,負責處理該請求的執行緒將被送回到執行緒池中等待對下一個請求進行處理。在這種情況下,Tomcat所能並行處理的最大吞吐量實際上與其執行緒池中的執行緒數量相關。反過來,如果將執行緒數量設定得過大,那麼作業系統將忙於處理執行緒的管理及切換等一系列工作,反而降低了效率。而在一些較新版本中,Tomcat則允許使用者使用非阻塞IO。在這種情況下,Tomcat將擁有一系列用來接收請求的執行緒。一旦請求到達,這些執行緒就會接收該請求,並將請求轉給真正處理請求的工作執行緒。因此在新版Tomcat的執行過程中將只包括幾十個執行緒,卻能夠同時處理成千上萬的請求。當然,由於非阻塞IO是非同步的,而不是在呼叫返回時就立即執行後續處理,因此其處理單個請求的時間較使用阻塞IO所需要的時間長。

因此在服務少量的使用者時,使用非阻塞IO的Tomcat對於單個請求的響應時間常常是Tomcat的2倍以上,但是在使用者數量是成千上萬個的時候,使用非阻塞IO的Tomcat的吞吐量則非常穩定:

因此如果想要提高您的單個服務效能,首先您需要保證您在Tomcat等Web容器中正確地使用了非阻塞模式:

protocol=”org.apache.coyote.http11.Http11NioProtocol” redirectPort=”8443″/>

當然,使用非阻塞IO並不僅僅是通過配置Tomcat就完成了。試想在一個子服務實現中呼叫另一個子服務的情況:如果在呼叫子服務時呼叫方被阻塞,那麼呼叫方的一個執行緒就被阻塞在那裡,而不能處理其它待處理的請求。因此在您的應用中包含了較長時間的的阻塞呼叫時,您需要考慮使用非阻塞方式組織服務的實現。

在使用非阻塞方式組織服務之前,您最好詳細地閱讀《Enterprise Integration Pattern》。Spring旗下的專案Spring Integration則是Enterprise Integration Pattern在Spring體系中的一種實現。因為它是在是一個非常大的話題,因此我會在其它博文中對它們進行簡單地介紹。

在通過使用非阻塞模式提高了併發連線數之後,我們就需要考慮是否其它硬體會成為單個服務例項的瓶頸了。首先,更大的併發會導致更大的記憶體佔用。因此如果您所開發的應用對記憶體大小較為敏感,那麼您首先要做的就是為系統新增記憶體。而且在您的記憶體敏感應用的實現中,記憶體管理也會變成您需要考慮的一項任務。雖然說很多語言,如Java等,已經通過提供垃圾回收機制解決了野指標,記憶體洩露等一系列問題,但是在這些垃圾回收機制啟動的時候,您的服務會暫時掛起。因此在服務實現的過程中,您需要考慮通過一些技術來儘量避免記憶體回收。

另外一個和硬體有關的話題可能就是CPU了。一個伺服器常常包含多個CPU,而這些CPU可以包含多個核,因此在該臺服務例項上常常可以同時執行十幾個,甚至幾十個執行緒。但是在實現服務時,我們常常忽略了這種資訊,從而導致某些服務只能由少數幾個執行緒並行執行。通常情況下,這都是因為服務過多地訪問同一個資源,如過多地使用了鎖,同步塊,或者是資料庫效能不夠等一系列原因。

還有一個需要考慮的事情就是服務的動靜分離。如果一個應用需要提供一系列靜態資源,那麼那些常用的Servlet容器可能並不是一個最優的選擇。一些輕量級的Web伺服器,如nginx在服務靜態資源時的效率就將明顯高於Apache等一系列動態內容伺服器。

由於這篇文章的主旨並不是為了講解如何編寫一個具有較高效能的服務,因此對於上面所述的各種增強單個服務效能的技巧將不再進行深入講解。

除了從服務自身下功夫來增強一個服務例項的縱向擴充套件性之外,我們還有一個重要的用來提高服務例項工作效率的武器,那就是服務端快取。這些快取通過將之前得到的計算結果記錄在快取系統中,從而儘可能地避免對該結果再次進行計算。通過這種方式,服務端快取能大大地減輕資料庫的壓力:

那它和服務的擴充套件性有什麼關係呢?答案是,如果服務端快取能夠減輕系統中每個服務的負載,那麼它實際上相當於提高了單個服務例項的工作效率,減少了其它組成對擴容的需求,變相地增加了各個相關組成的擴充套件性。

現在市面上較為主流的服務端快取主要分為兩種:執行於服務例項之上並與服務例項處於同一個程式之內的快取,以及在服務例項之外獨立執行的快取。而後一種則是現在較為流行的解決方案:

從上圖中可以看出,由於程式內快取與特定的應用例項繫結,因此每個應用例項將只能訪問特定的快取。這種繫結一方面會導致單個服務例項所能夠訪問的快取容量變得很小,另一方面也可能導致不同的快取例項中存在著冗餘的資料,降低了快取系統的整體效率。相較而言,由於獨立快取例項是獨立於各個應用伺服器例項執行的,因此應用服務例項可以訪問任意的快取例項。這同時解決了服務例項能夠使用的快取容量過小以及冗餘資料這兩個問題。

如果您希望瞭解更多的有關如何搭建服務端快取的知識,請檢視我的另一篇博文《Memcached簡介》。

除了服務端快取之外,CDN也是一種預防服務過載的技術。當然,它的最主要功能還是提高距離服務較遠的使用者訪問服務的速度。通常情況下,這些CDN服務會根據請求分佈及實際負載等眾多因素在不同的地理區域內搭建。在提供服務時,CDN會從服務端取得服務的靜態資料,並快取在CDN之內。而在一個距離該服務較遠的使用者嘗試使用該服務時,其將會從這些CDN中取得這些靜態資源,以提高載入這些靜態資料的速度。這樣伺服器就不必再處理從世界各地所發來的對靜態資源的請求,進而降低了伺服器的負載。

資料庫的擴充套件性

相較於服務例項,資料庫的擴充套件則是一個更為複雜的話題。我們知道,不同的服務對資料的使用方式常常具有很大的差異。例如不同的服務常常具有非常不同的讀寫比,而另一些服務則更強調擴充套件性。因此如何對資料庫進行擴充套件並沒有一個統一的方法,而常常決定於應用自身對資料的要求。因此在本節中,我們將採取由下向上的方法講解如何對資料庫進行擴充套件。

通常情況下,對一個話題自上而下的講解常常能夠形成較好的知識系統。在使用該方式對問題進行講解的時候,我們將首先提出問題,然後再以該問題為中心講解組成該問題的各個子問題。在講解中我們需要逐一地解決這些子問題,並將這些子問題的解決方案進行關聯和比較。通過這種方式,讀者常常能夠更清晰地認識到各個解決方案的優點和缺點,進而能夠根據問題的實際情況對解決方案進行取捨。這種方法較為適合問題較為簡單並且清晰的情況。

而在問題較為複雜,包含情況較多的情況下,我們就需要將這些問題拆分為子問題,並在講清楚各個子問題之後再去分析整個問題如何通過這些子問題解決方案合作解決。

那麼如何將資料庫的擴充套件性分割為子問題呢?在決定一個資料庫應該擁有哪些特性時,常常用來作為評判標準的就是CAP理論。該理論指出我們很難保證資料庫的一致性(Consistency),可用性(Availability)以及分割槽容錯性(Partition tolerance):

因此一系列資料庫都選擇了其中的兩個特性來作為其實現的重點。例如常見的關係型資料庫主要保證的是資料的一致性及資料的可用性,而並不強調對擴充套件性非常重要的分割槽容錯性。這也便是資料庫的橫向擴充套件成為業界難題的一個原因。

當然,如果您的應用對一致性或可用性的要求並不是那麼高,那麼您就可以選擇將分割槽容錯性作為重點的資料庫。這些型別的資料庫有很多。例如現在非常流行的NoSQL資料庫大多都將分割槽容錯性作為一個實現重點。

因此在本節中,我們將會以關係型資料庫作為重點進行講解。又由於對關係型資料庫進行橫向擴充套件常常較縱向擴充套件更為困難,因此我們將首先講解如何對關係型資料庫進行橫向擴充套件。

首先,最為常見也最為簡單的縱向擴充套件方法就是增加關係型資料庫所在服務例項的效能。我們知道,資料庫在執行時會將其所包含的資料載入在記憶體之中,而且最常訪問的資料是否存在於記憶體之中是資料庫是否執行良好的關鍵。如果資料庫所在的服務例項能夠根據實際負載提供足夠的記憶體,以承載所有最常被訪問的資料,那麼資料庫的效能將得到充分地發揮。因此在執行縱向擴充套件的第一步就是要檢查您的資料庫所在的服務例項是否擁有足夠的資源。

當然,僅僅從硬體入手是不夠的。在前面的章節中已經介紹過,縱向擴充套件需要從兩個方面入手:硬體的增強,以及軟體的優化。就資料庫本身而言,其最重要的保證執行效能的組成就是索引。在當代的各個資料庫中,索引主要分為聚簇索引以及非聚簇索引兩種。這兩種索引能夠加速對具有特定特徵的資料的查詢:

因此在資料庫優化過程中,索引可以說是最為重要的一環。從上圖中可以看出,如果一個查詢能夠通過索引來完成,而不是通過逐個查詢資料庫中所擁有的記錄來進行,那麼整個查詢只需要分析組成索引的幾個節點,而不是遍歷資料庫所擁有的成千上萬條記錄。這將會大大地提高資料庫的執行效能。

但是如果索引沒有存在於記憶體中,那麼資料庫就需要從硬碟中將它們讀取到記憶體中再進行操作。這明顯是一個非常慢的操作。因此為了您的索引能夠正常工作,您首先要保證資料庫執行所在的服務例項擁有足夠的記憶體。

除了保證擁有足夠的記憶體之外,我們還需要保證資料庫的索引自身沒有過多的浪費記憶體。一個最常見的索引浪費記憶體的情況就是Index Fragmentation。也就是說,在經過一系列新增,更新和刪除之後,資料庫中的資料在儲存中的物理結構中將變得不再規律。這主要分為兩種:Internal Fragmentation,即物理結構中可能存在著大量空白;External Fragmentation,即這些資料在物理結構中並不是有序排列的。Internal Fragmentation意味著索引所包含節點的增加。這一方面導致我們需要更大的空間來儲存索引,從而佔用更多的記憶體,另一方面也會讓資料尋找所需要遍歷的節點數量增加,從而導致系統效能的下降。而External Fragmentation則意味著從磁碟順序讀取這些資料時需要硬碟重新進行定址等操作,也會顯著降低系統的執行效能。還有一個需要考慮的有關External Fragmentation的問題則是是否我們的服務與其它服務使用了共享磁碟。如果是,那麼其它服務對於磁碟的使用會導致External Fragmentation的問題無法從根本上解決,巡道操作將常常發生。

另外一個常用的對索引進行優化的方法就是在非聚簇索引中通過INCLUDE子句包含特定列,以加快某些請求語句的執行速度。我們知道,聚簇索引和非聚簇索引的差別主要就存在於是否包含資料。如果從聚簇索引中執行資料的查詢,那麼在找到對應的節點之後,我們就已經可以從該節點中得到需要查詢的資料。而如果我們的查詢是在非聚簇索引中進行的,那麼我們得到的則是目標資料所在的位置。為了找到真正的資料,我們還需要進行一次定址操作。而在通過INCLUDE子句包含了所需要資料的情況下,我們就可以避免這次定址,進而提高了查詢的效能。

但是需要注意的是,索引是資料庫在其本身所擁有的資料之外額外建立的資料結構,因此其實際上也需要佔用記憶體。在插入及刪除資料的時候,資料庫同樣需要維護這些索引,以保證索引和實際資料的一致性,因此其會導致資料庫插入及刪除操作效能的下降。

還有一個需要考慮的則是通過正確地設定Fill Factor來儘量避免Page Split。在常見的資料庫中,資料是記錄在具有固定大小的頁中。當我們需要插入一條資料的時候,目標頁中的可用空間可能已經不足以再新增一條新的資料。此時資料庫會新增一個新的頁,並將資料從一個頁分到這兩個頁中。在該過程中,資料庫不僅僅要新增及修改資料頁本身,更需要對IAM等頁進行更改,因此是一個較為消耗資源的操作。FillFactor是一個用來控制在葉頁建立時每頁所填充的百分比的全域性設定。在設定了FillFactor的基礎之上,使用者還可以設定PAD_INDEX選項,來控制非葉頁也使用FillFactor來控制資料的填充。一個較高的FillFactor會使資料更加集中,由此擁有更高的讀取效能。而一個較低的FillFactor則對寫入較為友好,因為其防止了Page Split。

除了上面所述的各種方法之外,您還可以通過其它一系列資料庫功能來提高效能。這其中最重要的當然是各個資料庫所提供的執行計劃(Execution Plan)。通過執行計劃,您可以看到您正在執行的請求是如何被資料庫執行的:

由於如何提高單個資料庫的效能是一個龐大的話題,而我們的文章主要集中在如何提高擴充套件性,因此我們在這裡不再對如何提高資料庫的執行效能進行詳細的介紹。

反過來,由於單個伺服器的效能畢竟有限,因此我們並不能無限地對關係型資料庫進行縱向擴充套件。因此在必要條件下,我們需要考慮對關係型資料庫進行橫向擴充套件。而將AKF橫向擴充套件模型施行在關係型資料庫之上後,其各個軸的意義則如下所示:

現在就跟我來看看各個軸的含義。在AKF模型中,X軸表示的是應用可以通過部署更多的服務例項來解決擴充套件性的問題。而由於關係型資料庫要管理資料的讀寫並保證資料的一致性,因此在X軸上的擴充套件將不能簡單地通過部署額外的資料庫例項來解決問題。在進行X軸擴充套件的時候,這些資料庫例項常常擁有不同的職責並組成特定的拓撲結構。這就是資料庫的Replication。

而相較於X軸,資料庫AKF模型中的Y軸和Z軸則較為容易理解。AKF模型中的Y軸表示的是將所有的工作根據資料的型別或業務邏輯進行劃分,而Z軸則表示根據使用者的某些特性對使用者的請求進行劃分。這兩種劃分實際上都是要將資料庫中的資料劃分到多個資料庫例項中,因此它們對應的則是資料庫的Partition。

讓我們先看看資料庫的Replication。簡單地說,資料庫的Replication表示的就是將資料儲存在多個資料庫例項中。讀請求可以在任意的資料庫例項上執行,而一旦某個資料庫例項上發生了資料的更新,那麼這些更新將會自動複製到其它資料庫例項上。在資料複製的過程中,資料來源被稱為Master,而目標例項則被稱為Slave。這兩個角色並不是互斥的:在一些較為複雜的拓撲結構中,一個資料庫例項可能既是Master,又是Slave。

在關係型資料庫的Replication中,最為常見的拓撲模型就是簡單的Master-Slave模型。在該模型中,對資料的讀取可以在任意的資料庫例項上完成。而在需要對資料進行更新的時候,資料將只能寫入特定的資料庫例項。此時這些資料的更改將沿著單一的方向從Master向Slave進行傳遞:

在該模型中,資料讀取的工作是由Master和Slave共同處理的。因此在上圖中,每個資料庫的讀負載將是原來的一半左右。但是在寫入時,Master和Slave都需要執行一次寫操作,因此各個資料庫例項的寫負載並沒有降低。如果讀負載逐漸增大,我們還可以加入更多的Slave節點以分擔讀負載:

相信您現在已經清楚了,關係型資料庫的橫向擴充套件主要是通過加入一系列資料庫例項來分擔讀負載來完成的。但是有一點需要注意的是,這種寫入傳遞關係是靠Master和Slave中的一個獨立的執行緒來完成的。也就是說,一個Master擁有多少個Slave,它的內部就需要維持多少個執行緒來完成對屬於它的Slave的更新。由於在一個大型應用中常常可能包含上百個Slave例項,因此將這些Slave都歸於同一個Master將導致Master的效能急劇下降。

其中一個解決方法就是將其中的某些Slave轉化為其它Slave的Master,並將它們組織成為一個樹狀結構:

但是Master-Slave模型擁有一個缺點,那就是有單點失效的危險。一旦作為Master的資料庫例項失效了,那麼整個資料庫系統,至少是以該Master節點為根的子系統將會失效。

而解決該問題的一種方法就是使用多Master的Replication模型。在該模型中,每個Master資料庫例項除了可以將資料同步給各個Slave之外,還可以將資料同步給其它的Master:

在這種情況下,我們避免了單點失效的問題。但是如果兩個資料庫例項對同一份資料更新,那麼它們將產生資料衝突。當然,我們可以通過將對資料的劃分為毫不相干的多個子集並由每個Master節點負責某個特定子集的更新的方式來防止資料衝突。

從上圖中可以看到,使用者對資料的寫入會根據特定條件來分配到不同的資料庫例項上。接下來,這些寫入會同步到其它例項上,從而保持資料的一致性。但是既然我們能將這些資料獨立地切割為各個子集,那麼我們為什麼不去嘗試一下資料庫的Partition呢?

簡單地說,資料庫的Partition就是將資料庫中需要記錄的資料劃分為一系列子集,並由不同的資料庫例項來記錄這些資料子集所包含的資料。通過這種方法,對資料的讀取以及寫入負載都會根據資料所在的資料庫例項來進行劃分。而這也就是資料庫沿AKF擴充套件模型的Y軸進行橫向擴充套件的方法。

在執行資料庫的Partition時,資料庫原有的資料將被切分到不同的資料庫例項中。每個資料庫例項將只包含原資料庫中幾個表的資料,從而將對整個資料庫的訪問切分到不同的資料庫例項中:

但是在某些情況下,對資料庫中的資料按表切分並不能解決問題。切分完畢後的某個資料庫例項仍然可能承擔了過多的負載。那麼此時我們就需要將該資料庫再次切分。只是這次我們所切分的是資料庫中的資料行:

在這種情況下,我們在對資料進行操作之前首先需要執行一次計算來決定資料所在的資料庫例項。

然而資料庫的Partition並不是沒有缺點。最常見的問題就是我們不能通過同一條SQL語句操作不同資料庫例項中記錄的資料。因此在決定對資料庫進行切分之前,您首先需要仔細地檢查各個表之間的關係,並確認被分割到不同資料庫中的各個表沒有過多的關聯操作。

好了。至此為止,我們已經講解了如何建立具有可擴充套件性的服務例項,快取以及資料庫。相信您已經對如何建立一個具有高擴充套件性的應用有了一個較為清晰的認識。當然,在撰寫本文的過程中,我也發現了一系列可以繼續講解的話題,如Spring Integration,以及對資料庫Replication以及Partition(Sharding)的講解。在有些方面(如資料庫),我並不是專家。但是我會盡我所能把本文所寫的知識點一一陳述清楚。

相關文章