日消息量突破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、每個用戶反饋,每個小問題的背後都可能是隱藏着的大風險,重視每個出現的小問題,深究下去,直到系統變得更穩健。

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