從小白到架構師(1): 應對高併發

「從小白到架構師」系列努力以淺顯易懂、圖文並茂的方式向各位讀者朋友介紹 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 這樣的錯誤情況。有時間我會專門寫一篇文章來介紹分佈式數據庫的相關知識。

下集:應對業務的複雜度

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章