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

騰訊雲開發者發表於2023-02-09

圖片

導語|騰訊工程師許揚從 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與演算法運維|工程師文化

相關文章