國民應用QQ如何實現高可用的訂閱推送系統

圖片

導語|騰訊工程師許揚從 QQ 提醒實際業務場景出發,闡述一個訂閱推送系統的技術要點和實現思路。如何通過推拉結合、異構存儲、多重觸發、可控調度、打散執行、可靠推送等技術,實現推送可靠性、推送可控性和推送高效性?本篇爲你詳細解答。

目錄

1 業務背景與訴求

1.1 業務背景

1.2 技術訴求

2 實現方案

2.1 推拉結合

2.2 異構存儲

2.3 多重觸發

2.4 可控溫度

2.5 打散執行

2.6 引入消息隊列

2.7 At least once推送

2.8 容災方案

3 總結

01業務背景與訴求

1.1 業務背景

QQ服務了大量的移動互聯網用戶。**作爲一個超大流量的平臺,其訂閱提醒功能無論對於用戶還是業務方而言,都發揮着至關重要的作用。**QQ提醒的業務場景非常多樣,舉個例子,《使命與召喚》手遊在某日早上 10 點發布, QQ則提醒預約用戶下載並領取禮包;春節刷一刷領紅包在小年當天晚上8點05分開始, QQ 則提醒訂閱用戶參與。

QQ提醒整體業務實現流程是:

  • 業務方在管理端建立推送任務;

  • 用戶在終端訂閱推送任務;

  • 預設時間到時,通過消息服務給所有訂閱的用戶推送消息。

1.2 技術訴求

不難看出,這是一個通過預設時間觸發的訂閱推送系統, QQ 團隊期望它能達到的技術要點涉及 3 個方面。

  • 推送可靠性:任何業務方在系統上配置的任務,都應該得到觸發;任何訂閱了提醒任務的用戶,都應該收到推送消息。

  • 推送可控性:消息服務的容量是有上限的,系統的總體消息推送速率不能超過該上限。而業務投放的任務卻有一定隨機性,可能某一時刻沒有任務,可能某一時刻多個任務同時觸發。所以系統必須在總體上做速率把控,避免推送過快導致下游處理失敗,影響業務體驗。如果造成下游消息服務雪崩,後果不堪設想。

  • 推送高效性:QQ 團隊規劃提高系統的推送速度,以滿足業務的更高時效性的要求。實際上, QQ 團隊的業務場景下做高併發是相對簡單的,而做到高可靠和可控反而較複雜。話不多說,下面談談 QQ 團隊如何實現這些技術要點。

02實現方案

以下是整體架構圖,供各位讀者進行宏觀瞭解。接下來講8個重點實現思路。

圖片

2.1 推拉結合

首先給各位讀者拋出一個疑問:提醒推送系統一定要通過推送來下發提醒嗎?答案是否定的。既然推送的內容是固定的,那麼 QQ 團隊可以提前將任務數據下發到客戶端,讓客戶端自行計時觸發提醒。這類似於配置下發系統。

但如果採用類似於配置預下發的方式,就涉及到一個問題:提前多久下發呢?提前太久,如果下發後任務需要修改怎麼辦?對於 QQ 業務而言,這是很常見的問題。比如一個遊戲原定時間發佈不了(這也被稱爲跳票),需要修改到一個月後或者更久觸發提醒。這個修改如果沒有被客戶端拉取到,那麼客戶端就會在原定時間觸發提醒。尤其是 IOS 客戶端本地,採用系統級別 localnotification 觸發提醒,無法阻止。這最後必然導致用戶投訴,業務方口碑受損。

消息推送模式主要分爲拉取和推送兩種,通過組合可以形成如下表呈現的幾種模式。各種模式各有優劣,需要根據具體業務場景進行考量。

圖片

經過權衡, QQ 團隊採取圖示混合模式——推拉結合。即允許部分用戶提前拉取到任務,未拉取的走推送。這個預下發的提前量是提醒當天 0 點開始。因此 QQ 團隊也強制要求業務方不能在提醒當天再修改任務信息,包括提醒時間和提醒內容。因爲當天0點之後用戶就開始拉取,所以必須保證任務時間和內容不變。

2.2 異構存儲

系統主要會有兩部分數據:

  • 業務方創建的任務數據。包含任務的提醒時間和提醒內容;

  • 用戶訂閱生成的訂閱數據。主要是訂閱用戶 uin 列表數據,這個列表元素級別可達到千萬以上,並且必須要能夠快速讀取。

該項目存儲選型主要從訪問速度上考慮。任務數據可靠性要求高,不需要快速存取,使用MySQL即可。訂閱列表數據需要頻繁讀寫,且推送觸發時對於存取效率要求較高,考慮使用內存型數據庫。

圖片

最終QQ團隊採用的是 Redis 的 set 類型來存儲訂閱列表,有以下好處

  • Redis 單線程模型,有效避免讀寫衝突;

  • set 底層基於 intset 和 hash 表實現,存儲整型 uin 在空間和時間上均高效;

  • 原生支持去重;

  • 原生支持高效的批量取接口(spop),適合於推送時使用。

2.3 多重觸發

再問各位讀者一個問題,**計時服務一般是怎麼做的?**分佈式計時任務有很多成熟的實現方案,一般是採用延遲隊列來實現,比如 Redis sorted set 或者利用 RabbitMQ 死信隊列。QQ 團隊使用的移動端 QQ 通用計時器組件,即是基於Redis sorted set 實現。

爲了保證任務能夠被可靠觸發, QQ 團隊又增加了本地數據庫輪詢。假如外部組件通用計時器沒有準時回調 QQ 團隊,本地輪詢會在延遲3秒後將還未觸發的任務進行觸發。這主要是爲了防止外部組件可能的故障導致業務觸發失敗,增加一個本地的掃描查漏補缺。值得注意的是,引入這樣的機制可能會帶來任務多次觸發的可能(例如本地掃描觸發了,同一時間計時器也恢復),這就需要 QQ 團隊保證任務觸發的冪等性(即多次觸發最終效果一致,不會重複推送)。觸發流程如下:

圖片

2.4 可控調度

如前所述,當多個千萬級別的推送任務在同一時間觸發時,推送量是很可觀的,系統需要具備總體的任務間調度控制能力。因此需要引入調度器,由調度器來控制每一秒鐘的推送量。調度器必須是分佈式,以避免單點服務。因此這是一個分佈式限頻的問題。

這裏 QQ 團隊簡單用 Redis INCR 命令計數。記錄當前秒鐘的請求量,所有調度器都嘗試將當前任務需要下發的量累加到這個值上。如果累加的結果沒有超過配置值,則繼續累加。最後超過配置值時,每個調度器按照自己搶到的下發量進行下發。簡單點說就是下發任務前先搶額度,搶到額度再下發。當額度用完或者沒有搶到額度,則等待下一秒。僞代碼如下:

CREATE TABLE table_xxx(
    ds BIGINT COMMENT '數據日期',
    label_name STRING COMMENT '標籤名稱',
    label_id BIGINT COMMENT '標籤id',
    appid STRING COMMENT '小程序appid',
    useruin BIGINT COMMENT 'useruin',
    tag_name STRING COMMENT 'tag名稱',
    tag_id BIGINT COMMENT 'tag id',
    tag_value BIGINT COMMENT 'tag權重值'
)
PARTITION BY LIST( ds )
SUBPARTITION BY LIST( label_name )(
    SUBPARTITION sp_xxx VALUES IN ( 'xxx' ),
    SUBPARTITION sp_xxxx VALUES IN ( 'xxxx' )
)

調度流程如下:

圖片

值得關注的是,冪等性如何保證呢?講完了調度的實現,再來論證下冪等性是否成立。

假設第一種情況,調度器執行一半掛了,後面又再次對同一個任務進行調度。由於調度器每次對一個任務進行調度時,都會先查看任務當前剩餘推送量(即任務還剩多少塊),根據任務的剩餘塊數來繼續調度。所以,當任務再次觸發時,調度器可以接着前面的任務繼續完成。

假設第二種情況,一個任務被同時觸發兩次,由兩個調度器同時進行調度,那麼兩個調度器會互相搶額度,搶到後用在同一個任務。從執行效果來看,和一個調度器沒有差別。因此,任務可以被重複觸發。

2.5 打散執行

任務分塊執行的必要性在於:將任務打散分成小任務了,才能實現細粒度的調度。否則,幾個 1000w 級別的任務,各位開發者如何調度?假如將所有任務都拆分成 5000 量級的小任務塊,那麼速率控制就轉化成分發小任務塊的塊數控制。假設配置的總體速率是3w uin/s,那麼調度器每一秒最多可以下發 6 個任務塊。這 6 個任務塊可以是多個任務的。如下圖所示:

圖片

任務分塊執行還有其他好處。將任務分成多塊均衡分配給後端的worker去執行,可以提高推送的併發量,同時減少後端worker異常的影響粒度。

那麼有開發者會問到:如何分塊呢?具體實現時調度器負責按配置值下發指令,指令類似到某個任務的列表上取一個任務塊,任務塊大小 5000 個uin,並執行下發。後端的推送器worker收到指令後,便到指定的任務訂閱列表上(redis set實現),通過 spop 獲取到 5000 個 uin ,執行推送。

2.6 引入消息隊列

一般來說,消息隊列的意義主要是削峯填谷、異步解耦。對本項目而言,引入消息隊列有以下好處:

  • 將任務調度和任務執行解耦(調度服務並不需要關心任務執行結果);

  • 異步化,保證調度服務的高效執行,調度服務的執行是以 ms 爲單位;

  • 藉助消息隊列實現任務的可靠消費( At least once );

  • 將瞬時高併發的任務量打散執行,達到削峯的作用。

圖片

具體的實現方式上,採用隊列模型,調度器在進行上文所述的任務分塊後,將每一塊子任務寫入到消息隊列中,由推送器節點進行競爭消費。

2.7 At least once推送

實現用戶級別的可靠性,即要保證所有訂閱用戶都被至少推送一次(At least once)。如何做到這一點呢?前提是當把用戶 uin 從訂閱列表中取出進行推送後,在推送結果返回之前,必須保證用戶 uin 被妥善保存,以防止推送失敗後沒有機會再推送。由於 Redis 沒有提供從一個 set 中批量 move 數據到另一個set中,這裏採取的做法是通過 redis lua 腳本來保證這個操作的原子性,具體 lua 代碼如下(近似):

redis.replicate_commands()
local set_key, task_key = KEYS [1], KEYS [2]
local num = tonumber(ARGV [1])
local array
array = redis.call('SPOP', set_key, num)
if #array > 0 then
    redis.call("SADD", task_key, unpack(array))
end
return redis.call('scard', task_key)

推送流程整體如下

圖片

2.8 容災方案

訂閱推送系統最重要的是保證推送的可靠性。用戶的訂閱數據對於系統來說是重中之重。因此,業務團隊採用了異構的存儲來保證數據的可靠性。每一個用戶訂閱事件,都會在 CKV (騰訊自主研發的 KV 型數據庫)中記錄,並將用戶 uin 添加到 Redis 中的訂閱集合。在任一系統發生故障時,可以從任意一份數據中恢復出另一份數據,形成互備。同時, Redis 存儲也使用了騰訊雲的Redis集羣架構。採用了 2 副本、3 分片的模型,以進一步提高可靠性。

圖片

03總結

上文論述瞭如何在高併發的基礎上實現可控和可靠的任務推送。這個方案可以總結爲 Dispatcher+Worker 模型,其核心思想是分治思想,類似於在一條快遞流水線上先將大包裹化整爲零,分割成標準的小件,再分發給流水線上的衆多快遞員,執行標準化的配送服務。高性能大流量推送機制是騰訊QQ在真實業務高併發場景下沉澱的高效運營能力,在有效提升用戶活躍度與粘性方面效果顯著。

騰訊QQ團隊在服務內部各個業務條線的同時,也將這部分核心能力進行了抽象、解耦和沉澱,可以作爲通用能力服務於各個行業及B端業務。相關技術服務信息,在騰訊移動開發平臺(TMF)可以獲取。以上便是整個QQ提醒訂閱推送系統的實現思路和方案。歡迎各位讀者在評論區分享交流。

-End-

原創作者|許揚
技術責編|許揚

你可能感興趣的騰訊工程師作品

算法工程師深度解構ChatGPT技術

騰訊雲開發者2022年度熱文盤點

| 3小時!開發ChatGPT微信小程序

7天DAU超億級,《羊了個羊》技術架構升級實戰

技術盲盒:前端後端AI與算法運維|工程師文化

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