文字已收錄至我的GitHub:https://github.com/ZhongFuCheng3y/3y,有300多篇原創文章,最近在連載面試和專案系列!
我,三歪,最近要開始寫專案系列文章。我給這個系列取了一個名字,叫做《揭祕》
沒錯,我又給自己挖了個坑。
為什麼想寫專案相關的文章呢?原因有以下:
這個系列就以「訊息管理平臺」來打個樣吧,這是我維護近一年的系統了。這篇文章可以帶你全面認識「訊息管理平臺」是怎麼設計和實現的,有興趣的同學歡迎在評論區下留言和交流。
這篇文章可能稍微會有些許長,我是打算一篇就把該系統給講清楚。「訊息管理平臺」原理並不難,沒有很多專業名詞,實現起來也不會複雜,你要是覺得學到了,歡迎給我點個贊👍
「訊息管理平臺」可能在不同的公司會有不同的叫法,有的時候我會叫它「推播系統」,有的時候我會叫它「訊息管理平臺」,也有的同事叫它「觸達平臺」,甚至浮誇點我也可以叫它「訊息中臺」
但是不管怎麼樣,它的功能就是給使用者發訊息。在公司裡它是怎麼樣的定位?只要以官方名義傳送的訊息,都走訊息管理平臺。
一般你註冊一個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,每天億級的流量推播,一篇文章也不可能把訊息管理平臺的所有功能點都講完,內容也不止上面這些,但核心我應該是講清楚的了。
傳送訊息可以做得很簡單,也可以做得很平臺化,如果你覺得你學到了些許東西,希望可以給我點個點贊和轉發一波。如果你對我寫的內容有疑問,歡迎評論區交流。
後續可能會更多寫廣告系統相關的內容,會以一些小的問題切入,不得不說,廣告系統比訊息管理平臺還是要複雜和有趣得多。提前關注預定最新文章,不會讓你希望的!
我是三歪,下期揭祕-廣告系統再見