微信搜【Java3y】关注这个有梦想的男人,点赞关注是对我最大的支持!文本已收录至我的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,每天亿级的流量推送,一篇文章也不可能把消息管理平台的所有功能点都讲完,内容也不止上面这些,但核心我应该是讲清楚的了。
发送消息可以做得很简单,也可以做得很平台化,如果你觉得你学到了些许东西,希望可以给我点个在看和转发一波。如果你对我写的内容有疑问,欢迎评论区交流。
后续可能会更多写广告系统相关的内容,会以一些小的问题切入,不得不说,广告系统比消息管理平台还是要复杂和有趣得多。提前关注预定最新文章,不会让你希望的!
我是三歪,下期揭秘-广告系统再见
PDF文档的内容均为手打,有任何的不懂都可以直接来问我