從小白到架構師(1): 應對高並行

2022-10-10 12:01:16

「從小白到架構師」系列努力以淺顯易懂、圖文並茂的方式向各位讀者朋友介紹 WEB 伺服器端從單體架構到今天的大型分散式系統、微服務架構的演進歷程。本文是「從小白到架構師」系列的第一篇,主要講述提升網站吞吐量、應對更高並行量的主要技術手段。

從個人部落格開始

相信很多朋友都搭建過個人部落格之類的後端系統,這類系統的架構非常簡單:

首先購買一臺雲伺服器,並在上面安裝 MySQL 資料庫,然後部署一個 node.js 之類的 HTTP 伺服器監聽 80 和 443 埠,在 node.js 中連線資料庫並實現業務邏輯。最後購買一個域名並設定 DNS 記錄指向我們的伺服器 IP 地址,這個網站就算搭建完成了。

隨著不斷的努力,我們網站的存取量越來越多。某天早晨當你美滋滋開啟網站想要看一眼最新評論時,卻發現網站打不開了。。。

登入伺服器檢視紀錄檔後發現因為存取人數過多,MySQL 已經無法及時響應所有的查詢請求,看來有必要進行一波優化了。

快取

在部落格、新聞、微博、(短)視訊、電商等大多數業務場景下讀取請求的次數要遠遠大於寫入請求的次數,且讀取集中在少數熱門資料上而長尾資料很少被存取。在這樣的場景中我們可以通過加快取的方式來提高網站處理讀取請求的並行量。

Redis 是一種比較常用的快取系統,它是 Key-Value 結構的記憶體快取。Redis 作為獨立程序執行並通過 TCP 協定提供服務,這意味著不同伺服器上的業務程序(如 node.js) 可以連線到同一個 Redis 範例並共用其中的資料。

由於資料在記憶體中 Redis 的存取速度要遠遠大於基於磁碟儲存的資料庫(單個 Redis 範例可以達到 每秒10萬次讀寫,而 MySQL 只能達到每秒百次寫入或千次查詢)。但是記憶體的價格比 SSD 昂貴很多,可用的記憶體空間非常有限,這要求我們妥善設計快取方案以及淘汰策略,在快取命中率和記憶體消耗之間取得合理的平衡。

使用快取是一種有效的提高系統吞吐量的方案,但要注意處理快取一致性、快取穿透、快取雪崩等問題。

Redis 快取更新一致性

Redis 官方提供了 Redis Cluster 作為叢集解決方案,社群中也有 Codis 等優秀的代理式叢集解決方案,AWS、阿里雲、騰訊雲等雲服務商都提供了商業化的 Redis 叢集。在單機版 Redis 吞吐量不夠用時,我們可以方便的遷移到 Redis 叢集上。

負載均衡

快取抗住了大部分的存取請求,隨著使用者數的增長,現在並行壓力主要落在單機的業務伺服器上。

一種升級思路是提高單臺伺服器的設定比如4核8GB記憶體升級到8核16GB記憶體,這種思路稱為縱向擴容;另一種思路是提高伺服器的數量,使用多臺伺服器同時處理請求,這種思路稱為橫向擴容。相對於不斷增加的存取量,單機效能的提升空間卻極其有限,所以在實際工作中更多的採用橫向擴容的思路。

我們使用反向代理軟體 Nginx 代替業務伺服器監聽埠,在多臺雲伺服器上部署業務伺服器,並將這些業務伺服器設定為 Nginx 的後端伺服器組。來自使用者瀏覽器的 HTTP 請求首先到達 Nginx, Nginx 根據我們設定的規則將請求轉發給負載較低的一臺業務伺服器,在收到業務伺服器響應之後將其返回給使用者。

業務伺服器的數量可以根據當前的存取量隨時增加或減少,在高峰期增加伺服器保證質量,低谷期減少伺服器節約成本。

我們都知道在電腦 A 上「複製」一個檔案是不能在電腦 B 上進行「貼上」的,同理一個使用者的第一次請求被路由到業務伺服器 A 第二次請求被路由到業務伺服器 B 也會產生類似的問題。抽象一點說,第一次請求改變了業務伺服器 A 的狀態,而第二次請求的正確響應依賴於業務伺服器的狀態,在「複製-貼上」這個例子中「貼上板」的狀態決定了是否能夠正確處理「貼上」請求。

聰明的你可能會說:那麼同一個使用者的請求始終路由到同一臺業務伺服器就可以了?我們複習一下上面這句話:「業務伺服器的數量可以根據當前的存取量隨時增加或減少」,也就是說儲存了使用者狀態的業務伺服器可能會被我們回收掉,在高峰期某個使用者可能會被分流到新的伺服器。

這是一個非常難以解決的問題,所以業界通常的思路是解決問題本身,即:業務伺服器無狀態化。業務伺服器應該像純函數一樣,輸出完全由輸入決定,自身不儲存任何資料,也不維護任何狀態。無狀態的伺服器可以隨時啟動和停止,伺服器的數量也可以隨時增加或減少。某臺伺服器故障後,它未完成的請求也可以轉移到其它伺服器上重試。當然業務伺服器無狀態不代表業務邏輯無狀態,所有的狀態都應儲存在資料庫

單臺 Nginx 的效能雖然很高但仍是有極限的,同樣的思路我們可以將負載分佈在多臺 Nginx 上。Linux Virtual Server 是工作在 TCP 層(OSI 四層)的負載均衡器,是業界常用的 Nginx 負載均衡方案。

由於 LVS 是單機版的軟體,若 LVS 所在伺服器宕機則會導致整個後端系統都無法存取,因此需要有備用節點。可使用 keepalived 軟體模擬出虛擬 IP,然後把虛擬 IP 繫結到多臺 LVS 伺服器上,瀏覽器存取虛擬 IP 時,會被路由器重定向到真實的 LVS 伺服器,當主 LVS 伺服器宕機時,keepalived 軟體會自動更新路由器中的路由表,把虛擬 IP 重定向到另外一臺正常的 LVS 伺服器,從而達到 LVS 伺服器高可用的效果

如果 LVS 也扛不住了呢?不用著急,在 DNS 伺服器中可設定一個域名對應多個 IP 地址。DNS 伺服器可以按照負載均衡策略將域名解析到其中一臺 LVS 的 ip 地址,從此係統可自由的進行橫向擴容:

在上面這張架構圖中除了資料庫外的元件都不是單機執行的,單臺機器故障不會導致整個系統宕機,任何一個元件容量不足時都可以通過加機器迅速擴容。這是分散式系統中另一個重要的原則:消除伺服器內單點

資料庫篇

經過快取和橫向擴容,我們的網站已經可以應對高並行的讀請求以及業務邏輯計算的開銷。但是我們寫入的吞吐量仍然受限於單機資料庫,那麼有沒有辦法解決資料庫的單點問題呢?

讀寫分離

包括 MySQL 在內的絕大多數主流資料庫均支援主從複製,從庫會監聽主庫的更新並將更新同步到本地,從而始終保持與主庫的資料集一致。

從庫除了作為備份之外也可以像快取一樣分擔主庫的讀取壓力,即資料更新寫入主庫而查詢操作則在從庫上進行,我們將這種技術稱為讀寫分離。

一些複雜的查詢會消耗資料庫大量的 IO 和 CPU 資源,舉例來說:我們將關注關係儲存在 MySQL 中,而計算使用者粉絲數的 select count 查詢非常耗時,我們可以將這樣的查詢移到從庫上進行,主庫的資源則可以用來處理更多寫請求。

分庫分表

在讀寫分離一節中我們設定了多個用於處理讀取請求的從庫,但是處理寫入請求的主庫始終只有一個,主庫仍然是制約整個網站的吞吐量的瓶頸。那我們能否像讀庫一樣設定多個主庫,以此來提升網站寫入的吞吐量呢?

答案是肯定的,使用多個主庫的核心問題在於如何決定某一條資料應該寫入哪一個節點中。比如使用者 A 發表了一篇文章我們將它存入了資料庫 1,後續查詢時我們卻到資料庫 2 中進行搜尋,自然一無所獲;又或者使用者 A 的第一篇文章存入了資料庫 1,第二篇文章卻存入了資料庫 2,在我們按時間查詢使用者 A 的文章時就不得不搜尋每一個資料庫然後費時費力的將結果重新排序。

決定資料寫入哪個節點的策略我們通常稱為分表的路由策略,選擇路由策略的原則是儘可能的將需要一起使用的資料放到同一個資料庫中,避免跨庫帶來的額外複雜度。比如在部落格系統的場景中,我們通常會將同一個使用者的文章放入同一個資料庫。

接下來的事情就是如何將使用者 ID 對映到某個表上了。最簡單的方法是 hash(user_id) % db_num, 但實際場景中節點的數量會發生變化(即擴縮容),此時幾乎所有資料的 db_id 都會發生改變,在擴縮容過程中需要遷移大量的資料。因此,在實際使用更多的是一致性雜湊演演算法,它的目標是在資料庫節點數量變化時儘可能的減少需要遷移的資料量。

無論如何選擇分表路由策略我們都無法完全避免進行跨表讀寫,這時有一些額外的工作需要處理,比如將多個資料庫返回的結果重新進行排序和分頁,或者需要保證跨庫寫入的 ACID (事務)性。此時就要使用諸如 MyCat 這樣的資料庫中介軟體來幫我們處理這些麻煩事了。

和單機資料庫一樣,分庫分表架構下同樣可以為資料庫節點設定從庫,一是可以用作備份,二是用來實現讀寫分離。

NewSQL

MySQL 以資料頁為單位進行儲存,每個資料頁內按主鍵順序儲存著多行資料。在寫入新資料時首先需要讀取主鍵索引找到對應的資料頁,然後將新的資料行插入進去。必要時還需要要將原來一頁中的資料轉移到其它資料頁上才能滿足頁內按主鍵順序排列的要求。 這種由於一次資料庫寫入請求導致的多次磁碟寫的現象被稱為寫放大,隨機讀寫和寫放大是制約 MySQL 寫入效能的主要瓶頸。

本文描述基於 MySQL 預設的 InnoDB 儲存引擎, InnoDB 同時也是 MySQL 中應用最廣的儲存引擎。本文不強調 MySQL 和 InnoDB 的區分。

LSM-Tree 是一種紀錄檔式的儲存結構,對資料的增刪改都是通過在紀錄檔尾部追加一條新記錄實現的。由於不需要尋找資料頁和維護頁結構只需要進行順序寫,紀錄檔式儲存結構的寫入效能大大優於 MySQL 這類面向頁的儲存結構。

LSM-Tree 結構資料庫的經典代表是 RocksDB 和 LevelDB, 很多新一代的資料庫(NewSQL)的底層均使用 RocksDB 或 LevelDB 作為儲存引擎。比如大名鼎鼎的 TiDB 便是以 RocksDB 作為儲存引擎,在其上通過 Multi-Raft 協定構造高一致性、高可用、支援快速擴縮容的分散式資料系統。

圖片源自 tidb 官網: https://docs.pingcap.com/zh/tidb/dev/tidb-storage

直接使用 TiDB 之類的分散式資料庫可能是比自行分庫分表更簡單高效的方案。除了 TiDB 外還有各類 NewSQL 活躍在業界解決著傳統關係型資料庫難以解決的問題,比如用於進行復雜統計查詢的 Hive、用於進行模糊搜尋的 ElasticSearch、用於儲存和分析海量紀錄檔的 ClickHouse 等時序資料庫、用於計算共同好友等場景的 Dgraph 等圖資料庫…… 這些新型資料必將極大的提高開發效率和系統效能。

訊息佇列

訊息佇列在應對高並行上也是一種非常有用的技術,這裡訊息佇列有兩種用途:第一是用作限流,使用者請求先進入訊息佇列排隊,然後慢慢送到業務伺服器進行處理,起到削峰填谷的作用,可以用來應對秒殺等瞬間峰值的場景;第二是非同步處理任務,比如訂單建立成功後立即返回,通知發貨等邏輯通過訊息佇列進行非同步處理,從而減少請求處理時間。

總結

應對高並行

我們從最簡單的單伺服器+單資料庫架構開始,通過快取和讀寫分離技術提高讀取吞吐量,通過橫向擴容提高業務伺服器容量,通過使用分庫分表技術提高資料庫寫入能力。最後兼具高效能、高一致性的新一代的分散式資料庫系統 ———— TiDB。

快取、橫向擴容、通過 MQ 非同步執行是在業務開發中最常用、成本最低的提高吞吐量的方案。新一代的分散式資料庫系統替我們解決了傳統關係型資料庫單點執行、吞吐量有限、難以橫向擴容、應用場景侷限等問題,各大廠商正在越來越多的將 NewSQL 應用於生產環境中, 學習使用新一代資料庫技術必將極大的提高開發效率和系統效能。

走向分散式系統

在本文中我們應對吞吐量不足的核心思路是將單機系統改造為分散式系統,很多同學一提到分散式系統便想到 CAP 理論、Paxos 演演算法、Hadoop 等嚇人的名詞,然後就沒有然後了。

在本文中我們提到了兩種分散式系統,第一種是在「負載均衡」一節中提到的無狀態分散式系統,這類系統結構比較簡單通常由負載均衡+業務伺服器組成,由於無狀態的特性可以隨意擴縮容。第二種便是比較複雜的有狀態分散式系統,具體的講就是各種分散式資料庫(包括記憶體資料庫),幸運的是廠商準備好了開箱即用的方案,倒也不必為此花費過多心力。

本文中提到的「分庫分表 + 主從複製」是大多數分散式資料庫的基本思想,分散式資料庫面臨的主要難點是系統內的拓撲是動態變化的:現在資料庫中有幾個主節點在正常工作?這些主節點的地址是什麼?那些節點發生了主從切換? 分散式資料庫需要讓系統內所有節點對系統的拓撲結構的認知始終保持一致,否則便會出現應該寫入節點 A 實際上寫入了節點 B 這樣的錯誤情況。有時間我會專門寫一篇文章來介紹分散式資料庫的相關知識。

下集:應對業務的複雜度

在本文中我們重點關注負載均衡、資料庫、快取等基礎設施,對於業務邏輯一筆帶過。在實際工作中業務邏輯卻是複雜、多變的,業務程式碼在不斷迭代也更容易出錯,「從小白到架構師」系列第二篇將講述單體架構到微服務的演進歷程,從系統架構角度研究如何控制業務複雜度、包容業務系統故障。