喜馬拉雅億級用戶量的離線消息推送系統架構設計實踐

本文由喜馬拉雅技術團隊李乾坤原創,原題《推送系統實踐》,感謝做者的無私分享。html

一、引言

1.1 什麼是離線消息推送

對於IM的開發者來講,離線消息推送是再熟悉不過的需求了,好比下圖就是典型的IM離線消息通知效果。java

1.2 Andriod端離線推送真心不易

移動端離線消息推送涉及的端無非就是兩個——iOS端和Andriod端,iOS端沒什麼好說的,APNs是惟一選項。mysql

Andriod端比較奇葩(主要指國內的手機),爲了實現離線推送,各類保活黑科技層出不窮,隨着保活難度的不斷升級,可使用的保活手段也是愈來愈少,有興趣能夠讀一讀我整理的下面這些文章,感覺一下(文章是按時間順序,隨着Andriod系統保活難度的提高,不斷進階的)。git

上面這幾篇只是我整理的這方面的文章中的一部分,特別注意這最後一篇《Android保活從入門到放棄:乖乖引導用戶加白名單吧(附7大機型加白示例)》。是的,當前Andriod系統對APP自已保活的容忍度幾乎爲0,因此那些曾今的保活手段在新版本系統裏,幾乎通通都失效了。github

自已作保活已經沒戲了,保離線消息推送總歸是還得作。怎麼辦?按照現時的最佳實踐,那就是對接種手機廠商的ROOM級推送通道。具體我就不在這裏展開,有興趣的地能夠詳讀《Android P正式版即將到來:後臺應用保活、消息推送的真正噩夢》。web

自已作保活、自建推送通道的時代(這裏固然指的是Andriod端啦),離線消息推送這種系統的架構設計相對簡單,無非就是每臺終端計算出一個deviceID,服務端經過自建通道進行消息透傳,就這麼點事。redis

而在自建通道死翹翹,只能依賴廠商推送通道的現在,小米華爲魅族OPPOvivo(這只是主流的幾家)等等,手機型號太多,各家的推送API、設計規範各不相同(別跟我提什麼統一推送聯盟,那玩意兒我等他3年了——詳見《萬衆矚目的「統一推送聯盟」上場了》),這也直接致使先前的離線消息推送系統架構設計必須從新設計,以適應新時代的推送技術要求。spring

1.3 怎麼設計合理呢

那麼,針對不一樣廠商的ROOM級推送通道,咱們的後臺推送架構到底該怎麼設計合理呢?sql

本文分享的離線消息推送系統設計並不是專門針對IM產品,但不管業務層的差異有多少,大體的技術思路上都是相通的,但願借喜馬拉雅的這篇分享能給正在設計大用戶量的離線消息推送的你帶來些許啓發。api

*** 推薦閱讀:**喜馬拉雅技術團隊分享的另外一篇《長鏈接網關技術專題(五):喜馬拉雅自研億級API網關技術實踐》,有興趣也能夠一併閱讀。

二、技術背景

首先介紹下在喜馬拉雅APP中推送系統的做用,以下圖就是一個新聞業務的推送/通知。

離線推送主要就是在用戶不打開APP的時候有一個手段觸達用戶,保持APP的存在感,提升APP的日活。

咱們目前主要用推送的業務包括:

  • 1)主播開播:公司有直播業務,主播在開直播的時候會給這個主播的全部粉絲髮一個推送開播提醒
  • 2)專輯更新:平臺上有很是多的專輯,專輯下面是一系列具體的聲音,好比一本兒小說是一個專輯,小說有不少章節,那麼當小說更新章節的時候給全部訂閱這個專輯的用戶發一個更新的提醒:
  • 3)個性化、新聞業務等。

既然想給一個用戶發離線推送,系統就要跟這個用戶設備之間有一個聯繫的通道。

**作過這個的都知道:**自建推送通道須要App常駐後臺(就是引言裏提到的應用「保活」),而手機廠商由於省電等緣由廣泛採起「激進」的後臺進程管理策略,致使自建通道質量較差。目前通道通常是由「推送服務商」去維護,也就是說公司內的推送系統並不直接給用戶發推送(就是上節內容的這篇裏提到的狀況:《Android P正式版即將到來:後臺應用保活、消息推送的真正噩夢》)。

這種狀況下的離線推送流轉流程以下:

國內的幾大廠商(小米華爲魅族OPPOvivo等)都有本身官方的推送通道,可是每一家接口都不同,因此一些廠商好比小米、個推提供集成接口。發送時推送系統發給集成商,而後集成商根據具體的設備,發給具體的廠商推送通道,最終發給用戶。

給設備發推送的時候,必須說清楚你要發的是什麼內容:即title、message/body,還要指定給哪一個設備發推送。

咱們以token來標識一個設備, 在不一樣的場景下token的含義是不同的,公司內部通常用uid或者deviceId標識一個設備,對於集成商、不一樣的廠商也有本身對設備的惟一「編號」,因此公司內部的推送服務,要負責進行uid、deviceId到集成商token 的轉換。

三、總體架構設計

如上圖所示,推送系統總體上是一個基於隊列的流式處理系統。

**上圖右側:**是主鏈路,各個業務方經過推送接口給推送系統發推送,推送接口會把數據發到一個隊列,由轉換和過濾服務消費。轉換就是上文說的uid/deviceId到token的轉換,過濾下文專門講,轉換過濾處理後發給發送模塊,最終給到集成商接口。

**App 啓動時:**會向服務端發送綁定請求,上報uid/deviceId與token的綁定關係。當卸載/重裝App等致使token失效時,集成商經過http回調告知推送系統。各個組件都會經過kafka 發送流水到公司的xstream 實時流處理集羣,聚合數據並落盤到mysql,最終由grafana提供各類報表展現。

四、業務過濾機制設計

各個業務方能夠無腦給用戶發推送,但推送系統要有節制,所以要對業務消息有選擇的過濾。

過濾機制的設計包括如下幾點(按支持的前後順序):

  • 1)用戶開關:App支持配置用戶開關,若用戶關閉了推送,則不向用戶設備發推送;
  • 2)文案排重:一個用戶不能收到重複的文案,用於防止上游業務方發送邏輯出錯;
  • 3)頻率控制:每個業務對應一個msg_type,設定xx時間內最多發xx條推送;
  • 4)靜默時間:天天xx點到xx點不給用戶發推送,以避免打擾用戶休息。
  • 5)分級管理:從用戶和消息兩維度進行分級控制。

針對第5點,具體來講就是:

  • 1)每個msg/msg_type有一個level,給重要/高level業務更多發送機會;
  • 2)當用戶一天收到xx條推送時,不是重要的消息就再也不發給這些用戶。

五、分庫分表下的多維查詢問題

不少時候,設計都是基於理論和經驗,但實操時,總會遇到各類具體的問題。

喜馬拉雅如今已經有6億+用戶,對應的推送系統的設備表(記錄uid/deviceId到token的映射)也有相似的量級,因此對設備表進行了分庫分表,以deviceId爲分表列。

**但實際上:**常常有根據uid/token的查詢需求,所以還須要創建以uid/token到deviceId的映射關係。由於uid 查詢的場景也很頻繁,所以uid副表也擁有和主表一樣的字段。

由於天天會進行一兩次全局推,且針對沉默用戶(即不常使用APP的用戶)也有專門的推送,存儲方面實際上不存在「熱點」,雖然使用了緩存,但做用頗有限,且佔用空間巨大。

多分表以及緩存致使數據存在三四個副本,不一樣邏輯使用不一樣副本,常常出現不一致問題(追求一致則影響性能), 查詢代碼很是複雜且性能較低。

最終咱們選擇了將設備數據存儲在tidb上,在性可以用的前提下,大大簡化了代碼。

六、特殊業務的時效性問題

6.1 基本概念

推送系統是基於隊列的,「先到先推」。大部分業務不要求很高的實時性,但直播業務要求半個小時送達,新聞業務更是「慾求不滿」,越快越好。

**若進行新聞推送時:**隊列中有巨量的「專輯更新」推送等待處理,則專輯更新業務會嚴重干擾新聞業務的送達。

6.2 這是隔離問題?

**一開始咱們認爲這是一個隔離問題:**好比10個消費節點,3個專門負責高時效性業務、7個節點負責通常業務。當時隊列用的是rabbitmq,爲此改造了 spring-rabbit 支持根據msytype將消息路由到特定節點。

該方案有如下缺點:

  • 1)總有一些機器很忙的時候,另外一些機器在「袖手旁觀」;
  • 2)新增業務時,須要額外配置msgType到消費節點的映射關係,維護成本較高;
  • 3)rabbitmq基於內存實現,推送瞬時高峯時佔用內存較大,進而引起rabbitmq 不穩定。

6.3 實際上是個優先級問題

後來咱們覺察到這是一個優先級問題:高優先級業務/消息能夠插隊,因而封裝kafka支持優先級,比較好的解決了隔離性方案帶來的問題。具體實現是創建多個topic,一個topic表明一個優先級,封裝kafka主要是封裝消費端的邏輯(即構造一個PriorityConsumer)。

**備註:**爲描述簡單,本文使用consumer.poll(num)來描述使用consumer拉取num個消息,與真實 kafka api 不一致,請知悉。

PriorityConsumer實現有三種方案,如下分別闡述。

1)poll到內存後從新排序:

java 有現成的基於內存的優先級隊列PriorityQueue 或PriorityBlockingQueue,kafka consumer 正常消費,並將poll 到的數據從新push到優先級隊列。

  • 1.1)若是使用有界隊列,隊列打滿後,後面的消息優先級再高也put 不進去,失去「插隊」效果;
  • 1.2)若是使用無界隊列,原本應堆在kafka上的消息都會堆到內存裏,OOM的風險很大。

2)先拉取高優先級topic的數據:

只要有就一直消費,直到沒有數據再消費低一級topic。消費低一級topic的過程當中,若是發現有高一級topic消息到來,則轉向消費高優先級消息。

該方案實現較爲複雜,且在晚高峯等推送密集的時間段,可能會致使低優先級業務徹底失去推送機會。

3)優先級從高到低,循環拉取數據:

一次循環的邏輯爲:

consumer-1.poll(topic1-num);

cosumer-i.poll(topic-i-num);

consumer-max.priority.poll(topic-max.priority-num)

若是topic1-num=topic-i-num=topic-max.priority-num,則該方案是沒有優先級效果的。topic1-num 能夠視爲權重,咱們約定:topic-高-num=2 * topic-低-num,同一時刻全部topic 都會被消費,經過一次消費數量的多少來變相實現「插隊效果」。具體細節上還借鑑了「滑動窗口」策略來優化某個優先級的topic 長期沒有消息時總的消費性能。

從中咱們能夠看到,時效問題先是被理解爲一個隔離問題,後被視爲優先級問題,最終轉化爲了一個權重問題。

七、過濾機制的存儲和性能問題

在咱們的架構中,影響推送發送速度的主要就是tidb查詢和過濾邏輯,過濾機制又分爲存儲和性能兩個問題。

這裏咱們以xx業務頻控限制「一個小時最多發送一條」爲例來進行分析。

**初版實現時:**redis kv 結構爲 <deviceId_msgtype,已發送推送數量>。

頻控實現邏輯爲:

  • 1)發送時,incr key,發送次數加1;
  • 2)若是超限(incr命令返回值>發送次數上限),則不推送;
  • 3)若未超限且返回值爲1,說明在msgtype頻控週期內第一次向該deviceId發消息,需expire key設置過時時間(等於頻控週期)。

上述方案有如下缺點:

  • 1)目前公司有60+推送業務, 6億+ deviceId,一共6億*60個key ,佔用空間巨大;
  • 2)不少時候,處理一個deviceId須要2條指令:incr+expire。

爲此,咱們的解決方法是:

  • 1)使用pika(基於磁盤的redis)替換redis,磁盤空間能夠知足存儲需求;
  • 2)委託系統架構組擴充了redis協議,支持新結構ehash。

ehash基於redis hash修改,是一個兩級map <key,field,value>,除了key 能夠設置有效期外,field也能夠支持有效期,且支持有條件的設置有效期。

頻控數據的存儲結構由<deviceId_msgtype,value>變爲<deviceId,msgtype,value>,這樣對於多個msgtype,deviceId只存一次,節省了佔用空間。

incr 和 expire 合併爲1條指令:incr(key,filed,expire),減小了一次網絡通訊:

  • 1)當field未設置有效期時,則爲其設置有效期;
  • 2)當field還未過時時,則忽略有效期參數。

由於推送系統重度使用incr指令,能夠視爲一條寫指令,大部分場景還用了pipeline來實現批量寫的效果,咱們委託系統架構組小夥伴專門優化了pika 的寫入性能,支持「寫模式」(優化了寫場景下的相關參數),qps達到10w以上。

ehash結構在流水記錄時也發揮了重要做用,好比<deviceId,msgId,100001002>,其中100001002是咱們約定的一個數據格式示例值,前中後三個部分(每一個部分佔3位)分別表示了某個消息(msgId)針對deviceId的發送、接收和點擊詳情,好比頭3位「100」表示因發送時處於靜默時間段因此發送失敗。(本文同步發佈於:www.52im.net/thread-3621…

相關文章
相關標籤/搜索