天天看點

高并發架構通用設計方案(典藏版)

作者:散文随風想

前言

既然是億級使用者應用,那麼高并發必然是其架構設計的核心要素。

本文我們将介紹高并發架構設計的一些通用設計方案。

關鍵詞:讀/寫分離、資料緩存、緩存更新、CQRS、資料分片、異步寫

高并發架構設計的要點

高并發意味着系統要應對海量請求。從筆者多年的面試經驗來看,很多面試者在面對“什麼是高并發架構”的問題時,往往會粗略地認為一個系統的設計是否滿足高并發架構,就是看這個系統是否可以應對海量請求。再細問具體的細節時,回答往往顯得模棱兩可,比如每秒多少個請求才是高并發請求、系統的性能表現如何、系統的可用性表現如何,等等。

為了可以清晰地評判一個系統的設計是否滿足高并發架構,在正式給出通用的高并發架構設計方案前,我們先要厘清形成高并發系統的必要條件、高并發系統的衡量名額和高并發場景分類。

形成高并發系統的必要條件

  • 高性能: 性能代表一個系統的并行處理能力,在同樣的硬體裝置條件下,性能越高,越能節約硬體資源;同時性能關乎使用者體驗,如果系統響應時間過長,使用者就會産生抱怨。
  • 高可用性: 系統可以長期穩定、正常地對外提供服務,而不是經常出故障、當機、崩潰。
  • 可擴充性: 系統可以通過水準擴容的方式,從容應對請求量的日漸遞增乃至突發的請求量激增。

我們可以将形成高并發系統的必要條件類比為一個籃球運動員的各項屬性:“高性能”相當于這個球員在賽場上的表現力強,“高可用性”相當于這個球員在賽場上總可以穩定發揮,“可擴充性”相當于這個球員的未來成長性好。

高并發系統的衡量名額

1. 高性能名額

一個很容易想到的可以展現系統性能的名額是,在一段時間内系統的平均響應時間。例如,在一段時間内有10000個請求被成功響應,那麼在這段時間内系統的平均響應時間是這10000個請求響應時間的平均值。

然而,平均值有明顯的硬傷并在很多資料統計場景中為大家所調侃。假設你和傳奇籃球巨星姚明被分到同一組,你的身高是174cm,姚明的身高是226cm,那麼這組的平均身高是2m!這看起來非常不合理。

假設在10000個請求中有9900個請求的響應時間分别是1ms,另外100個請求的響應時間分别是100ms,那麼平均響應時間僅為1.99ms,完全掩蓋了那100個請求的100ms響應時間的問題。平均值的主要缺點是易受極端值的影響,這裡的極端值是指偏大值或偏小值——當出現偏大值時,平均值将會增大;當出現偏小值時,平均值将會減小。

筆者推薦的系統性能的衡量名額是響應時間PCTn統計方式,PCTn表示請求響 應時間按從小到大排序後第n分位的響應時間。假設在一段時間内100個請求的響應時間從小到大排序如圖所示,則第99分位的響應時間是100ms,即PCT99= 100ms。
高并發架構通用設計方案(典藏版)

分位值越大,對響應時間長的請求越敏感。比如統計10000個請求的響應時間:

  • PCT50=1ms,表示在10000個請求中50%的請求響應時間都在1ms以内。
  • PCT99=800ms,表示在10000個請求中99%的請求響應時間都在800ms以内。
  • PCT999=1.2s,表示在10000個請求中99.9%的請求響應時間都在1.2s以内。

從筆者總結的經驗資料來看,請求的平均響應時間=200ms,且PCT99=1s的高并發系統基本能夠滿足高性能要求。如果請求的響應時間在200ms以内,那麼使用者不會感受到延遲;而如果請求的響應時間超過1s,那麼使用者會明顯感受到延遲。

2. 高可用性名額

可用性=系統正常運作時間/系統總運作時間,表示一個系統正常運作的時間占比,也可以将其了解為一個系統對外可用的機率。我們一般使用N個9來描述系統的可用性如何,如表所示。

高并發架構通用設計方案(典藏版)

高可用性要求系統至少保證3個9或4個9的可用性。在實際的系統名額監控中,很多公司會取3個9和4個9的中位數:99.95%(3個9、1個5),作為系統可用性監控的門檻值。當監控到系統可用性低于99.95%時及時發出告警資訊,以便系統維護者可以及時做出優化,如系統可用性補救、擴容、分析故障原因、系統改造等。

3. 可擴充性名額

面對到來的突發流量,我們明顯來不及對系統做架構改造,而更快捷、有效的做法是增加系統叢集中的節點來水準擴充系統的服務能力。可擴充性=吞吐量提升比例/叢集節點增加比例。在最理想的情況下,叢集節點增加幾倍,系統吞吐量就能增加幾倍。一般來說,擁有70%~80%可擴充性的系統基本能夠滿足可擴充性要求。

高并發場景分類

我們使用計算機實作各種業務功能,最終将展現在對資料的兩種操作上,即讀和寫,于是高并發請求可以被歸類為高并發讀和高并發寫。

比如有的業務場景讀多寫少,需要重點解決高并發讀的問題;有的業務場景寫多讀少,需要重點解決高并發寫的問題;而有的業務場景讀多寫多,則需要同時解決高并發讀和高并發寫的問題。将高并發場景劃分為高并發讀場景和高并發寫場景,是因為在這兩種場景中往往有不同的高并發解決方案。

資料庫讀/寫分離

大部分網際網路應用都是讀多寫少的,比如刷帖的請求永遠比發帖的請求多,浏覽商品的請求永遠比下單購買商品的請求多。資料庫承受的高并發請求壓力,主要來自讀請求。我們可以把資料庫按照讀/寫請求分成專門負責處理寫請求的資料庫(寫庫)和專門負責處理讀請求的資料庫(讀庫),讓所有的寫請求都落到寫庫,寫庫将寫請求處理後的最新資料同步到讀庫,所有的讀請求都從讀庫中讀取資料。這就是資料庫讀/寫分離的思路。

資料庫讀/寫分離使大量的讀請求從資料庫中分離出來,減少了資料庫通路壓力,縮短了請求響應時間。

讀/寫分離架構

我們通常使用資料庫主從複制技術實作讀/寫分離架構,将資料庫主節點Master作為“寫庫”,将資料庫從節點Slave作為“讀庫”,一個Master可以與多個Slave連接配接,如圖所示。

高并發架構通用設計方案(典藏版)

市面上各主流資料庫都實作了主從複制技術。

讀/寫請求路由方式

在資料庫讀/寫分離架構下,把寫請求交給Master處理,而把讀請求交給Slave處理,那麼由什麼角色來執行這樣的讀/寫請求路由呢?一般可以采用如下兩種方式。

1. 基于資料庫Proxy代理的方式

在業務服務和資料庫伺服器之間增加資料庫Proxy代理節點(下文簡稱Proxy),業務服務對資料庫的一切操作都需要經過Proxy轉發。Proxy收到業務服務的資料庫操作請求後,根據請求中的SQL語句進行歸類,将屬于寫操作的請求(如insert/delete/update語句)轉發到資料庫Master,将屬于讀操作的請求(如select語句)轉發到資料庫任意一個Slave,完成讀/寫分離的路由。開源項目如中心化代理形式的MySQL-Proxy和MyCat,以及本地代理形式的MySQL-Router等都實作了讀/寫分離功能。

2. 基于應用内嵌的方式

基于應用内嵌的方式與基于資料庫Proxy代理的方式的主要差別是,它在業務服務程序内進行請求讀/寫分離,資料庫連接配接架構開源項目如gorm、shardingjdbc等都實作了此形式的讀/寫分離功能。

主從延遲與解決方案

資料庫讀/寫分離架構依賴資料庫主從複制技術,而資料庫主從複制存在資料複制延遲(主從延遲),是以會導緻在資料複制延遲期間主從資料的不一緻,Slave擷取不到最新資料。針對主從延遲問題有如下三種解決方案。

1. 同步資料複制

資料庫主從複制預設是異步模式,Master在寫完資料後就傳回成功了,而不管Slave是否收到此資料。我們可以将主從複制配置為同步模式,Master在寫完資料後,要等到全部Slave都收到此資料後才傳回成功。

這種方案可以保證資料庫每次寫操作成功後,Master和Slave都能讀取到最新資料。這種方案相對簡單,将資料庫主從複制修改為同步模式即可,無須改造業務服務。

但是由于在處理業務寫請求時,Master要等到全部Slave都收到資料後才能傳回成功,寫請求的延遲将大大增加,資料庫的吞吐量也會有明顯的下滑。這種方案的實用價值較低,僅适合在低并發請求的業務場景中使用。

2. 強制讀主

不同的業務場景對主從延遲的容忍性不一樣。例如,使用者a剛剛釋出了一條狀态,他浏覽個人首頁時應該展示這條狀态,這個場景不太能容忍主從延遲;而好友使用者b此時浏覽使用者a的個人首頁時,可以暫時看不到使用者a最新釋出的狀态,這個場景可以容忍主從延遲。

我們可以對業務場景按照主從延遲容忍性的高低進行劃分,對于主從延遲容忍性高的場景,執行正常的讀/寫分離邏輯;而對于主從延遲容忍性低的場景,強制将讀請求路由到資料庫Master,即強制讀主。

3. 會話分離

比如某會話在資料庫中執行了寫操作,那麼在接下來極短的一段時間内,此會話的讀請求暫時被強制路由到資料庫Master,與“強制讀主”方案中的例子很像,保證每個使用者的寫操作立刻對自己可見。暫時強制讀主的時間可以被設定為略高于資料庫完成主從資料複制的延遲時間,盡量使強制讀主的時間段覆寫主從資料複制的實際延遲時間。

本地緩存

在計算機世界中,緩存(Cache)無處不在,如CPU緩存、DNS緩存、浏覽器緩存等。值得一提的是,Cache在大陸台灣地區被譯為“快取”,更直接地展現了它的用途:快速讀取。緩存的本質是通過空間換時間的思路來保證資料的快速讀取。

業務服務一般需要通過網絡調用向其他服務或資料庫發送讀資料請求。為了提高資料的讀取效率,業務服務程序可以将已經擷取到的資料緩存到本地記憶體中,之後業務服務程序收到相同的資料請求時就可以直接從本地記憶體中擷取資料傳回,将網絡請求轉化為高效的記憶體存取邏輯。這就是本地緩存的主要用途。在本書後面的核心服務設計篇中會大量應用本地緩存,本節先重點介紹本地緩存的技術原理。

基本的緩存淘汰政策

雖然緩存使用空間換時間可以提高資料的讀取效率,但是記憶體資源的珍貴決定了本地緩存不可無限擴張,需要在占用空間和節約時間之間進行權衡。這就要求本地緩存能自動淘汰一些緩存的資料,淘汰政策應該盡量保證淘汰不再被使用的資料,保證有較高的緩存命中率。基本的緩存淘汰政策如下。

  • FIFO(First In First Out)政策: 優先淘汰最早進入緩存的資料。這是最簡單的淘汰政策,可以基于隊列實作。但是此政策的緩存命中率較低,越是被頻繁通路的資料是越早進入隊列的,于是會被越早地淘汰。此政策在實踐中很少使用。
  • LFU(Least Frequently Used)政策: 優先淘汰最不常用的資料。LFU政策會為每條緩存資料維護一個通路計數,資料每被通路一次,其通路計數就加1,通路計數最小的資料是被淘汰的目标。此政策很适合緩存在短時間内會被頻繁通路的熱點資料,但是最近最新緩存的資料總會被淘汰,而早期通路頻率高但最近一直未被通路的資料會長期占用緩存。
  • LRU(Least Recent Used)政策: 優先淘汰緩存中最近最少使用的資料。此政策一般基于雙向連結清單和哈希表配合實作。雙向連結清單負責存儲緩存資料,并總是将最近被通路的資料放置在尾部,使緩存資料在雙向連結清單中按照最近通路時間由遠及近排序,每次被淘汰的都是位于雙向連結清單頭部的資料。哈希表負責定位資料在雙向連結清單中的位置,以便實作快速資料通路。此政策可以有效提高短期内熱點資料的緩存命中率,但如果是偶發性地通路冷資料,或者批量通路資料,則會導緻熱點資料被淘汰,進而降低緩存命中率。

LRU政策和LFU政策的缺點是都會導緻緩存命中率大幅下降。近年來,業界出現了一些更複雜、效果更好的緩存淘汰政策,比如W-TinyLFU政策。

分布式緩存

由于本地緩存把資料緩存在服務程序的記憶體中,不需要網絡開銷,故而性能非常高。但是把資料緩存到記憶體中也有較多限制,舉例如下。

  • 無法共享: 多個服務程序之間無法共享本地緩存。
  • 程式設計語言限制: 本地緩存與程式綁定,用Golang語言開發的本地緩存元件不可以直接為用Java語言開發的伺服器所使用。
  • 可擴充性差: 由于服務程序攜帶了資料,是以服務是有狀态的。有狀态的服務不具備較好的可擴充性。
  • 記憶體易失性: 服務程序重新開機,緩存資料全部丢失。

我們需要一種支援多程序共享、與程式設計語言無關、可擴充、資料可持久化的緩存,這種緩存就是分布式緩存。

分布式緩存選型

主流的分布式緩存開源項目有Memcached和Redis,兩者都是優秀的緩存産品,并且都具有緩存資料共享、與程式設計語言無關的能力。不過,相對于Memcached而言,Redis更為流行,主要展現如下。

  • 資料類型豐富: Memcached僅支援字元串資料類型緩存,而Redis支援字元串、清單、集合、哈希、有序集合等資料類型緩存。
  • 資料可持久化: Redis通過RDB機制和AOF機制支援資料持久化,而Memcached沒有資料持久化能力。
  • 高可用性: Redis支援主從複制模式,在伺服器遇到故障後,它可以通過主從切換操作保證緩存服務不間斷。Redis具有較高的可用性。
  • 分布式能力: Memcached本身并不支援分布式,是以隻能通過用戶端,以一緻性哈希這樣的負載均衡算法來實作基于Memcached的分布式緩存系統。而Redis有官方出品的無中心分布式方案Redis Cluster,業界也有豆瓣Codis和推特Twemproxy的中心化分布式方案。

由于Redis支援豐富的資料類型和資料持久化,同時擁有高可用性和高可擴充性,是以它成為大部分網際網路應用分布式緩存的首選。

如何使用Redis緩存

使用Redis緩存的邏輯如下。

  1. 嘗試在Redis緩存中查找資料,如果命中緩存,則傳回資料。
  2. 如果在Redis緩存中找不到資料,則從資料庫中讀取資料。
  3. 将從資料庫中讀取到的資料儲存到Redis緩存中,并為此資料設定一個過期時間。
  4. 下次在Redis緩存中查找同樣的資料,就會命中緩存。

将資料儲存到Redis緩存時,需要為資料設定一個合适的過期時間,這樣做有以下兩個好處。

  1. 如果沒有為緩存資料設定過期時間,那麼資料會一直堆積在Redis記憶體中,尤其是那些不再被通路或者命中率極低的緩存資料,它們一直占據Redis記憶體會造成大量的資源浪費。設定過期時間可以使Redis自動删除那些不再被通路的緩存資料,而對于經常被通路的緩存資料,每次被通路時都重置過期時間,可以保證緩存命中率高。
  2. 當資料庫與Redis緩存由于各種故障出現了資料不一緻的情況時,過期時間是一個很好的兜底手段。例如,設定緩存資料的過期時間為10s,那麼資料庫和Redis緩存即使出現資料不一緻的情況,最多也就持續10s。過期時間可以保證資料庫和Redis緩存僅在此時間段内有資料不一緻的情況,是以可以保證資料的最終一緻性。

在上述邏輯中,有一個極有可能帶來風險的操作:某請求通路的資料在Redis緩存中不存在,此請求會通路資料庫讀取資料;而如果有大量的請求通路資料庫,則可能導緻資料庫崩潰。Redis緩存中不存在某資料,隻可能有兩種原因:一是在Redis緩存中從未存儲過此資料,二是此資料已經過期。下面我們就這兩種原因來做有針對性的優化。

緩存穿透

當使用者試圖請求一條連資料庫中都不存在的非法資料時,Redis緩存會顯得形同虛設。

  • 嘗試在Redis緩存中查找此資料,如果命中,則傳回資料。
  • 如果在Redis緩存中找不到此資料,則從資料庫中讀取資料。
  • 如果在資料庫中也找不到此資料,則最終向使用者傳回空資料

可以看到,Redis緩存完全無法阻擋此類請求直接通路資料庫。如果黑客惡意持續發起請求來通路某條不存在的非法資料,那麼這些非法請求會全部穿透Redis緩存而直接通路資料庫,最終導緻資料庫崩潰。這種情況被稱為“緩存穿透”。

為了防止出現緩存穿透的情況,當在資料庫中也找不到某資料時,可以在Redis緩存中為此資料儲存一個空值,用于表示此資料為空。這樣一來,之後對此資料的請求均會被Redis緩存攔截,進而阻斷非法請求對資料庫的騷擾。

不過,如果黑客通路的不是一條非法資料,而是大量不同的非法資料,那麼此方案會使得Redis緩存中存儲大量無用的空資料,甚至會逐出較多的合法資料,大大降低了Redis緩存命中率,資料庫再次面臨風險。我們可以使用布隆過濾器來解決緩存穿透問題。

布隆過濾器由一個固定長度為m的二進制向量和k個哈希函數組成。當某資料被加入布隆過濾器中後,k個哈希函數為此資料計算出k個哈希值并與m取模,并且在二進制向量對應的N個位置上設定值為1;如果想要查詢某資料是否在布隆過濾器中,則可以通過相同的哈希計算後在二進制向量中檢視這k個位置值:

  • 如果有任意一個位置值為0,則說明被查詢的資料一定不存在;
  • 如果所有的位置值都為1,則說明被查詢的資料可能存在。之是以說可能存在,是因為哈希函數免不了會有資料碰撞的可能,在這種情況下會造成對某資料的誤判,不過可以通過調整m和k的值來降低誤判率。

雖然布隆過濾器對于“資料存在”有一定的誤判,但是對于“資料不存在”的判定是準确的。布隆過濾器很适合用來防止緩存穿透:将資料庫中的全部資料加入布隆過濾器中,當使用者請求通路某資料但是在Redis緩存中找不到時,檢查布隆過濾器中是否記錄了此資料。如果布隆過濾器認為資料不存在,則使用者請求不再通路資料庫;如果布隆過濾器認為資料可能存在,則使用者請求繼續通路資料庫;如果在資料庫中找不到此資料,則在Redis緩存中設定空值。雖然布隆過濾器對“資料存在”有一定的誤判,但是誤判率較低。最後在Redis緩存中設定的空值也很少,不會影響Redis緩存命中率。

緩存雪崩

如果在同一時間Redis緩存中的資料大面積過期,則會導緻請求全部湧向資料庫。這種情況被稱為“緩存雪崩”。緩存雪崩與緩存穿透的差別是,前者是很多緩存資料不存在造成的,後者是一條緩存資料不存在導緻的。

緩存雪崩一般有兩種誘因:大量資料有相同的過期時間,或者Redis服務當機。第一種誘因的解決方案比較簡單,可以在為緩存資料設定過期時間時,讓過期時間的值在預設的小範圍内随機分布,避免大部分緩存資料有相同的過期時間。第二種誘因取決于Redis的可用性,選取高可用的Redis叢集架構可以極大地降低Redis服務當機的機率。

高并發讀場景總結:CQRS

無論是資料庫讀/寫分離、本地緩存還是分布式緩存,其本質上都是讀/寫分離,這也是在微服務架構中經常被提及的CQRS模式。CQRS(Command Query Responsibility Segregation,指令查詢職責分離)是一種将資料的讀取操作與更新操作分離的模式。query指的是讀取操作,而command是對會引起資料變化的操作的總稱,新增、删除、修改這些操作都是指令。

CQRS的簡要架構與實作

為了避免引入微服務領域驅動設計的相關概念,下圖給出了CQRS的簡要架構。

高并發架構通用設計方案(典藏版)

1)當業務服務收到用戶端發起的command請求(即寫請求)時,會将此請求交給寫資料存儲來處理。

2)寫資料存儲完成資料變更後,将資料變更消息發送到消息隊列。

3)讀資料存儲負責監聽消息隊列,當它收到資料變更消息後,将資料寫入自身。

4)當業務服務收到用戶端發起的query請求(即讀請求)時,将此請求交給讀資料存儲來處理。

5)讀資料存儲将此請求希望通路的資料傳回。

寫資料存儲、讀資料存儲、資料傳輸通道均是較為寬泛的代稱,其中寫資料存儲和讀資料存儲在不同的高并發場景下有不同的具體指代,資料傳輸通道在不同的高并發場景下有不同的形式展現,可能是消息隊列、定時任務等。

  • 對于資料庫讀/寫分離來說,寫資料存儲是 Master,讀資料存儲是 Slave,消息隊列的實作形式是資料庫主從複制。
  • 對于分布式緩存場景來說,寫資料存儲是資料庫,讀資料存儲是 Redis 緩存,消息隊列的實作形式是使用消息中間件監聽資料庫的binlog資料變更日志。

無論是何種場景,都應該為寫資料存儲選擇适合高并發寫入的存儲系統,為讀資料存儲選擇适合高并發讀取的存儲系統,消息隊列作為資料傳輸通道要足夠健壯,保證資料不丢失。

繼續閱讀