本文由喜馬拉雅技術團隊李乾坤原創,原題《推送系統實踐》,感謝作者的無私分享。
1、引言
1.1 什么是離線消息推送
對於IM的開發者來說,離線消息推送是再熟悉不過的需求了,比如下圖就是典型的IM離線消息通知效果。

1.2 Andriod端離線推送真心不易
移動端離線消息推送涉及的端無非就是兩個——iOS端和Andriod端,iOS端沒什么好說的,APNs是唯一選項。
Andriod端比較奇葩(主要指國內的手機),為了實現離線推送,各種保活黑科技層出不窮,隨着保活難度的不斷升級,可以使用的保活手段也是越來越少,有興趣可以讀一讀我整理的下面這些文章,感受一下(文章是按時間順序,隨着Andriod系統保活難度的提升,不斷進階的)。
- 《應用保活終極總結(一):Android6.0以下的雙進程守護保活實踐》
- 《應用保活終極總結(二):Android6.0及以上的保活實踐(進程防殺篇)》
- 《應用保活終極總結(三):Android6.0及以上的保活實踐(被殺復活篇)》
- 《Android P正式版即將到來:后台應用保活、消息推送的真正噩夢》
- 《全面盤點當前Android后台保活方案的真實運行效果(截止2019年前)》
- 《2020年了,Android后台保活還有戲嗎?看我如何優雅的實現!》
- 《史上最強Android保活思路:深入剖析騰訊TIM的進程永生技術》
- 《Android進程永生技術終極揭密:進程被殺底層原理、APP應對被殺技巧》
- 《Android保活從入門到放棄:乖乖引導用戶加白名單吧(附7大機型加白示例)》
上面這幾篇只是我整理的這方面的文章中的一部分,特別注意這最后一篇《Android保活從入門到放棄:乖乖引導用戶加白名單吧(附7大機型加白示例)》。是的,當前Andriod系統對APP自已保活的容忍度幾乎為0,所以那些曾今的保活手段在新版本系統里,幾乎統統都失效了。
自已做保活已經沒戲了,保離線消息推送總歸是還得做。怎么辦?按照現時的最佳實踐,那就是對接種手機廠商的ROOM級推送通道。具體我就不在這里展開,有興趣的地可以詳讀《Android P正式版即將到來:后台應用保活、消息推送的真正噩夢》。
自已做保活、自建推送通道的時代(這里當然指的是Andriod端啦),離線消息推送這種系統的架構設計相對簡單,無非就是每台終端計算出一個deviceID,服務端通過自建通道進行消息透傳,就這么點事。
而在自建通道死翹翹,只能依賴廠商推送通道的如今,小米、華為、魅族、OPPO、vivo(這只是主流的幾家)等等,手機型號太多,各家的推送API、設計規范各不相同(別跟我提什么統一推送聯盟,那玩意兒我等他3年了——詳見《萬眾矚目的“統一推送聯盟”上場了》),這也直接導致先前的離線消息推送系統架構設計必須重新設計,以適應新時代的推送技術要求。
1.3 怎么設計合理呢
那么,針對不同廠商的ROOM級推送通道,我們的后台推送架構到底該怎么設計合理呢?
本文分享的離線消息推送系統設計並非專門針對IM產品,但無論業務層的差別有多少,大致的技術思路上都是相通的,希望借喜馬拉雅的這篇分享能給正在設計大用戶量的離線消息推送的你帶來些許啟發。
* 推薦閱讀:喜馬拉雅技術團隊分享的另一篇《長連接網關技術專題(五):喜馬拉雅自研億級API網關技術實踐》,有興趣也可以一並閱讀。
學習交流:
- 即時通訊/推送技術開發交流5群:215477170 [推薦]
- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
- 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK
(本文同步發布於:http://www.52im.net/thread-3621-1-1.html)
2、技術背景
首先介紹下在喜馬拉雅APP中推送系統的作用,如下圖就是一個新聞業務的推送/通知。

離線推送主要就是在用戶不打開APP的時候有一個手段觸達用戶,保持APP的存在感,提高APP的日活。
我們目前主要用推送的業務包括:
- 1)主播開播:公司有直播業務,主播在開直播的時候會給這個主播的所有粉絲發一個推送開播提醒
- 2)專輯更新:平台上有非常多的專輯,專輯下面是一系列具體的聲音,比如一本兒小說是一個專輯,小說有很多章節,那么當小說更新章節的時候給所有訂閱這個專輯的用戶發一個更新的提醒:
- 3)個性化、新聞業務等。
既然想給一個用戶發離線推送,系統就要跟這個用戶設備之間有一個聯系的通道。
做過這個的都知道:自建推送通道需要App常駐后台(就是引言里提到的應用“保活”),而手機廠商因為省電等原因普遍采取“激進”的后台進程管理策略,導致自建通道質量較差。目前通道一般是由“推送服務商”去維護,也就是說公司內的推送系統並不直接給用戶發推送(就是上節內容的這篇里提到的情況:《Android P正式版即將到來:后台應用保活、消息推送的真正噩夢》)。
這種情況下的離線推送流轉流程如下:

國內的幾大廠商(小米、華為、魅族、OPPO、vivo等)都有自己官方的推送通道,但是每一家接口都不一樣,所以一些廠商比如小米、個推提供集成接口。發送時推送系統發給集成商,然后集成商根據具體的設備,發給具體的廠商推送通道,最終發給用戶。
給設備發推送的時候,必須說清楚你要發的是什么內容:即title、message/body,還要指定給哪個設備發推送。
我們以token來標識一個設備, 在不同的場景下token的含義是不一樣的,公司內部一般用uid或者deviceId標識一個設備,對於集成商、不同的廠商也有自己對設備的唯一“編號”,所以公司內部的推送服務,要負責進行uid、deviceId到集成商token 的轉換。
3、整體架構設計

如上圖所示,推送系統整體上是一個基於隊列的流式處理系統。
上圖右側:是主鏈路,各個業務方通過推送接口給推送系統發推送,推送接口會把數據發到一個隊列,由轉換和過濾服務消費。轉換就是上文說的uid/deviceId到token的轉換,過濾下文專門講,轉換過濾處理后發給發送模塊,最終給到集成商接口。
App 啟動時:會向服務端發送綁定請求,上報uid/deviceId與token的綁定關系。當卸載/重裝App等導致token失效時,集成商通過http回調告知推送系統。各個組件都會通過kafka 發送流水到公司的xstream 實時流處理集群,聚合數據並落盤到mysql,最終由grafana提供各種報表展示。
4、業務過濾機制設計
各個業務方可以無腦給用戶發推送,但推送系統要有節制,因此要對業務消息有選擇的過濾。
過濾機制的設計包括以下幾點(按支持的先后順序):
- 1)用戶開關:App支持配置用戶開關,若用戶關閉了推送,則不向用戶設備發推送;
- 2)文案排重:一個用戶不能收到重復的文案,用於防止上游業務方發送邏輯出錯;
- 3)頻率控制:每一個業務對應一個msg_type,設定xx時間內最多發xx條推送;
- 4)靜默時間:每天xx點到xx點不給用戶發推送,以免打擾用戶休息。
- 5)分級管理:從用戶和消息兩維度進行分級控制。
針對第5點,具體來說就是:
- 1)每一個msg/msg_type有一個level,給重要/高level業務更多發送機會;
- 2)當用戶一天收到xx條推送時,不是重要的消息就不再發給這些用戶。
5、分庫分表下的多維查詢問題
很多時候,設計都是基於理論和經驗,但實操時,總會遇到各種具體的問題。
喜馬拉雅現在已經有6億+用戶,對應的推送系統的設備表(記錄uid/deviceId到token的映射)也有類似的量級,所以對設備表進行了分庫分表,以 deviceId 為分表列。

但實際上:經常有根據 uid/token 的查詢需求,因此還需要建立以 uid/token 到 deviceId 的映射關系。因為uid 查詢的場景也很頻繁,因此uid副表也擁有和主表同樣的字段。

因為每天會進行一兩次全局推,且針對沉默用戶(即不常使用APP的用戶)也有專門的推送,存儲方面實際上不存在“熱點”,雖然使用了緩存,但作用很有限,且占用空間巨大。
多分表以及緩存導致數據存在三四個副本,不同邏輯使用不同副本,經常出現不一致問題(追求一致則影響性能), 查詢代碼非常復雜且性能較低。
最終我們選擇了將設備數據存儲在tidb上,在性能夠用的前提下,大大簡化了代碼。

6、特殊業務的時效性問題
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 長期沒有消息時總的消費性能。
從中我們可以看到,時效問題先是被理解為一個隔離問題,后被視為優先級問題,最終轉化為了一個權重問題。
7、過濾機制的存儲和性能問題
在我們的架構中,影響推送發送速度的主要就是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”表示因發送時處於靜默時間段所以發送失敗。
附錄:更多消息推送技術文章
《iOS的推送服務APNs詳解:設計思路、技術原理及缺陷等》
《信鴿團隊原創:一起走過 iOS10 上消息推送(APNS)的坑》
《Android端消息推送總結:實現原理、心跳保活、遇到的問題等》
《一個基於MQTT通信協議的完整Android推送Demo》
《求教android消息推送:GCM、XMPP、MQTT三種方案的優劣》
《掃盲貼:淺談iOS和Android后台實時消息推送的原理和區別》
《移動端IM實踐:谷歌消息推送服務(GCM)研究(來自微信)》
《從HTTP到MQTT:一個基於位置服務的APP數據通信實踐概述》
《基於WebSocket實現Hybrid移動應用的消息推送實踐(含代碼示例)》
《Go語言構建千萬級在線的高並發消息推送系統實踐(來自360公司)》
《了解iOS消息推送一文就夠:史上最全iOS Push技術詳解》
《基於APNs最新HTTP/2接口實現iOS的高性能消息推送(服務端篇)》
《長連接網關技術專題(四):愛奇藝WebSocket實時推送網關技術實踐》
>> 更多同類文章 ……
本文已同步發布於“即時通訊技術圈”公眾號。
▲ 本文在公眾號上的鏈接是:點此進入。同步發布鏈接是:http://www.52im.net/thread-3621-1-1.html