在編寫一個應用時,咱們經常考慮的是該應用應該如何實現特定的業務邏輯。可是在逐漸發展出愈來愈多的用戶後,這些應用經常會暴露出一系列問題,如不容易增大容量,容錯性差等等。這經常會致使這些應用在市場的拓展過程當中沒法快速地響應用戶的需求,並最終失去商業上的先機。html
一般狀況下,咱們將應用所具備的用來避免這一系列問題的特徵稱爲非功能性需求。相信您已經可以從字面意義上理解這個名詞了:功能性需求用來提供對業務邏輯的支持,而非功能性需求則是一系列和業務邏輯無關,卻可能影響到產品後續發展的一系列需求。這些需求經常包括:高可用性(High Avalibility),擴展性(Scalability),維護性(Maintainability),可測試性(Testability)等等。nginx
而在這些非功能性需求中,擴展性多是最有趣的一種了。所以在本文中,咱們將對如何編寫一個具備高可擴展性的應用進行講解。算法
什麼是擴展性數據庫
假設咱們編寫了一個Web應用,並將其置於共有云上以向用戶提供服務。該應用的創意很是新穎,並在短期內就吸引了大量的用戶。可是因爲咱們在編寫該應用時並無指望它來處理這麼多用戶的請求,所以它的運行速度愈來愈慢,甚至可能出現服務沒有響應的狀況。頻繁發生這種事情的結果就是,用戶將沒法忍受該應用常常性地宕機,並將尋找其它相似應用來得到相似的服務。apache
該應用所缺乏的可以根據負載來對處理能力進行適當擴展的能力即是應用的擴展性,而其衡量的標準則是處理能力擴展的簡單程度。若是您的應用在添加了更多內存後就能運行得更好,或者經過添加一個額外的服務實例就能解決服務實例過載的問題,那麼咱們就能夠說該應用的擴展性很是好。若是爲了處理更多的負載而不得不重寫整個應用,那麼應用的開發者就須要在多多注意應用的擴展性了。緩存
較好的擴展性不只能夠省卻您重寫應用的麻煩,更重要的是,它會幫助您在市場的爭奪中得到先機。試想一下,若是您的應用已經出現了處理能力不夠的苗頭,卻沒有適當的解決方案來提升整個系統的處理能力,那麼您能作的事情只能是從新編寫一個具備更高處理能力的具備同一個功能的應用。在該段時間內,您的應用的處理能力顯得愈來愈捉襟見肘。而體如今客戶層面上的,則是您的應用的響應速度愈來愈慢,甚至有時都沒法正常工做。在新應用上線以前,您的應用將逐漸地流失客戶。而這些流失的客戶則頗有可能變成相似軟件的忠實客戶,從而使得您的產品失去了市場競爭的先機。反過來,若是您的應用具備很是良好的擴展性,而您的競爭對手並無跟上用戶的增加速度,那麼的應用就有了徹底超越甚至壓制競爭對手的可能。安全
固然,一個成功的應用不該該僅僅擁有高擴展性,而是應該在一系列非功能性需求上都作得很好。例如您的應用不該該有太多的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容器中正確地使用了非阻塞模式:
<Connector connectionTimeout="20000" maxThreads="1000" port="8080"
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)的講解。在有些方面(如數據庫),我並非專家。可是我會盡我所能把本文所寫的知識點一一陳述清楚。