面對千萬級、億級流量怎麼處理?
業務量增長 10 倍、100 倍怎麼處理?
怎麼設計一個高併發系統?
高併發系統都有什麼特點?
... ...
諸如此類,問法很多,但是面試這種類型的問題,看着很難無處下手,但是我們可以有一個常規的思路去回答,就是圍繞支撐高併發的業務場景怎麼設計系統才合理?如果你能想到這一點,那接下來我們就可以圍繞硬件和軟件層面怎麼支撐高併發這個話題去闡述了。本質上,這個問題就是綜合考驗你對各個細節是否知道怎麼處理,是否有經驗處理過而已。
面對超高的併發,首先硬件層面機器要能扛得住,其次架構設計做好微服務的拆分,代碼層面各種緩存、削峯、解耦等等問題要處理好,數據庫層面做好讀寫分離、分庫分表,穩定性方面要保證有監控,熔斷限流降級該有的必須要有,發生問題能及時發現處理。
微服務架構演化
在互聯網早期的時候,單體架構就足以支撐起日常的業務需求,大家的所有業務服務都在一個項目裏,部署在一臺物理機器上。
所有的業務包括你的交易訂單、會員、庫存、商品、營銷等等都夾雜在一起,當流量一旦高起來之後,單體架構的問題就暴露出來了,機器掛了所有的業務全部無法使用了。
於是,集羣架構的架構開始出現,單機無法抗住的壓力,最簡單的辦法就是水平拓展橫向擴容。這樣,通過負載均衡把壓力流量分攤到不同的機器上,暫時是解決了單點導致服務不可用的問題。
但是隨着業務的發展,在一個項目裏維護所有的業務場景使開發和代碼維護變得越來越困難,一個簡單的需求改動都需要發佈整個服務,代碼的合併衝突也會變得越來越頻繁,同時線上故障出現的可能性越大,微服務的架構模式就誕生了。
把每個獨立的業務拆分開獨立部署,開發和維護的成本降低,集羣能承受的壓力也提高了,再也不會出現一個小小的改動點需要牽一髮而動全身了。
以上的點從高併發的角度而言,似乎都可以歸類爲通過服務拆分和集羣物理機器的擴展提高了整體的系統抗壓能力,那麼,隨之拆分而帶來的問題也就是高併發系統需要解決的問題。
通信
微服務化的拆分帶來的好處和便利性是顯而易見的,但是與此同時各個微服務之間的通信就需要考慮了。
對於 SOA、微服務化的架構而言,就對部署、運維、服務治理、鏈路追蹤等等有了更高的要求。
基於此,無論選用何種框架 Spring Cloud、Spring Cloud Alibaba、Dubbo、Thrift、gRpc 其實都一樣。
於現在國內的技術棧選擇來說,大廠基本都是自研,中小廠更多采用如 Dubbo 這類框架,現在來說,Spring Cloud Alibaba 應該是未來一段時間的主流方向。
但是無論使用何種框架,一些基本原理都是應該瞭解的。此處以 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 也可以通過配置做到:
1acks=all 只有參與複製的所有節點全部收到消息,才返回生產者成功。這樣的話除非所有的節點都掛了,消息纔會丟失。
2replication.factor=N,設置大於1的數,這會要求每個partion至少有2個副本
3min.insync.replicas=N,設置大於1的數,這會要求leader至少感知到一個follower還保持着連接
4retries=N,設置一個非常大的值,讓生產者發送失敗一直重試
5
6
雖然我們可以通過配置的方式來達到 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 張表,每張表初始值不同,設置 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 這樣的緩存來說就完全不是問題。
以秒殺系統舉例,活動預熱商品信息可以提前緩存提供查詢服務,活動庫存數據可以提前緩存,下單流程可以完全走緩存扣減,秒殺結束後再異步寫入數據庫,數據庫承擔的壓力就小的太多了。當然,引入緩存之後就還要考慮緩存擊穿、雪崩、熱點一系列的問題了。
更多請查看:《我想進大廠》之 Redis 奪命連環 11 問
熱 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、事務一致性的問題。
而爲了解決這些問題我們又要不斷的加入各種措施熔斷、限流、降級、離線覈對、預案處理等等來防止和追溯這些問題。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/BQcMuiCBFiw9adwR5yCDeQ