這個《我想進大廠》系列的最後一篇,終結篇。可能有點標題黨了,但是我想要表達的意思和目的是一致的。
這是一道很常見的面試題,但是大多數人並不知道怎麼回答,這種問題其實可以有很多形式的提問方式,你一定見過而且感覺無從下手:
面對業務急劇增長你怎麼處理?
業務量增長10倍、100倍怎麼處理?
你們系統怎麼支撐高併發的?
怎麼設計一個高併發系統?
高併發系統都有什麼特點?
... ...
諸如此類,問法很多,但是面試這種型別的問題,看著很難無處下手,但是我們可以有一個常規的思路去回答,就是圍繞支撐高併發的業務場景怎麼設計系統才合理?如果你能想到這一點,那接下來我們就可以圍繞硬體和軟體層面怎麼支撐高併發這個話題去闡述了。本質上,這個問題就是綜合考驗你對各個細節是否知道怎麼處理,是否有經驗處理過而已。
面對超高的併發,首先硬體層面機器要能扛得住,其次架構設計做好微服務的拆分,程式碼層面各種快取、削峰、解耦等等問題要處理好,資料庫層面做好讀寫分離、分庫分表,穩定性方面要保證有監控,熔斷限流降級該有的必須要有,發生問題能及時發現處理。這樣從整個系統設計方面就會有一個初步的概念。
微服務架構演化
在網際網路早期的時候,單體架構就足以支撐起日常的業務需求,大家的所有業務服務都在一個專案裡,部署在一臺物理機器上。所有的業務包括你的交易系統、會員資訊、庫存、商品等等都夾雜在一起,當流量一旦起來之後,單體架構的問題就暴露出來了,機器掛了所有的業務全部無法使用了。
於是,叢集架構的架構開始出現,單機無法抗住的壓力,最簡單的辦法就是水平擴充橫向擴容了,這樣,通過負載均衡把壓力流量分攤到不同的機器上,暫時是解決了單點導致服務不可用的問題。
但是隨著業務的發展,在一個專案裡維護所有的業務場景使開發和程式碼維護變得越來越困難,一個簡單的需求改動都需要釋出整個服務,程式碼的合併衝突也會變得越來越頻繁,同時線上故障出現的可能性越大。微服務的架構模式就誕生了。
把每個獨立的業務拆分開獨立部署,開發和維護的成本降低,叢集能承受的壓力也提高了,再也不會出現一個小小的改動點需要牽一髮而動全身了。
以上的點從高併發的角度而言,似乎都可以歸類為通過服務拆分和叢集物理機器的擴充套件提高了整體的系統抗壓能力,那麼,隨之拆分而帶來的問題也就是高併發系統需要解決的問題。
RPC
微服務化的拆分帶來的好處和便利性是顯而易見的,但是與此同時各個微服務之間的通訊就需要考慮了。傳統HTTP的通訊方式對效能是極大的浪費,這時候就需要引入諸如Dubbo類的RPC框架,基於TCP長連線的方式提高整個叢集通訊的效率。
我們假設原來來自客戶端的QPS是9000的話,那麼通過負載均衡策略分散到每臺機器就是3000,而HTTP改為RPC之後介面的耗時縮短了,單機和整體的QPS就提升了。而RPC框架本身一般都自帶負載均衡、熔斷降級的機制,可以更好的維護整個系統的高可用性。
那麼說完RPC,作為基本上國內普遍的選擇Dubbo的一些基本原理就是接下來的問題。
Dubbo工作原理
-
服務啟動的時候,provider和consumer根據配置資訊,連線到註冊中心register,分別向註冊中心註冊和訂閱服務
-
register根據服務訂閱關係,返回provider資訊到consumer,同時consumer會把provider資訊快取到本地。如果資訊有變更,consumer會收到來自register的推送
-
consumer生成代理物件,同時根據負載均衡策略,選擇一臺provider,同時定時向monitor記錄介面的呼叫次數和時間資訊
-
拿到代理物件之後,consumer通過代理物件發起介面呼叫
-
provider收到請求後對資料進行反序列化,然後通過代理呼叫具體的介面實現
Dubbo負載均衡策略
-
加權隨機:假設我們有一組伺服器 servers = [A, B, C],他們對應的權重為 weights = [5, 3, 2],權重總和為10。現在把這些權重值平鋪在一維座標值上,[0, 5) 區間屬於伺服器 A,[5, 8) 區間屬於伺服器 B,[8, 10) 區間屬於伺服器 C。接下來通過隨機數生成器生成一個範圍在 [0, 10) 之間的隨機數,然後計算這個隨機數會落到哪個區間上就可以了。
-
最小活躍數:每個服務提供者對應一個活躍數 active,初始情況下,所有服務提供者活躍數均為0。每收到一個請求,活躍數加1,完成請求後則將活躍數減1。在服務執行一段時間後,效能好的服務提供者處理請求的速度更快,因此活躍數下降的也越快,此時這樣的服務提供者能夠優先獲取到新的服務請求。
-
一致性hash:通過hash演算法,把provider的invoke和隨機節點生成hash,並將這個 hash 投射到 [0, 2^32 - 1] 的圓環上,查詢的時候根據key進行md5然後進行hash,得到第一個節點的值大於等於當前hash的invoker。
圖片來自dubbo官方
- 加權輪詢:比如伺服器 A、B、C 權重比為 5:2:1,那麼在8次請求中,伺服器 A 將收到其中的5次請求,伺服器 B 會收到其中的2次請求,伺服器 C 則收到其中的1次請求。
叢集容錯
-
Failover Cluster失敗自動切換:dubbo的預設容錯方案,當呼叫失敗時自動切換到其他可用的節點,具體的重試次數和間隔時間可用通過引用服務的時候配置,預設重試次數為1也就是隻呼叫一次。
-
Failback Cluster快速失敗:在呼叫失敗,記錄日誌和呼叫資訊,然後返回空結果給consumer,並且通過定時任務每隔5秒對失敗的呼叫進行重試
-
Failfast Cluster失敗自動恢復:只會呼叫一次,失敗後立刻丟擲異常
-
Failsafe Cluster失敗安全:呼叫出現異常,記錄日誌不丟擲,返回空結果
-
Forking Cluster並行呼叫多個服務提供者:通過執行緒池建立多個執行緒,併發呼叫多個provider,結果儲存到阻塞佇列,只要有一個provider成功返回了結果,就會立刻返回結果
-
Broadcast Cluster廣播模式:逐個呼叫每個provider,如果其中一臺報錯,在迴圈呼叫結束後,丟擲異常。
訊息佇列
對於MQ的作用大家都應該很瞭解了,削峰填谷、解耦。依賴訊息佇列,同步轉非同步的方式,可以降低微服務之間的耦合。
對於一些不需要同步執行的介面,可以通過引入訊息佇列的方式非同步執行以提高介面響應時間。在交易完成之後需要扣庫存,然後可能需要給會員發放積分,本質上,發積分的動作應該屬於履約服務,對實時性的要求也不高,我們只要保證最終一致性也就是能履約成功就行了。對於這種同類性質的請求就可以走MQ非同步,也就提高了系統抗壓能力了。
對於訊息佇列而言,怎麼在使用的時候保證訊息的可靠性、不丟失?
訊息可靠性
訊息丟失可能發生在生產者傳送訊息、MQ本身丟失訊息、消費者丟失訊息3個方面。
生產者丟失
生產者丟失訊息的可能點在於程式傳送失敗拋異常了沒有重試處理,或者傳送的過程成功但是過程中網路閃斷MQ沒收到,訊息就丟失了。
由於同步傳送的一般不會出現這樣使用方式,所以我們就不考慮同步傳送的問題,我們基於非同步傳送的場景來說。
非同步傳送分為兩個方式:非同步有回撥和非同步無回撥,無回撥的方式,生產者傳送完後不管結果可能就會造成訊息丟失,而通過非同步傳送+回撥通知+本地訊息表的形式我們就可以做出一個解決方案。以下單的場景舉例。
- 下單後先儲存本地資料和MQ訊息表,這時候訊息的狀態是傳送中,如果本地事務失敗,那麼下單失敗,事務回滾。
- 下單成功,直接返回客戶端成功,非同步傳送MQ訊息
- MQ回撥通知訊息傳送結果,對應更新資料庫MQ傳送狀態
- JOB輪詢超過一定時間(時間根據業務配置)還未傳送成功的訊息去重試
- 在監控平臺配置或者JOB程式處理超過一定次數一直髮送不成功的訊息,告警,人工介入。
一般而言,對於大部分場景來說非同步回撥的形式就可以了,只有那種需要完全保證不能丟失訊息的場景我們做一套完整的解決方案。
MQ丟失
如果生產者保證訊息傳送到MQ,而MQ收到訊息後還在記憶體中,這時候當機了又沒來得及同步給從節點,就有可能導致訊息丟失。
比如RocketMQ:
RocketMQ分為同步刷盤和非同步刷盤兩種方式,預設的是非同步刷盤,就有可能導致訊息還未刷到硬碟上就丟失了,可以通過設定為同步刷盤的方式來保證訊息可靠性,這樣即使MQ掛了,恢復的時候也可以從磁碟中去恢復訊息。
比如Kafka也可以通過配置做到:
acks=all 只有參與複製的所有節點全部收到訊息,才返回生產者成功。這樣的話除非所有的節點都掛了,訊息才會丟失。
replication.factor=N,設定大於1的數,這會要求每個partion至少有2個副本
min.insync.replicas=N,設定大於1的數,這會要求leader至少感知到一個follower還保持著連線
retries=N,設定一個非常大的值,讓生產者傳送失敗一直重試
雖然我們可以通過配置的方式來達到MQ本身高可用的目的,但是都對效能有損耗,怎樣配置需要根據業務做出權衡。
消費者丟失
消費者丟失訊息的場景:消費者剛收到訊息,此時伺服器當機,MQ認為消費者已經消費,不會重複傳送訊息,訊息丟失。
RocketMQ預設是需要消費者回復ack確認,而kafka需要手動開啟配置關閉自動offset。
消費方不返回ack確認,重發的機制根據MQ型別的不同傳送時間間隔、次數都不盡相同,如果重試超過次數之後會進入死信佇列,需要手工來處理了。(Kafka沒有這些)
訊息的最終一致性
事務訊息可以達到分散式事務的最終一致性,事務訊息就是MQ提供的類似XA的分散式事務能力。
半事務訊息就是MQ收到了生產者的訊息,但是沒有收到二次確認,不能投遞的訊息。
實現原理如下:
- 生產者先傳送一條半事務訊息到MQ
- MQ收到訊息後返回ack確認
- 生產者開始執行本地事務
- 如果事務執行成功傳送commit到MQ,失敗傳送rollback
- 如果MQ長時間未收到生產者的二次確認commit或者rollback,MQ對生產者發起訊息回查
- 生產者查詢事務執行最終狀態
- 根據查詢事務狀態再次提交二次確認
最終,如果MQ收到二次確認commit,就可以把訊息投遞給消費者,反之如果是rollback,訊息會儲存下來並且在3天后被刪除。
資料庫
對於整個系統而言,最終所有的流量的查詢和寫入都落在資料庫上,資料庫是支撐系統高併發能力的核心。怎麼降低資料庫的壓力,提升資料庫的效能是支撐高併發的基石。主要的方式就是通過讀寫分離和分庫分表來解決這個問題。
對於整個系統而言,流量應該是一個漏斗的形式。比如我們的日活使用者DAU有20萬,實際可能每天來到提單頁的使用者只有3萬QPS,最終轉化到下單支付成功的QPS只有1萬。那麼對於系統來說讀是大於寫的,這時候可以通過讀寫分離的方式來降低資料庫的壓力。
讀寫分離也就相當於資料庫叢集的方式降低了單節點的壓力。而面對資料的急劇增長,原來的單庫單表的儲存方式已經無法支撐整個業務的發展,這時候就需要對資料庫進行分庫分表了。針對微服務而言垂直的分庫本身已經是做過的,剩下大部分都是分表的方案了。
水平分表
首先根據業務場景來決定使用什麼欄位作為分表欄位(sharding_key),比如我們現在日訂單1000萬,我們大部分的場景來源於C端,我們可以用user_id作為sharding_key,資料查詢支援到最近3個月的訂單,超過3個月的做歸檔處理,那麼3個月的資料量就是9億,可以分1024張表,那麼每張表的資料大概就在100萬左右。
比如使用者id為100,那我們都經過hash(100),然後對1024取模,就可以落到對應的表上了。
分表後的ID唯一性
因為我們主鍵預設都是自增的,那麼分表之後的主鍵在不同表就肯定會有衝突了。有幾個辦法考慮:
- 設定步長,比如1-1024張表我們分別設定1-1024的基礎步長,這樣主鍵落到不同的表就不會衝突了。
- 分散式ID,自己實現一套分散式ID生成演算法或者使用開源的比如雪花演算法這種
- 分表後不使用主鍵作為查詢依據,而是每張表單獨新增一個欄位作為唯一主鍵使用,比如訂單表訂單號是唯一的,不管最終落在哪張表都基於訂單號作為查詢依據,更新也一樣。
主從同步原理
- master提交完事務後,寫入binlog
- slave連線到master,獲取binlog
- master建立dump執行緒,推送binglog到slave
- slave啟動一個IO執行緒讀取同步過來的master的binlog,記錄到relay log中繼日誌中
- slave再開啟一個sql執行緒讀取relay log事件並在slave執行,完成同步
- slave記錄自己的binglog
由於mysql預設的複製方式是非同步的,主庫把日誌傳送給從庫後不關心從庫是否已經處理,這樣會產生一個問題就是假設主庫掛了,從庫處理失敗了,這時候從庫升為主庫後,日誌就丟失了。由此產生兩個概念。
全同步複製
主庫寫入binlog後強制同步日誌到從庫,所有的從庫都執行完成後才返回給客戶端,但是很顯然這個方式的話效能會受到嚴重影響。
半同步複製
和全同步不同的是,半同步複製的邏輯是這樣,從庫寫入日誌成功後返回ACK確認給主庫,主庫收到至少一個從庫的確認就認為寫操作完成。
快取
快取作為高效能的代表,在某些特殊業務可能承擔90%以上的熱點流量。對於一些活動比如秒殺這種併發QPS可能幾十萬的場景,引入快取事先預熱可以大幅降低對資料庫的壓力,10萬的QPS對於單機的資料庫來說可能就掛了,但是對於如redis這樣的快取來說就完全不是問題。
以秒殺系統舉例,活動預熱商品資訊可以提前快取提供查詢服務,活動庫存資料可以提前快取,下單流程可以完全走快取扣減,秒殺結束後再非同步寫入資料庫,資料庫承擔的壓力就小的太多了。當然,引入快取之後就還要考慮快取擊穿、雪崩、熱點一系列的問題了。
熱key問題
所謂熱key問題就是,突然有幾十萬的請求去訪問redis上的某個特定key,那麼這樣會造成流量過於集中,達到物理網路卡上限,從而導致這臺redis的伺服器當機引發雪崩。
針對熱key的解決方案:
- 提前把熱key打散到不同的伺服器,降低壓力
- 加入二級快取,提前載入熱key資料到記憶體中,如果redis當機,走記憶體查詢
快取擊穿
快取擊穿的概念就是單個key併發訪問過高,過期時導致所有請求直接打到db上,這個和熱key的問題比較類似,只是說的點在於過期導致請求全部打到DB上而已。
解決方案:
- 加鎖更新,比如請求查詢A,發現快取中沒有,對A這個key加鎖,同時去資料庫查詢資料,寫入快取,再返回給使用者,這樣後面的請求就可以從快取中拿到資料了。
- 將過期時間組合寫在value中,通過非同步的方式不斷的重新整理過期時間,防止此類現象。
快取穿透
快取穿透是指查詢不存在快取中的資料,每次請求都會打到DB,就像快取不存在一樣。
針對這個問題,加一層布隆過濾器。布隆過濾器的原理是在你存入資料的時候,會通過雜湊函式將它對映為一個位陣列中的K個點,同時把他們置為1。
這樣當使用者再次來查詢A,而A在布隆過濾器值為0,直接返回,就不會產生擊穿請求打到DB了。
顯然,使用布隆過濾器之後會有一個問題就是誤判,因為它本身是一個陣列,可能會有多個值落到同一個位置,那麼理論上來說只要我們的陣列長度夠長,誤判的概率就會越低,這種問題就根據實際情況來就好了。
快取雪崩
當某一時刻發生大規模的快取失效的情況,比如你的快取服務當機了,會有大量的請求進來直接打到DB上,這樣可能導致整個系統的崩潰,稱為雪崩。雪崩和擊穿、熱key的問題不太一樣的是,他是指大規模的快取都過期失效了。
針對雪崩幾個解決方案:
- 針對不同key設定不同的過期時間,避免同時過期
- 限流,如果redis當機,可以限流,避免同時刻大量請求打崩DB
- 二級快取,同熱key的方案。
穩定性
熔斷
比如營銷服務掛了或者介面大量超時的異常情況,不能影響下單的主鏈路,涉及到積分的扣減一些操作可以在事後做補救。
限流
對突發如大促秒殺類的高併發,如果一些介面不做限流處理,可能直接就把服務打掛了,針對每個介面的壓測效能的評估做出合適的限流尤為重要。
降級
熔斷之後實際上可以說就是降級的一種,以熔斷的舉例來說營銷介面熔斷之後降級方案就是短時間內不再呼叫營銷的服務,等到營銷恢復之後再呼叫。
預案
一般來說,就算是有統一配置中心,在業務的高峰期也是不允許做出任何的變更的,但是通過配置合理的預案可以在緊急的時候做一些修改。
核對
針對各種分散式系統產生的分散式事務一致性或者受到攻擊導致的資料異常,非常需要核對平臺來做最後的兜底的資料驗證。比如下游支付系統和訂單系統的金額做核對是否正確,如果收到中間人攻擊落庫的資料是否保證正確性。
總結
其實可以看到,怎麼設計高併發系統這個問題本身他是不難的,無非是基於你知道的知識點,從物理硬體層面到軟體的架構、程式碼層面的優化,使用什麼中介軟體來不斷提高系統的抗壓能力。但是這個問題本身會帶來更多的問題,微服務本身的拆分帶來了分散式事務的問題,http、RPC框架的使用帶來了通訊效率、路由、容錯的問題,MQ的引入帶來了訊息丟失、積壓、事務訊息、順序訊息的問題,快取的引入又會帶來一致性、雪崩、擊穿的問題,資料庫的讀寫分離、分庫分表又會帶來主從同步延遲、分散式ID、事務一致性的問題,而為了解決這些問題我們又要不斷的加入各種措施熔斷、限流、降級、離線核對、預案處理等等來防止和追溯這些問題。
這篇文章結合了之前的文章的一些內容,實際上最開始的時候就是想寫這一篇,發現篇幅實在太大了而且內容不好概括,所以就拆分了幾篇開始寫,這一篇算是對前面內容的一個歸納和總結吧,不是我為了水。
還有就是我的讀者朋友們的群開通了,你也希望加入的話那就加我的個人微信,備註”入群“吧。
還有還有最後一件事情,幫朋友發一個阿里雲的招聘資訊,急招各路大牛,base北京。有興趣的朋友也可以新增我的個人微信,備註”招聘“即可。
職位要求:
- 計算機相關專業本科及以上學歷;
- 精通兩種以上主流資料庫和快取技術,有豐富的資料庫設計、優化、維護經驗。或熟悉ELK、hadoop、spark、flink等大資料技術中的一種或多種。
- 精通JAVA、GO、Python等至少一門語言,5年以上的系統開發經驗,熟悉微服務架構、基於容器和雲原生技術,有豐富的實踐和落地經驗。
- 大型分散式系統架構設計經驗,如:容災高可用、業務容災多活、兩地三中心架構等,有專案落地經驗者優先。
- 熟悉阿里雲產品,通過阿里雲ACP、PMP、TOGAF相關認證者優先考慮。
- 能夠準確的理解客戶需求,有從事過大型企業雲化架構規劃、設計和諮詢的交付經驗。
- 具備良好的客戶溝通能力,工作積極主動,認真負責。
- END -