微信搜【 Java3y】關注這個有夢想的男人,點贊關注是對我最大的支持!文本已收錄至個人GitHub:https://github.com/ZhongFuCheng3y/3y,有300多篇原創文章,最近在連載面試和項目系列!java
我,三歪,最近要開始寫項目系列文章。我給這個系列取了一個名字,叫作《揭祕》git
沒錯,我又給本身挖了個坑。github
爲何想寫項目相關的文章呢?緣由有如下:面試
這個系列就以「消息管理平臺」來打個樣吧,這是我維護近一年的系統了。這篇文章能夠帶你全面認識「消息管理平臺」是怎麼設計和實現的,有興趣的同窗歡迎在評論區下留言和交流。sql
這篇文章可能稍微會有些許長,我是打算一篇就把該系統給講清楚。「消息管理平臺」原理並不難,沒有不少專業名詞,實現起來也不會複雜,你要是以爲學到了,歡迎給我點個贊👍json
「消息管理平臺」可能在不一樣的公司會有不一樣的叫法,有的時候我會叫它「推送系統」,有的時候我會叫它「消息管理平臺」,也有的同事叫它「觸達平臺」,甚至浮誇點我也能夠叫它「消息中臺」小程序
可是無論怎麼樣,它的功能就是給用戶發消息。在公司裏它是怎麼樣的定位?只要以官方名義發送的消息,都走消息管理平臺。微信小程序
通常你註冊一個APP/網站
,你能夠收到該APP/網站
給你發什麼消息呢?通常就如下吧?微信
好了,我相信你已經知道這個系統是用來幹嗎的了。那爲何要有這個系統呢?網絡
能夠說,只要是作APP的公司幾乎都會有消息管理平臺。
咱們不少時候都會想給用戶發消息:
那麼問題來了,發消息困難嗎?發消息複雜嗎?
顯然,發消息很是簡單,一點兒也不復雜。
發短信無非就是調用第三方短信的API、發郵件無非就是調用郵件的API、發微信類的消息(手Q/小程序/微信服務號)無非就是調用微信的API、發通知欄消息(Push)無非就是調APNS/手機廠商的API、發IM消息也可使用雲服務,調雲服務的API...
可能不少人的項目都是這麼幹的,無非發條消息,本身實現也不是不能夠。
但這樣會帶來的問題就是在一個公司內部,會有不少個項目都會有「發送消息」的代碼實現。假設發消息出了問題,還得去本身解決。
首先是系統很差維護,其次是不必。我一個搞廣告的,雖然我要發消息,憑什麼要我本身去實現?
咱們在寫代碼時,可能會把公用的代碼抽成方法,供當前的項目重複調用。若是該公用的代碼被多個項目使用,可能咱們又會抽成組件包,供多個項目使用。只要該公用的代碼被足夠多的人去用,那它就頗有可能從組件上升爲一個平臺(系統)級的東西。
回到消息管理平臺的本質,它就是一個能夠發消息的系統。那怎麼設計和實現呢?咱們從接口提及吧。
消息管理平臺是一個提供消息發送服務的平臺,若是讓我去實現,個人想法多是把每種類型的消息都寫一個接口,而後把這些接口對外暴露。
因此,可能會有如下的接口:
/** * content:發送的文案 * receiver:接收者 */ sendSms(String content,String receiver); sendIm(String content,String receiver); sendPush(String content,String receiver); sendEmail(String content,String receiver); sendTencent(String content,String receiver); //....
這樣實現好像也不是不能夠,反正每一個接口都挺清晰的,要發什麼類型的消息,你調用哪一個接口就行了。
假設咱們定義瞭如上的接口,如今咱們要發消息了,咱們會有如下的場景:
假如你是新手,你可能會想:這簡單,我每種類型分開兩個接口,分別是單發和批量接口。
sendSingleSms(); sendBatchSms(); //...
上面這樣設計有必要嗎?其實沒啥必要。我將接收人定義爲一個Array
不就得了?Array
的size==1
,那我就把該文案發給這我的,Array
的size>1
,那我就把這個文案發給Array
裏邊的全部人。
因此咱們的接口仍是隻有一個:
/** * content:發送的文案 * receiver:接收者(可多個,可單個) */ sendSms(String content,Set<String> receiver);
其實在咱們這也不是定義Array
,個人接口receiver
仍然是String
,若是有多個用,
號分隔就能夠了。
/** * content:發送的文案 * receiver:接收者(可多個,可單個),多個用逗號分隔開 */ sendSms(String content,String receiver);
如今還有個場景,不一樣的文案發給不一樣的人怎麼辦?有的人就說,這不已經實現了嗎?直接調用上面的接口就完事了啊。你又不是不能重複調用,好比說:
確實如此,原本就能夠這樣作的。但不夠好
舉個真實的場景:如今有一個主播開播了,得發送一條消息告訴訂閱該主播的人趕忙去看。爲了提升該條通知的效果 ,在文案上咱們是這樣設計的:{用戶暱稱},你訂閱的主播三歪已經開播了,趕忙去看吧!
這種消息咱們確定是要求實時性的(假設推送消息的速度太慢了,等到用戶收到消息了,主播都下播了,那用戶不得錘死你?)
畫外音:顯然這種狀況屬於 不一樣的文案發給不一樣的人
這種消息在業務層是怎麼作的呢?多是掃DB表,遍歷出訂閱該主播的粉絲,而後給他們推送消息。
那如今咱們只能每掃出一個訂閱該主播的粉絲,就得調用send()
接口發送消息。若是該主播有500W
的粉絲,那就得調用500W
次send
接口,這不是很可怕?這調用次數,這網絡開銷...
因而乎,咱們得提供一個「批量」接口,可讓調用方一次傳入不一樣文案所攜帶不一樣的人。那怎麼作呢?也很簡單,實際上就是上面接口再封裝一層,讓調用方能「批量」傳進來就行了。因此代碼能夠是這樣的:
/** * 一次傳入多個(文案以及發送者)的「組」進來 * List<SendParam> * SendParam 裏邊 定義了 content 和receiver */ sendBatchSms(List<SendParam> sendParam);
如今接口的「雛形」已經出現了,到這裏咱們實現了消息管理平臺最基本的功能:發消息
咱們先無論內部的實現是如何,假設咱們已經適配好調通好對應的API了,如今咱們的接口在發消息層面上已經有充分必要的條件了:只要你傳入接收者和發送內容
,我就能夠給你發消息。
但咱們對外稱但是一個平臺啊,怎麼能搞得像是隻封裝了幾個方法似的,平臺就該有平臺的樣子。
我舉個平常最最最基本的功能:有人調用了個人接口發了條短信,這條短信的文案是一條內容爲驗證碼類型,他問我這條短信到底下發到用戶手上了沒有。
若是接入太短信的同窗就會知道:發送短信到用戶收到是一個異步的過程
回到問題上,他想要他調用個人接口有沒有把短信發送成功,那我只要問他拿到手機號和文案,而後有如下步驟:
那目前咱們在現有的接口,仍是很完美地支持上面的問題的,對吧?只要咱們記錄了下發的結果和回執的信息,咱們就能夠告訴他所提供的手機號和文案究竟有沒有下發到用戶手上。
那今天他又過來問了:今天有不少人來反饋收不到驗證碼短信(不是所有人收不到,是大部分人),我想了解一下今天驗證碼短信下發的成功率是多少。
此時的我,只能去匹配(like %%
)他的文案調用個人接口下發了多少人,調用短信服務商的API下發成功多少人,收到的成功回執(結果)有多少人。
經過匹配文案的方式最終也是能夠告訴他結果的,可是這種是很傻X的作法。歸根到底仍是由於系統提供的服務仍是太薄弱了。
那怎麼解決上面所講的問題呢?其實也很簡單,匹配文案很傻X,那我給他這一批驗證碼的短信取個惟一的Id那不就能夠了嗎?
像咱們去接入短信服務商同樣,咱們須要去新建一個短信模板,這個模板表明了你要發送的內容,新建模板後會給你個模板Id,你下發的時候指定這個模板Id就行了。
那咱們的平臺也能夠這樣玩啊,你想發消息對吧?能夠,先來個人平臺新建一個」模板「,到時候把模板Id發給我就行。
因而,咱們就完美地解決上面所提到的問題了。
咱們如今再來討論一下有沒有必要不一樣的消息類型(短信、郵件、IM等)須要分開不一樣的的接口,實際上是不必的了。由於只要抽象了」模板「這個概念,消息類型天然咱們就能夠在模板上固化掉,只要傳了模板Id,我就知道你發的是什麼類型消息。
這樣一來,咱們最終會有兩個接口:批量與單個發送接口。
/** * 發送消息接口 * @author java3y */ public interface SendService { /** * 相同文案,發給0~N 人 * @param sendParam */ void send(SendParam sendParam); /** * 不一樣文案,發給不一樣人,一次可接收多組 * @param sendParam */ void batchSend(BatchSendParam sendParam); } public class SendParam { /** * 模板Id */ private String templateId; /** * 消息參數 */ private MsgParam msgParam; } public class MsgParam { /** * 接收者:假設有多個,則用「,」分隔開 */ private String receiver; /** * 自定義參數(文案) */ private Map<String, String> variables; }
單個接口指的是:一次給1~N
人發送消息,這批人收到的是相同的文案
批量接口指的是:一次給1我的發送一個文案,但一次調用能夠傳N我的及對應的文案
這裏的單個和批量不是以發送人的維度去定義的,而是人所對應的消息文案。
再再再舉個例子,如今我給關注個人同窗都發一條消息:「大哥大嫂新年好」,這種狀況我只須要使用send
方法就行了,相同的文案我給一批人發,這批人收到的文案是如出一轍的。
一次單推接口調用的請求參數:
{ "templateId": 12345, "msgParam": { "receivers": "三歪,敖丙,雞蛋,米豆", "variables": { "content": "大哥大哥新年好", "title": "來個贊吧,親" } } }
若是我要給關注個人同窗都發一條消息:「{微信用戶名},大哥大哥新年好」,這種狀況我通常用batchSend
方法,在發送以前組合人所對應的文案封裝成一個List
,一次調用接口對調用方而言就是一次發了List.size()
組人。
一次批量接口調用的請求參數:
{ "templateId": 12345, "msgParam": [ { "receivers": "敖丙", "variables": { "content": "敖丙,大哥大哥新年好", "title": "來個贊吧,親" } }, { "receivers": "雞蛋", "variables": { "content": "雞蛋,大哥大哥新年好", "title": "來個贊吧,親" } } ] }
沒想到單單接口這塊我這篇就寫了這麼長,主要是照顧沒有經驗的同窗哈~
回顧設計接口的思路:
在前面咱們已經定義好接口了,跟簡單大家所實現的發消息功能最主要的區別就是多了」模板「的概念。
在上面提到了一點:有了」模板「,能夠將不少信息固化到模板中。那咱們固化了什麼東西到模板中呢?
1
表示短信,2
表示郵件...json
的格式存儲在一個字段中。userId
,發通知欄消息(PUSH)用的是did
,發短信用的是手機號,發微信類的消息用的是openId
。指定接收者的Id類型,代表這個模板你要傳入哪一種類型的id
。假設你指明是userId
,但你要發短信,消息管理平臺就須要將userId
轉成手機號。這裏也是用一個字段標識,1
表示userId
,2
表示did
...能夠發現的是,咱們把一條消息所須要的信息(甚至不須要的信息)都塞進模板裏面了,等調用方傳入模板Id時,我就能拿到我想要的全部信息了。
這是一個模板的所有了嗎?固然不是咯。上面提到的是模板共性的內容,咱們按模板的使用場景還劃分兩種類型:
T+1
離線的)。例子:若是用戶註冊登陸了APP,能夠隔一天(甚至更長時間)給用戶發消息。這種屬於非實時(離線)推送,這種就不須要技術來承接,去圈選人羣后設置對應的時間便可推送。隨着系統和業務的演進,運營模板和技術模板的界限會愈來愈模糊。從本質上就是提供了兩種發消息的方式:
用戶在平臺建立模板時,不一樣類型的模板須要填寫的字段是不同的:運營模板須要填寫人羣和任務觸發時間,而技術模板壓根就不須要填人羣和任務觸發時間,因此咱們模板會有一個字段標識該模板是運營類型仍是技術類型。1
表示運營類型,2
表示技術類型...
你以爲已經完了嗎?nonono,尚未。咱們還會區分消息的類型,目前最主要由三類組成:通知、營銷和驗證碼。
問題來了,爲何咱們要區分消息的類型呢?作統計用嗎?固然不是了,就這幾個粒度的類型有什麼好統計的。
仍是以例子來講明吧:在2020-02-30
日,運營同窗圈選了一個5000W
的人羣選擇在晚上8點發送一條短信,大體的狀況就是告訴用戶三歪文章更新了,不看血虧。系統在晚上8點
準時執行任務,讀取該模板的模板信息下發。5000W
人,系統能秒發嗎?顯然是不行的
畫外音:除了考慮自身的系統能力,還得考慮下游能承受的能力。 你瞎搞,人家就不帶你玩了。
因此,這5000W
人確定是須要必定的時間才能徹底下發的,如今咱們假設是15分鐘
徹底下發完畢吧。在8點2分
觸發了一條驗證碼的短信,結果由於這個5000W
的人羣所致使驗證碼的消息延遲發送,這合理嗎?顯然不合理。
怎麼致使的?緣由是這5000W
的消息和驗證碼的消息走的是同一個通道,致使驗證碼的消息被阻塞掉了。咱們將不一樣的消息類型走不一樣的通道,就能夠解決掉上面的問題。
因此,咱們的系統在設計層面上就把運營模板默認設置爲營銷類型的消息,而技術模板的消息類型由調用者自行選擇。在現實場景中,能堵的就只有營銷類的消息。
畫外音:上面所講的這些實踐都是跟使用場景和具體業務所關聯的,確定不是一朝一夕就能夠全想出來的。
模板也已經聊完了,還有些細節的東西我這就不贅述了。我再來簡要總結一下:
BB了這麼久了,可能不少人只是想來看看:三歪這逼在標題還敢還寫個揭祕,發消息誰不會,不就調個API嘛,還能給你玩出花來?
別急嘛,如今就寫。前面已經鋪墊了接口的設計和模板到底是什麼了,如今咱們仍是回到接口的實現上吧。
首先咱們簡單來看看消息管理平臺的系統架構鏈路圖:
畫外音:上面咱們所說的接口定義在 統一調用層(接入層)中
調用者調用咱們的send/batchSend
方法,會直接調用下游的API下發消息嗎?不會
直接調用下游的API下發消息風險太大了,接口1W+QPS
都是很正常的事,因此咱們接收到消息後只是作簡單的參數校驗處理和信息補全就把消息發到消息隊列上。這樣作的好處就是接口接入層十分輕量級,只要Kafka抗得住,請求就沒問題。
發到消息隊列時,會根據不一樣的消息類型發到不一樣的topic
上,發送層監聽topic
進行消費就行了。架構大體以下:
發送層消費topic
後,會把消息放在各自的內存隊列上,多個線程消費內存隊列的消息來實現消息的下發。
能夠看到的是:從接入層發到消息隊列上咱們就已經作了分topic
來實現業務上的隔離,在消費時咱們也是放到各自的內存隊列中來進行消費。這就實現了:不一樣渠道和同渠道的不一樣類型的消息都互不干擾。
看到上面這張圖,若是思考過的同窗確定會問:這要內存隊列幹啥啊?反正你在上層已經分了topic
了,不用內存隊列也能夠實現你所講的「業務隔離」啊。
也的確,這裏使用內存隊列的主要緣由是爲了提升併發度。提升了併發度,這意味着下發速度能夠更快(在下發消息的過程當中,最耗時的仍是網絡交互,像短信這種能夠多開點線程進行消費)。
在前面所提到的業務規則就是在下發層這兒作的,包括夜間屏蔽、1小時去重和Id轉換等
userId+消息渠道
做爲Key,看是否存在Redis上,假設存在,則過濾掉id轉換
這功能咱們作成了個系統,這塊我放在下面簡單說一下吧,這就不在贅述了。畫外音:這種場景最好使用 Pipeline來讀寫Redis
隨後就是適配各個渠道的接口,調用API
下發消息了,這塊就跟大家單個的實現沒什麼大的區別了,調用個接口還能給你玩出花來?(代碼風格會稍好一些,模板方法模式、責任鏈、生產者與消費者模式等在項目中都有對應的應用)
總結一下接口的實現:
API
發送消息,而是放入消息隊列上(支持高併發)在前面也提到了,發不一樣類型的消息會須要有不一樣的id
類型:微信類須要openId
、短信須要手機號、push通知欄推送須要did
。
在大多數狀況下,通常調用者就傳入userId
給到我,我這邊須要根據不一樣的消息類型對userId
進行轉換。
那在咱們這邊是怎麼實現該系統的呢?主要的步驟和邏輯有如下:
topic
,在Flink
清洗出一個統一的數據模型,將清洗後的數據寫到另外一個的topic
。Flink
清洗出的topic
,實時寫到數據源(這裏咱們用的是搜索引擎)看着也不會很難,對吧?
有沒有想過一個問題,爲何要用一個Id映射系統去監聽Flink
洗出來的topic
,而不是在Flink
直接寫到數據源呢?
其實經過Flink直接寫到數據源也是徹底沒問題的,而封裝了一個Id映射系統,就能夠把這活作得更細緻。
從描述能夠發現的是:在上面只實現了實時增量。不少時候咱們會擔憂增量存在問題,致使部分數據的不許確或者丟失,都會寫一份全量,Id映射也是一樣的。
那Id映射的全量是怎麼作的呢?用戶數據經過各類關聯關係會在Hive
造成一張表,而Id映射的全量就是基於這張Hive
表來實現全量(天天凌晨會讀取Hive表的信息,再寫一遍數據源)。
基於上面這些邏輯,專門給Id映射作了個後臺管理(能夠手動觸發全量、是否開啓增量/全量、修改全量觸發的時間)
我以爲這塊是消息管理平臺最最最精華的一部分。
夢迴咱們當初的接口設計環節,咱們就是由於有「數據統計」的需求,才引入了模板的概念。如今咱們已經有了一個模板Id
了,在咱們這邊是怎麼實現數據的統計的呢?咱們對消息的統計都是基於模板的維度來實現的。
在建立模板時就會有一個模板Id生成,基於這個模板Id,咱們生成了一個叫作umpId
的值:第一位分爲技術/運營推送,最後八位是日期,中間六位是模板Id
由於全部的消息都會通過接入層,只要消息帶有連接,咱們就會給連接後加上umpid
參數,連接會一直下發透傳,直至用戶點擊
每一個系統在執行消息的時候都會可能致使這條消息發不出去(多是消息去重了,多是用戶的手機號不正確,多是用戶過久沒有登陸了等等都有可能)。咱們在這些『關鍵位置』都打上日誌,方便咱們去排查。
這些「關鍵位置」咱們都給它用簡單的數字來命個名。好比說:咱們用「11」來表明這個用戶沒有綁定手機號,用「12」來表明這個用戶10分鐘前收到了一條如出一轍的消息,用「13」來表明這個用戶屏蔽了消息.....
「11」「12」「13」「14」「15」「16」這些就叫作「點位」,把這些點位在關鍵的位置中打上日誌,這個就叫作「埋點」
有了埋點,咱們要作的就是將這些點位收集起來,而後統一處理成咱們的數據格式,輸出到數據源中。
有logAgent幫咱們收集日誌到Kafka,實時清洗日誌咱們用的是Flink,清洗完咱們輸出到Redis(實時)/Hive(離線)。
Hive表的數據樣例(主要用於離線報表統計):
Redis會以多維度來進行存儲,以便支撐咱們的業務須要。好比,要查一條消息爲什麼發送失敗,經過userId
搜一下,直接完事(實時的都記錄在Redis中,因此這裏讀取的是Redis的數據)
好比,經過模板Id,查某條消息的總體下發狀況:
爲何我說這是消息管理平臺最最最精華的呢?umpId
貫穿了全部消息管理平臺通過的系統,只要是在消息管理平臺發的消息,都會被記錄下來發送,能夠經過點位來快速追蹤消息的下發狀況。
總結一下數據統計:
umpid
,給全部的消息推送連接都加上umpdId
參數前面提到了,運營的模板是須要圈選一批人羣,而後下發消息的,那這羣人從哪裏來?
在好久以前,消息管理平臺也把人羣給作掉了,大體的思路就是能夠支持文件上傳
和hivesql
上傳兩種方式去圈選人羣,圈出來上傳到hdfs
進行讀取,支持對人羣的更新/切分/導出等功能。
有了人羣的概念,你會發現你收到的消息其實都是跟你息息相關的(不是瞎給你推送的,你在裏面,才能圈到你)。多是由於你看了幾天的連衣裙,因此給你推送連衣裙的消息,吸引去你購買。
後來,因爲公司內部DMP
系統崛起,人羣就都交由DMP
給管理了。但實現的思路也都是相似的,只不過仍是一樣的:人家作的是平臺,功能確定比會本身寫幾個接口要完善很多。
作推送就免不了發錯了消息,特別是在運營側(分分鐘就推送千萬人),咱們平臺又作了什麼措施去儘量避免這種問題的發生呢?
在運營圈定人羣后,咱們會有單獨的測試功能去「測試單個用戶」是否能正常下發消息,文案連接是否存在問題。
這一個步驟是必需要作的,給用戶發出的消息,首先要通過本身的校驗。若是確認連接和文案都無問題後,則提交任務,走工單審批後才能發送。
若是在啓動以後發現文案/連接存在問題,還能夠攔截剩餘未發的消息。
針對於(技術方推送),咱們在預發環境下配置了「白名單」才能收到消息。
線上消息有「去重」的邏輯:
雖說,咱們制定了不少的規則去儘可能避免事故的發生,但不得不說推送仍是一個容易出現事故的功能。個人牛逼已經吹完了,若是某天發現個人推送出了事故,不要@我,當沒見過這篇文章就好。
不知道你們看完以後以爲消息管理平臺難不難,從理解上的角度而言,這系統應該是很好理解的,沒有摻雜不少業務的東西,都是作平臺性相關的內容。
這個系統能支持數W的QPS,天天億級的流量推送,一篇文章也不可能把消息管理平臺的全部功能點都講完,內容也不止上面這些,但核心我應該是講清楚的了。
發送消息能夠作得很簡單,也能夠作得很平臺化,若是你以爲你學到了些許東西,但願能夠給我點個在看和轉發一波。若是你對我寫的內容有疑問,歡迎評論區交流。
後續可能會更多寫廣告系統相關的內容,會以一些小的問題切入,不得不說,廣告系統比消息管理平臺仍是要複雜和有趣得多。提早關注預約最新文章,不會讓你但願的!
我是三歪,下期揭祕-廣告系統再見
PDF文檔的內容均爲手打,有任何的不懂均可以直接來問我