系統設計101—大型系統的演進(上)

System Design 101, Part 1

YH Yu
後端新手村
13 min readJul 5, 2019

--

本文屬於系列:系統設計學習地圖

為什麼要學習系統設計?主要是希望當使用者數量(系統流量)不斷增加時,我們依然能穩定地提供高效率的服務。衡量一個系統在這方面的能力有很多方式,本文選擇了兩個最主要的觀點做切入,分別是:

  1. 擴展性(Scalability):每當投入更多的資源,例如多增加一台伺服器,系統的效能(performance)也能成比例地增加。
  2. 可用性(Availability):系統任何時候都要能回應使用者的請求,簡單來說,就是希望系統掛掉的時間越少越好。

想要滿足這兩點,直覺上不就是加入更多伺服器幫忙做運算和備援?這樣說其實也沒有錯,但實務上會遇到非常多的問題要解決!本文會從一個非常簡單的架構開始,想像當使用者持續增加時,如何逐步地擴展系統的架構。重點在於不斷思考每個階段的系統設計,可能遇到的瓶頸、解決的方式和優缺點

假設我們要做一個購物網站好了,最基本的架構可能像這樣:

域名系統(Domain Name System, DNS)
將好記憶的網域名稱(domain name),轉換成實際的IP地址。往往是所有請求的第一站。

  1. 當使用者在瀏覽器中點擊網址“myShop.com”。瀏覽器首先會發送一個請求(request)到域名系統,詢問“myShop.com”的IP地址。域名系統查詢後回覆IP地址為“123.45.67.1”,這就是我們網站伺服器(Web Server)在網路上的實際位置。
  2. 瀏覽器再透過這個IP地址,將請求發到網站伺服器。伺服器會根據請求的內容(e.g. 瀏覽商品, 下單),開始做相對應的處理。
  3. 通常網站伺服器需要再連線到後面的資料庫(database)存取相關資料。最後將處理結果,轉換成網頁後回傳給使用者。

隨著使用者增加,我們的瓶頸出現在網站伺服器,它的運算能力已經跟不上使用者的流量了。使用者發現網站越來越慢,甚至在促銷的時段還會直接塞爆無法回應!怎麼辦呢?

垂直擴展(Vertical Scaling)

垂直擴展是一種非常簡單、直覺的擴展方式,白話一點就是硬體向上升級。既然伺服器不夠快,那直接幫它升級CPU、加記憶體不就得了!

這是系統在初期需要擴展時蠻常見的做法,效果其實也很不錯。但等到硬體規格到達一定程度後,就必須付出大量的金錢才能再提升一點點性能。此外,硬體規格是有極限的,就算再有錢也沒辦法無止盡地升級。我們必須再想想其它的方式!

前後端與服務分離(Isolation of Services)

讓網站伺服器一個人包山包海,負擔實在太沈重。要解決這個問題,我們可以將主要的運算(業務邏輯),抽離到其它伺服器,稱為應用程式伺服器。讓本來的網站伺服器可以專心在顯示畫面(使用者介面)的相關工作。

這就是現在很主流的『前後端分離』。對於前端(網站伺服器)來說,它不需要知道後端(應用程式伺服器)的內部邏輯,而是單純使用後端提供的“服務”,再根據服務的執行結果,產生流暢、友善的使用者介面。

以會員註冊這個動作為例,流程如下:

  1. (前端)產生會員註冊頁面,使用者在上面填好會員資料、按下註冊按鈕,前端便將這些資料送到後端,由後端的會員服務來處理。
  2. (後端)會員服務開始執行各種運算,像是去資料庫檢查這個使用者有沒有註冊過、雜湊(保密)使用者的密碼,以及將資料寫入資料庫等等。當全部動作結束後,通知前端動作完成。
  3. (前端)知道後端已經幫使用者完成註冊後,前端便生成一個漂亮的畫面歡迎使用者的加入。

前後端分離的好處是:

  1. 分散運算負擔
  2. 前後端選用的技術、開發團隊易於拆分
  3. 後端的各項服務可以讓不同的前端來使用(e.g. 網站、Android、IOS)

前後端分離後,後端的服務本身也可以視需求再切分,這就是服務分離。以我們的例子而言,後端伺服器可以將會員和商品相關的業務邏輯再切分,讓它們各自獨立,甚至擁有自己的專屬資料庫!

現在的系統架構演變成:

現在我們的系統已經能負擔比先前大上非常多的使用者流量,每個部分還可以再視需求做垂直擴展(升級硬體)!

然而,隨著使用者持續增加,系統又開始慢慢變慢了。雖然後來又再切分了幾個服務,但終究不可能無止盡的切分下去。而且隨著服務的數量變多,系統的複雜度也變得更高。若服務切分的不好,監控、追蹤上更是困難。

我們再次遇到了瓶頸。

水平擴展(Horizontal Scaling)

目前為止,我們一直使用垂直擴展的方式去加強系統的運算能力。但一個人再怎麼強大,力量也有限。既然如此,何不讓更多人一起幫忙?也就是為負擔較重的部分,加上更多的“雙胞胎”伺服器。這種方式稱為水平擴展,也是在系統的擴展性上,一個非常強大的武器!

問題是,若我們加了一台一模一樣的伺服器進來,它也有它的IP地址,這時候使用者的請求要分配到哪台伺服器呢?

一種做法是直接在DNS中設定,讓同一個網域名稱可以輪流(Round-robin)對應到不同的IP地址。但還有更好的方式——使用專門用於分配流量的元件,稱為負載平衡器。

負載平衡器Load Balancer)
顧名思義,這台機器可以將負載(load),也就是流量,根據選定的策略,分配到背後的一群伺服器上。

當瀏覽器向DNS詢問網站伺服器的IP地址時,得到的其實是負載平衡器的IP地址。就好像是這群伺服器的代表一樣,它負責在接到請求後,分派給後方的某一台伺服器去做處理。

加入了負載平衡器與水平擴展的伺服器後,系統架構如下:

Note: 應用程式伺服器“們”,前面也有負載平衡器作為代表,前端會將請求發送給它

水平擴展有下列好處:

  1. 理論上可以“無限”擴展運算能力
    只要不斷加入更多的伺服器即可。甚至是讓系統實現自動擴展(Auto Scaling),在流量變大時自動增加新的伺服器,流量變小時關閉。
  2. 滾動更新(rolling update)
    當伺服器有新版本要發布的時候,可以在背景建置好新版本的伺服器。完成後負載平衡器再將流量切換過去,然後關閉舊版本的伺服器。整個過程不需要任何停機時間。
  3. 易於管理故障的伺服器
    負載平衡器會定期檢查背後伺服器的狀態。若發現有伺服器失聯了,就可以先將它從流量的分派清單移除掉,然後再加入新的伺服器。

最重要的是,以上這些事情使用者完全都不會發現。對他們來說這就是一個全天候提供快速、穩定服務的系統!那麼,故事到這裡就結束了嗎?

事實上,水平擴展“並不容易”。最大的原因是,我們的伺服器是有狀態(stateful)的。以網站伺服器為例,使用者連線後,我們通常會建立一組會話(session)來記錄這名使用者的相關狀態(e.g. 這名使用者已經登入過、購物車內有哪些商品)。

會話傳統上會保存在使用者當下連線的伺服器,這在實施水平擴展前沒有問題。但現在我們有“許多”的伺服器,若使用者每次連線都被分派到不同的地方,他的狀態就會四散。很可能會一直被要求重新登入,購物車的內容也不一致!

可以說,如何處理複數伺服器的狀態是擴展性的一大挑戰

因此,我們接下來要思考『有狀態的伺服器』如何處理水平擴展?以使用者的session為例,有以下兩種直覺的做法:

  1. 即時同步所有伺服器的狀態:
    在系統規模小的時候可能沒問題,但想像一下,若擴展到百台甚至更多的伺服器,同步的成本會非常可觀。
  2. 讓相同的使用者永遠連線到固定伺服器:
    這種做法稱為黏性會話(sticky session),明顯的缺點有兩個。首先是每個使用者的行為不同,若重度用戶都剛好連線到少數幾台伺服器,負擔就會開始不平均。再來則是若某台伺服器掛掉了,原本分配到這台伺服器的使用者還是必須重新分配。

看來這兩個方法都有明顯的缺點。說到底,只要伺服器有狀態,我們就沒辦法輕易地實現水平擴展。那麼,若我們將狀態從伺服器中抽離,讓它變成無狀態(stateless)呢?

  1. 狀態交由使用者(瀏覽器)自行保存:
    將狀態“全部”放在HTTP Cookie回傳給使用者。每次連線的時候,使用者將保存的狀態帶給伺服器。若有狀態需要修改,由伺服器處理完後再交還給使用者保管。這種做法需要考慮cookie的容量不大,可能放不下太複雜的狀態。此外,若記錄的狀態越來越多,傳輸的流量也會不斷增長!
  2. 狀態放到資料庫,使用者只保存一組“ID”:
    這是目前很常見的方式,狀態被抽離到獨立的資料庫,使用者只需要在cookie保存一組“ID”。每次連線時將ID帶給伺服器,伺服器就會根據ID去資料庫取回使用者狀態。

這邊我們採用第二種做法。以保存session來說,在資料庫的選擇上,像登入狀態這種相對短暫,甚至可容許遺失的資料(volatile data),我們可以放在快取(cache)資料庫,例如 Redis,特點是結構簡單、速度快。

而像購物車內的商品這種較持久的資料(persistent data),則可以考慮保存在NoSQL的資料庫,例如MongoDB,特點是資料結構彈性、易擴展。

將session狀態從網站伺服器抽離後,我們的系統架構如下:

Note: 原先Application Servers的部分,簡化為一台伺服器作為示意

現在這些“無狀態”的網站伺服器(前端)可以輕易地水平擴展。至於應用程式伺服器(後端)的部分,通常業務邏輯的狀態本來就是放在資料庫,所以它們的水平擴展也沒有問題!

那麼,故事到這裡終於結束了嗎?可惜…還是沒有!我們好像只是把狀態的擴展問題,從伺服器轉移到資料庫而已。隨著流量越來越大,擴展的伺服器越來越多,大家終究還是向資料庫存取資料。

那麼,資料庫本身的擴展呢

資料庫的擴展性

必須先強調,資料庫的規劃、維護本身是一門非常專業的領域。較具規模的組織,甚至有資料庫管理員(DBA, Database Administrator)負責相關任務。但萬丈高樓平地起,我們還是可以從大方向去討論資料庫的擴展性,有哪些常用的觀念與技巧!

  1. 資料庫複寫(Replication)與讀寫分離

類似水平擴展伺服器的做法,我們為資料庫加入更多的雙胞胎,並將“當下”的資料複製過去。問題在於“後續”要如何修改資料?當然不可能一次連線到所有的資料庫來做操作!

主從模式(Master-slave)是這個問題的一種常見做法,在一群資料庫中選擇“一個”當作master,剩下的作為slave。資料的變動一律透過master完成,它會再將結果同步到各個slave(不一定即時,看我們的需求來設定)。

Master負責寫入,slave負責讀取,這就是讀寫分離。

Source: Scalability, availability, stability, patterns

這種設計特別適合讀取的頻率大於寫入的系統。例如我們的購物網站,大多時候是在提供商品資料給使用者瀏覽。所以只要加入更多專門用來讀取資料的slave,便可以大幅度地減輕原本資料庫的負擔!

2. 單點失效(Single point of failure)與故障轉移(Failover)

目前為止我們一直偏重在擴展性(scalability)的討論上。是時候來分析一下另一個面向,也就是系統的可用性(availability)了!所謂的“可用”,以使用者的角度來說,就是系統的表現是“正常”的,可以順利地回應使用者的請求。

而我們開發者的任務,就是要找出什麼情況下,系統的表現是“異常”的。最直接的判斷方式就是把當前的架構圖攤開來看,想像一個請求進來後,沿途會經過哪些元件?若其中有環節是只要它壞掉,整個系統就無法正常運作,那麼這個部分就稱為“單點失效”,也就是整個系統架構中的弱點!

其實先前在討論水平擴展時,就已經有提到這樣的情況。本來我們的伺服器是單點失效的(只有一台,掛了就沒了)。在引入水平擴展、透過負載平衡器自動管理故障的伺服器後,已經大幅提升了這部分的可用性。

資料庫也是類似的做法,只是若故障的是master,就必須從現有的slave中,選出一個晉升(promote)為新的master。整個自我修復的過程是自動的(不需人力介入),稱為故障轉移(Failover)。一個系統越能從各種錯誤狀態中自我修復,就擁有越高的可用性。

3. 資料庫快取

資料庫的速度,可以說是影響整個系統效能最大的因素。儘管我們已經透過複寫和讀寫分離,一定程度上擴展了資料庫的效能。但和伺服器的水平擴展不同,資料的同步是需要成本的。無論是主從或其它設計模式都有其限制和取捨。無論何時,都要把存取資料庫當作一個高成本的行為!

那麼,如何減少對資料庫的存取呢?關鍵就是快取(Cache),將曾經查詢過的結果保存起來(通常會再加上一個有效期限,過期後快取結果就消失,資料對即時性越要求,有效期限就越短)。每當需要查詢資料的時候,先找找看有沒有先前的查詢結果。若有找到就直接回傳。找不到才需要連線到資料庫。

加上快取機制後,資料庫的負擔可以大幅降低,應用程式伺服器處理請求的速度更快,進而讓整個系統的效能顯著的提升。

在導入了資料庫複寫、讀寫分離與快取後,現在我們的系統架構如下:

Note: 簡化了Web Servers的部分,並讓Application Servers連到同一個Cache Server示意資料庫快取

總結

本文圍繞著系統設計中的兩個主要觀點,擴展性與可用性。從一個簡單的架構開始,逐步去分析當流量不斷增長時,系統要如何擴展,每個階段可能遇到的瓶頸以及解決方式。

一開始我們簡單使用垂直擴展來升級硬體效能,直到無法再透過硬體升級來應付流量。接著將前後端與服務分離,把系統切成前端的網頁伺服器與後端各種服務的應用程式伺服器。

由於後端的服務不可能無止盡的切分,我們需要實施水平擴展,加入更多的伺服器並將流量交由負載平衡器管理。水平擴展的困難點在於伺服器本身擁有狀態,所以我們必須將狀態抽離至資料庫

資料庫的擴展,也可以說是整個系統的擴展性中最重要的部分。我們採用了資料庫複寫和讀寫分離的架構並以主從模式為例子,說明什麼是單點失效與故障轉移。最後則是為資料庫加上快取,降低存取的頻率來提升效能。

在下篇中,我們將會繼續探討:靜態資源的擴展性、非同步任務的處理、全文檢索的效能,以及非常重要的 — — 安全性!

下篇:系統設計101—大型系統的演進(下)

如果這篇文章對你有所幫助的話,歡迎拍手讓我知道,最多可以拍50下喔👏👏

--

--

YH Yu
後端新手村

Ever tried. Ever failed. No matter. Try Again. Fail again. Fail better.