1. 程式人生 > >日訊息量突破50億,談小米的高可用推送系統設計

日訊息量突破50億,談小米的高可用推送系統設計

小米推送是目前國內領先的推送服務提供商,主要為開發者提供快捷、準確、穩定的推送服務。目前日活躍裝置突破3億,日訊息量突破50億。本文將會介紹小米推送在提高系統可用性方面的一些經驗和教訓。

  • 推送系統的高可用性以及如何提高可用性

  • 緩衝機制與服務解耦

  • 無狀態服務以及多機房部署

  • 過載保護與分級機制

小米推送是目前國內領先的推送服務提供商,主要為開發者提供快捷、準確、穩定的推送服務。目前接入APP 7000+家,日活躍裝置突破3億,日訊息量突破50億。

之所以取得如此的成績,一方面得益於我們在小米手機上系統級的連線,使我們有更高的訊息送達率,另一方面是因為我們本身的服務質量不低於業內其他的推送服務提供商。目前我們在小米手機上的日活為1億+,而在非小米手機上的日活突破2億,在iOS上的累計接入裝置也達到3億以上,從這些非MIUI的資料也可以看出,開發者對我們的推送質量是比較認可的。

我們是面向開發者的服務,主要職責是將開發者的訊息實時準確的推送到目標裝置上,是連線開發者與使用者裝置之間的一條高速訊息通道。這中間涉及很多環節,提高系統可用性就是提高每個環節的可用性,只有系統無短板,高可用性才有可能。

什麼是高可用性

在介紹如何提高系統可用性之前,我們首先需要先了解一下什麼是系統可用性

基於業務性質的差異,每個業務對可用性的定義也不盡相同,不過一般情況下,大多以系統可用時間佔總服務時間的比例做為可用性的定義。例如我們常說的4個9的可用性,就是可用時間佔比超過9999/10000,即只有不到萬分之一的時間不可用,也即一年只有不到60分鐘的不可用時間。因此設計、維持一個高可用的系統是非常困難的,這不僅要求我們的系統基本不出問題,在出現問題之後也要以儘可能短的時間內恢復可用。

小米推送是面向開發者的服務,從本質上來說我們從事於服務行業,系統是否可用除了使用上面的可用時間佔比來衡量之外,開發者主觀或客觀的使用感受也是衡量我們服務質量的重要標準,例如網路連線的穩定性,API的可用性,裝置的連通率等。從上面的各種指標中抽象出來,我們重點關注的有兩點,一個是訊息的送達率,第二個是訊息的送達延遲。

由於送達率關聯因素很多,不好準確量化,因此除了上面的可用性定義之外,我們還以訊息的送達延遲作為可用性的另一計算標準。比如線上裝置送達延遲(從開發者訊息開始處理到送達到裝置上)在N(1、5、15、30)分鐘的比例佔比高於多少我們認為系統可用,否則認為系統可用性低。

如何提高系統可用性

那我們如何提高系統可用性呢?

由可用性的定義可知,要想提高系統可用性,唯有將系統不可用時間降低到最低。一方面我們要儘量減少系統不可用(故障)出現的機率,另一方面,在故障發生後,我們要儘量減少故障帶來的影響,減少故障恢復所需要的時間,將損失降低到最低。

要做到這幾點,我們需要清楚的知道,我們所面臨的主要挑戰和風險是什麼,只有弄清楚所面臨的風險點,才能提前想好對策加以應對。對自己的業務性質加以剖析,理清楚風險因素與主要矛盾,是做一個高可用系統的第一步。

具體到推送系統來說,我們所面臨的挑戰和風險主要有以下幾點:

  1. 我們面臨的開發者眾多,每個開發者的水平良莠不齊,而他們對推送的理解也不盡相同,很可能跟我們預期的使用方式千差萬別,開發者無意中的使用,很可能對我們的系統造成“攻擊”行為。而開發者在高峰期“扎堆”推送訊息,也給我們帶來過載的風險。

  2. 我們的量級比較龐大(同時線上1.5億+,日訊息量50億+),別的業務不容易遇到的事情在我們這邊更容易發生,例如效能問題。

  3. 我們面臨的運營環境不盡完善,機房故障、網路故障、磁碟故障、機器宕機等情況時有發生,如何從設計上避免這些故障帶給我們的風險也是我們需要考慮的重點。

  4. 我們使用的一些第三方元件不一定是非常可靠的,如何選取合適的元件,如何規避地基不穩帶來的影響,在架構設計和技術選型時也要特別注意。

  5. 來自我們自身的挑戰,我們無法保證自己的程式不出bug,也無法保證自己的操作不出意外,如何從流程和規範上儘量避免人為因素造成的影響也是非常重要的。

理清風險因素之後,剩下的事情就是去一一解決這些風險,規避風險的發生,良好的架構設計、謹慎的技術選型和合理規範的流程是其中的三劑良方。下面將重點從緩衝、解耦、服務去狀態、服務分級等幾方面介紹一下小米推送在提高系統可用性方面做的一些嘗試。

緩衝機制

架構設計是高可用性的根基,一個好的架構可以避免絕大多數風險的發生,將影響可用性的風險因素扼殺在搖籃裡。在做架構設計時,我們需要明白我們要解決的首要矛盾是什麼。

對於推送系統來說,我們面臨的主要問題是系統流量隨時間分佈不均衡以及系統容易過載的問題。我們面臨的請求來源主要是兩個,一是來自裝置的請求,這部分連線數多,請求量大,但總體可控,只要我們設計好足夠的系統容量,基本不會出很大的問題;另一個是來自開發者的請求,這類請求屬於不可控型別,所有的開發者都希望在儘可能短的時間內將自己的訊息推送出去,我們無法提前得知開發者請求傳送的時間以及傳送的數量,它屬於脈衝式的訪問型別。由於裝置活躍時間的原因,開發者的請求時間一般極為集中。

對於這類請求,我們不可能為峰值準備足夠的容量,這會造成極大的資源浪費。但如果我們不做提前預防,極有可能我們的系統會被高峰期的瞬發流量壓垮,因此我們需要引入一個緩衝機制。

這屬於典型的訊息佇列(Message Queue)的使用場景。訊息佇列是一種服務間資料通訊的常見中介軟體,一般使用producer-consumer模式或publisher-subscriber模式,除了緩衝的作用之外,解耦和擴充套件性也是我們採用它的重要原因。常見的訊息佇列元件有Kafka、RabbitMQ、ActiveMQ等等,可以根據業務性質以及佇列的特點選擇合適的元件。

在推送系統中我們大量使用了訊息佇列(MQ)元件,將開發者的請求快取在訊息佇列中,然後逐漸消費,緩解開發者集中式的推送帶給我們系統的瞬間壓力。上面第一張圖是我們接入層接收到的開發者請求量,高峰期的請求量是平時的數倍甚至數十倍,第二張圖是我們業務層使用MQ之後處理的請求量,可以看到曲線平滑了許多,緩衝效果相當明顯。(這是在我們系統本身處理能力非常強大的情況下,否則緩衝作用會更加明顯)

服務解耦

耦合度是判斷一個系統是否健壯的重要標準之一。耦合度高的系統在穩定性、容災和擴充套件性方面都不容樂觀,常常會因區域性故障擴散傳染到其他模組,而導致故障惡化,受影響面擴大,甚至影響整個系統的可用性,給系統帶來較高風險。因此,系統解耦是我們設計一個分散式系統時需要重點考慮的問題。架構分層、服務拆分、通訊解耦、程式碼重構等是降低系統耦合度的比較常見的解決方案。

首先是程式碼解耦。

程式碼耦合會使程式碼的維護變得異常困難,極大的增加了程式碼閱讀和理解的難度,並增大了出現bug的機率,另一方面,程式碼的耦合也常常使模組邏輯上的關係變得複雜。因此,採取一定的手段進行程式碼解耦是我們提高系統可用性的基礎一步,例如更加良好的程式碼結構設計,更加巧妙的抽象層次,定期的程式碼重構等等。

其次是功能解耦。

功能耦合是系統設計的大忌,常常會使功能之間的可用性相互影響。

例如一個變更頻繁的功能A和一個比較穩定的功能B耦合在一個服務模組中,功能A的頻繁釋出變更必然會導致引入故障的機率增加(釋出是可用性的最大殺手),這樣雖然B功能較為穩定,但由於它和A處於同一程序中,A功能的故障很可能導致B功能無法使用。

這就要求我們對服務進行拆分,根據功能之間的關聯將服務儘可能的拆分為簡單單一的模組,每個功能模組間的耦合儘可能的降到最低,從而保證某一個功能模組出故障時,其他模組不受影響。

服務拆分可以分為垂直拆分與水平拆分。垂直拆分指的是系統的分層擴充套件能力,大多情況下,為了架構的清晰與邏輯的解耦,我們一般將系統根據一定原則分為若干層級,例如根據請求的處理時序分為接入層、業務層、儲存層等,或者根據資料的訪問情況分為代理層、邏輯層、Cache層、DB層等,良好的層次不僅有利於後續的維護,對於服務解耦和效能提升也有很多的幫助。水平拆分指的是系統在水平方向上的擴充套件能力,例如在業務層有若干模組處理若干事項,當一個新功能出現時,我們可以通過增加一個業務模組的方式去處理新增加的業務邏輯,從而做到了功能之間的 解耦,增強了系統的穩定性。

既然服務拆分有那麼多好處,是不是拆分的粒度越細越好呢?也不盡然,需要根據具體情況進行分析,服務拆分之後程序內通訊勢必要變為服務間通訊,效能會受到一定影響,需要根據業務性質以及對效能的要求進行綜合考慮。(服務拆分還可能會產生資料一致性的問題,解決該問題使用的事務機制也會極大的降低系統性能以及增加系統複雜度)

再次是服務間的通訊解耦

有時候服務拆分之後系統的耦合度依然很高,服務間的通訊方式可能會導致拆分效果大打折扣。

例如A、B、C三個服務模組,A呼叫B相關的介面,B呼叫C相關的介面,如果都是同步呼叫,或相互之間有其他時序或邏輯上的依賴,C一旦出問題,可能會導致A、B同時陷入故障狀態,從而導致連鎖反應(甚至產生邏輯死鎖),故障在服務之間傳染。

解決的方法就是避免服務間的邏輯(或時序)依賴關係,採用一定的非同步訪問策略,如訊息佇列、非同步呼叫等,可以根據業務性質與資料的重要性靈活選取。需要著重提一下的是訊息佇列(MQ),一般MQ的實現中都提供了良好的解耦機制,生產者在接收到請求後,將請求放入MQ,然後繼續處理其他事情,而消費者在適當的時候對請求進行處理,生產者和消費者之間不用相互依賴,降低了模組之間的關聯,對提升系統的穩定性有很大幫助。在推送系統中,接入層對內部系統的訪問都使用非同步呼叫方式,其他重要的處理路徑使用訊息佇列進行通訊,而非關鍵路徑(可丟棄)使用udp進行通訊(內網穩定性丟包率極低)。

總體上來說,解耦的關鍵點是做到故障隔離,保證故障發生時影響面儘可能小,故障不會從一個模組傳染到另一個模組。

上圖是小米推送的系統架構圖。整個系統根據業務性質分為線上、離線、旁路三個子系統。其中線上系統負責處理線上業務邏輯,根據請求處理過程分成接入層(以及裝置接入層)、業務層、Cache層、儲存層等四個層級,業務層根據功能或功能組合拆分為若干模組。旁路系統負責實時監控線上系統並對線上系統進行反饋,離線系統對日誌進行分析並生成統計報表。各個模組(子系統)功能簡單,邏輯清晰,穩定性、可擴充套件性和可用性得到一定保障。

無狀態服務與多機房部署

單點和過載是可用性的另外兩個重要殺手。

由於機器、磁碟、網路等多種不可控因素的存在,叢集區域性故障發生的概率很大,如何在區域性故障發生時維持對外的可用性是我們必須要面對的問題。應對這個問題的方案就是做到容量冗餘,也就是在系統本身的容量之外預留一定的處理能力,這樣在區域性故障發生時,由於容量buffer的存在,不會導致系統停擺或出現過載。而要做到這一點,就要求我們的服務有良好的可擴充套件性,可以比較容易的進行擴容或縮容,更不能有單點的存在。

單點一般意義上是指某個模組只有一個節點對外提供服務,一般屬於設計上的缺陷,由於模組內部狀態過於複雜而無法進行多點部署。單點意味著系統要承受極大的可用性壓力,在過載或節點發生故障時,該模組將無法對外提供服務。因此我們在做系統設計時一定要避免產生單點服務,這其中的關鍵點是去除或降低對服務的內部狀態的依賴性,做到節點間的無差別服務,也就是應盡力做到服務的去狀態化。

狀態在程式碼設計上一般表現為節點間資料的差異性,例如某接入層服務模組,節點A管理一部分連線,節點B管理另一部分連線,從而導致某些請求必須在節點A或節點B處理,從而產生資料差異,導致節點間狀態的產生。消除狀態的過程也就是去除資料差異的過程,例如去除模組節點快取的資料,或者將模組資料轉移至其他模組去儲存。

無狀態服務有諸多好處,比較顯著的就是極大的增強了服務的可擴充套件性以及應對區域性故障的能力。我們可以非常容易的增加或者刪除一個節點,在某個節點故障時,該節點的請求會自動被其他節點處理,從而實現故障的自動恢復。(failover)

而有時候有些模組因為某些原因(如效能或複雜度)無法做到去狀態化,這時候可以採用一定的路由策略,如一致性hash演算法,來降低節點狀態帶來的影響。

除了剛才說的單點之外,還有另外一種意義上的單點——部署機房的單點。雖說機房整體故障的概率不大,但如果不加以重視,一旦出現將會給我們帶來滅頂之災。因此,我們要將服務部署在多個機房以規避這種風險。

那我們的服務需要在幾個機房部署呢?這需要根據實際情況來決定,理論上越多越好,機房數量越多,每個機房需要擔負的冗餘容量會越少,造成的資源浪費也就越少。在機房數量=N時,假如某機房發生故障,剩餘其他機房需要有承擔所有流量的能力,即N-1的機房需要承擔的流量為1,則總體資源佔用為 N/(N-1),N越大,資源佔用總量越小,浪費也越少。

在多機房部署時,需要特別考慮一下多機房之間資料同步的問題。經驗告訴我們,一定要在設計上避免對機房間資料同步機制產生依賴,否則很容易帶來資料一致性的問題。例如某資料在機房A寫入,在機房B讀取,但讀取時很可能資料並沒有從A同步完畢,從而導致B讀取的資料與實際資料不一致,產生資料一致性問題,如果資料存在快取機制,則會加大這種不一致帶來的風險。

上圖是我們經過若干次演變之後的多機房訪問策略。我們將請求根據資源使用情況對映到0~1之間的浮點數,每個機房處理一部分請求,而同一資源相關的請求也只能被同一個機房的服務處理,從而避免了同一資源在多機房讀寫帶來的資料一致性問題。1)接入層接收到請求之後,將請求放入本機房的MQ中,避免跨機房訪問帶來的接入層穩定性的降低。2)每個機房的業務層同時處理所有機房MQ中的資料,然後根據一定的過濾規則過濾掉不屬於本節點相關的請求。3)相當於使用相對寬裕的內網流量換取了架構的簡單與可用性的提升。

過載保護與分級機制

雖說訊息佇列的緩衝機制能給我們系統帶來很大的保護,防止我們被洪水猛獸般的請求量沖垮。但系統不出問題並不代表系統可用,請求堆積在訊息佇列中得不到處理,一樣不是我們希望看到的。因此過載保護一樣是我們需要考慮的問題。在過載保護方面,我們所做的有以下幾點:

  1. 接入層建立自我保護機制,對開發者的請求頻率加以限制,對異常請求提前拒絕。

  2. 建立旁路監控系統,實時分析出異常請求,並反饋給線上系統。對於邏輯異常的請求及早拒絕,對於數量異常的請求降低處理優先順序,防止單個開發者的請求影響到整個系統服務可用性。

  3. 在系統過載時,及時丟棄失效請求。系統過載時,大量請求可能堆積在訊息佇列中,這些請求很可能已經失效,客戶端已經超時,繼續處理這些請求毫無價值,及早的發現並忽略這些請求有助於系統的快速恢復。

  4. 建立模組分級機制。每個模組功能不同,重要性也不一樣,在系統超載時,降低非核心模組的優先順序,保障核心模組的執行,可以最大程度上保障核心功能的可用性。

  5. 建立訊息分級機制。對於訊息量異常或邏輯異常的APP請求,適時自動降低訊息處理優先順序,降低處理速度,從而保障大多數正常開發者的使用。

流程與規範

影響可用性的因素很多,釋出、單點、過載是最常見的三種情況,後兩種可以通過精心的架構設計加以規避,但釋出卻無法通過架構上的設計加以規避。人的因素是可用性的最大敵人,如果一個服務在設計好之後沒有任何變更,相信良好的設計可以使可用性長期穩定在一個很高的水平之上。但不做變更基本不可能,而服務變更勢必增加了風險引入的可能,如何規避人的因素帶來的風險,是提高可用性的最重要的一步。在大多數情況下,我們無法完全避免風險的發生,我們可做的就是降低風險發生的概率,以及在風險發生時有足夠的措施可以降低它帶來的影響。這就需要有一套完善的流程來規範我們的行為(說易行難,貴在堅持):

開發階段

  • 測試用例先行,全方位的用例覆蓋

  • 任何功能都要增加開關控制,以便在發生故障時可以及時關閉有問題特性

  • 有足夠的日誌、完善的監控證明功能正確性

  • 交叉code review,規避個人盲點

上線階段

  • 必須所有測試用例全部通過方可上線,並在線上環境實時執行測試case

  • 變更通告,周知相關人,以便及早發現問題

  • 灰度:節點灰度,流量灰度等

  • 記錄釋出日誌,便於後續追查問題

故障階段

  • 優先關閉開關、回滾服務

  • 故障恢復後再追查問題原因,避免因追查問題導致影響增大

  • 事後總結,完善測試用例及相關監控,防止類似事件再次發生

總結

轉眼小米推送已經成立四年多了,這期間經歷了從無到有,從漏洞百出到逐步穩定,踩過許多坑,邁過許多坎,架構經歷了數次調整,程式碼也經過若干次重構,系統的可用性終於有了穩步的提高,服務質量也逐漸得到認可。下面總結了一些我們在提高系統可用性、提高服務質量方面的一些小小經驗,以供參考。

  1. KISS(Keep It Simple Stupid!)。無論是程式碼還是架構,都要儘可能的保持簡單,如果一個系統(或程式碼)複雜到需要小心維護,那它離大規模風險爆發也就不遠了。架構不是一成不變的,它往往是為了解決當時的問題而做出的設計,隨著時間的變化和業務的發展,有時並不能很好的適應當前的需要。定時對系統架構(和程式碼)進行審視,並根據需要做出調整(或重構),可以有效的提高系統的可用性。

  2. 技術選型要慎重。技術選型決定後續系統實現的難度以及穩定性等,需要根據團隊成員的知識結構以及選用技術的掌握難度、社群活躍程度等慎重選擇。做後臺服務首要的就是穩定性與可用性,新技術可以從邊緣模組進行嘗試,成熟後再在核心繫統使用,貿然在核心繫統中使用新技術,往往會付出難以承受的代價。現在開源技術比較火熱,系統中對開源元件的使用也越來越多,在技術選型確定後,對系統中使用的每個元件都要進行深入瞭解,不能只是簡單的會用,而是要用好。理解每深入一分,系統的效能和穩定性也會增加一分。

  3. 給自己留足後路。要想保持系統穩定完全不出問題其實很難,人都會犯錯,關鍵是要給自己留足後路。我們不是在面向物件程式設計,我們其實是在面向bug程式設計,首先假設bug可能會出現,然後在設計上、編碼上預防(或解決)這些可能出現的問題,預留足夠的開關以便在bug真的發生時可以隨時補救,設計足夠多的測試case並在線上迴圈執行,上報足夠的監控資料驗證系統執行的正確性,列印充分的日誌以便在故障發生時快速的定位問題,開發足夠的工具以提高我們定位、解決問題的效率。

  4. 重視暴露的每個小問題。每次曲線異常、每次報警觸發、每個case fail、每個使用者反饋,每個小問題的背後都可能是隱藏著的大風險,重視每個出現的小問題,深究下去,直到系統變得更穩健。