APP推送是触达用户的一个非常重要的手段,对于提高产品活跃度、提高功能使用体验、提升用户粘性、提升用户留存率都会起到重要作用。伴鱼旗下多款APP,支持丰富的用户交互体验,对推送的依赖上表现的尤为突出。随着公司业务的快速发展,伴鱼旗下的app也在与日俱增,对推送场景的需求也开始多样化,推送量的需求更是飞速增长,这些都对伴鱼推送平台提出了更高的要求。本文就伴鱼推送平台在实践中遇到问题的思考以及相应的技术方案进行详细说明,以期给读者带来一些思考以及解决类似问题的思路。
推送平台通常会遇到的问题,在伴鱼这里也都同样遇到,最具有代表的问题:
- 高吞吐。推送巨大的流量如何支撑,尤其在运营集中做活动时候表现的尤为突出,动辄是千万量级 亦或是亿级别的量级,怎么能够很好的支撑?
- 低延迟。推送任务要能以最快的速度让用户收到。运营集中做活动的时间很短,要在这有限的时间内,尽可能快的触达用户。
同时伴鱼推送平台也遇到了我们业务特有的推送问题:
- 多客户端推送。伴鱼旗下的诸多app,在业务上有着强的关联关系,业务上的同一功能可能要给多个app下发推送,比如:家长都很关心学生的学习情况,学生若在学生端获得一个奖励,需要推送学生端,也需要及时通知家长端
- 多种推送场景。伴鱼诸多业务中比较突出的有三种场景的推送,一种是业务实时推送,在线课堂的交互实时推送就是比较典型的示例,这类推送用户多为在线用户,推送时效性要求很高,否则会影响用户的上课体验;一种是类似站内信的系统通知,这类推送用户多为离线用户,推送消息可靠性很高;一种是营销推送,这类推送用户多为离线用户,可靠性要求没有前两种高。第一种场景的流量曲线跟业务高峰期的曲线相同,每天量相对稳定,后两种场景流量是典型的脉冲式流量,有推送时流量会瞬间陡增。
- 推送相互隔离。推送平台是面向伴鱼所有业务线的,不能因为某个业务线的推送量过大,影响到别的业务的使用,如何能够按业务隔离,按推送类型隔离?
带着这些问题我们技术中台协力打造了一个高吞吐、低延迟、多业务隔离的的伴鱼推送平台
这里简述下推送的一般处理流程。推送常用的模式有推、拉两种模式,拉的模式消息实时性较差,对客户端的流量、电量消耗都比较大,因此目前很少采用这种模式。下面以推的模式为例介绍推送的流程。各推送通道的流程大同小异,这里不详细展开。
推送一般涉及到三个阶段流程:设备绑定;消息推送;消息回执
推送通道一般会为设备分配客户端唯一标识(token),用来做为长连接通道的标识,依据这个标识将消息送达到对应的客户端上。推送平台面向业务,是以系统的用户id作为推送标识。推送服务这时候就需要将用户id与客户端标识进行映射。客户端打开app会跟推送通道建立长连接,此时获取到token。然后将当前用户id和token一起发送给推送服务,推送服务将信息存储完成绑定操作。
当推送服务接收到推送请求时,一般推送请求是给某个用户id发送消息。推送服务根据用户id获取到之前绑定的设备标识,针对不同的通道构造不同的消息格式,调用推送通道的接口执行发送。推送通道接收到请求后,根据设备标识找到对应的长连接,将消息通过长连接通道发送到客户端。客户端进行消息的弹窗展示等逻辑处理。
客户端在收到消息或者点击的时候,可以调用配置的回执地址(有些推送通道支持,有些是主动请求推送通道轮训拉取),将该条消息的状态上报到推送服务。
伴鱼推送平台整体采用分层的架构设计,如图:
整体架构从上而下包括:业务层、网关层、推送服务层、长连接服务层以及依赖的基础资源层
业务层:
业务会借助推送的通道能力实现复杂的业务功能,其中典型的业务场景就是im。用户在发出im消息之后,借助推送平台进行消息信令的下行投递。im的实现是考虑了消息可靠性,客户端在收到推送的消息信令或客户端与后端连接服务建立长连接时,会采用拉的方式将消息获取到。业务上会借助im的这一特点使用特殊的系统号来发送im消息,从而达到可靠性推送的业务场景。前文提到的系统消息就是借助这一方案实现的。在推送可靠性相对较低的场景下业务也会使用推送的接口直接下发消息。为了方便运营人员进行营销推送和系统通知,我们开发了运营推送平台。该平台会根据不同的推送场景需求(系统通知/营销消息)选择通过im或者直接调用推送下发。业务层的所有推送流量都会统一接管到推送网关层,由网关层进行下行处理。
网关层:
推送网关作为一个网关层处理路由转发、流量控制等基本的网关工作。路由转发针对不同app、不同消息类型结合相应下发策略配置,将消息推送到相应的app上。下发规则在网关控制后,业务上的只需要关心推送消息的业务场景,不用考虑消息是否需要调用学生端推送,或者需要调用家长端推送。流量控制上网关层会根据推送的app、推送的消息类型、推送的消息级别、推送的场景等信息设置不同的消息处理能力。消息处理能力主要体现在消息的接收和下行。通过灵活的配置,可以随时为优先级高的业务消息提供更多的资源支持,接收上提供更多mq的topic,下行上提供更多的协程资源、更快的速度上限。网关层针对流量路由到不同app之后就需要通过推送服务进行实际的推送处理。
推送服务层:
推送服务层主要是针对客户端的长连接标识和用户信息进行映射绑定,并针对不同的用户客户端类型借助不同的推送通道进行消息下发。推送服务首先处理的问题就是设备的绑定/解绑,即将用户id与设备token建立映射关系。其次是接收到用户id的推送时,获取到相应token信息进行消息的下发。为了减少伴鱼各业务推送影响,推送服务层进行了纵向切分,针对不同的业务提供各自的推送服务,像图中pushpicturebook是专门为伴鱼绘本app提供推送的服务,pushrtc专门为在线课堂提供推送服务。
长连接服务层:
长连接层也是推送通道层。主要有自建长连接、苹果推送的APNS、google的GCM、Android四大厂商推送(华为、小米、vivo、oppo)。自建长连接服务基于隔离性的考虑也进行了纵向切分,比如connectpicturebook专门用于绘本的长连接,connect服务用于其他业务长连接。针对用户客户端所在网络区域不同(国内、国外),也对长连接服务进行了横向切分,通过接入点智能路由到相对较近的长连接接入点上。针对APNS和GCM推送通道的特点,在国外网络访问的延迟较低,我们将APNS和GCM的服务专门部署了海外节点,推送服务到这些节点通过专线连通。
基础资源层:
基础资源层包括了伴鱼基础架构提供的各类中间件能力。推送过程中依赖比较重要的是消息队列和缓存。推送流量到推送网关时,我们会根据使用消息队列来进行一次缓冲,并控制下发的速度从而保护下层的服务。用户的设备标识通过缓存存储,减少获取设备标识时的时延。各层也都会根据实现场景依赖DB进行持久化数据存储。还会用到其他资源包括底层运维资源,这些不是本文的重点,就不再赘述。
架构设计我们是立足伴鱼已有的技术架构体系,针对当前阶段推送目标来进行设计。我们在设计服务时以云原生理念为出发点,将服务粒度设计足够小,并且尽可能的保证服务的无状态,充分发挥底层容器化的能力。架构设计上着重考虑了服务的性能、高可用、伸缩性以及可扩展性。
性能保证:
高性能主要在体现各个服务的高QPS和低RT。
业务在流量请求到pushgateway时,pushgateway直接使用消息队列来承接所有流量,消息队列的高性能,保证了pushgateway的接受消息的高性能。pushgateway在消费消息队列中的消息时,会根据不同流量的业务启动不同数量大小的协程池,充分利用go协程并发处理能力快速处理消息。推送服务在收到推送请求时从缓存中直接获取客户端标识,根据用户的设备并行调用各推送通道的接口执行消息投递。针对推送场景的特点,设计了批量推送的接口,减少rpc的调用开销。整体的思路就是能并行的充分利用协程并发处理,能异步化的借助消息队列解耦,能走缓存的不要请求数据库。
高可用保证:
系统设计在考虑多实例、服务无状态(长连接服务除外)等基本的高可用前提下,结合服务特点进行了不同的处理。
pushgateway提供了灵活的动态配置,可以随时控制下游的流量,在下游资源紧张情况下可以随时限制流速,保证底层资源不会过载。业务上针对不同等级的消息分配不同大小的协程池资源处理,在保证了高优先级的消息能及时推送的同时,也让低优先级的消息有机会执行推送。
推送服务依赖多个下游推送通道,我们虽然已经并行处理各通道的推送,但是任意推送通道出现问题可能还是会影响推送的可用性,针对这个问题,我们对各下游推送通道并行调用的同时,设计了超时降级策略,如果在指定时间不能响应则直接对该通道快速失败,保证其他通道依然能够推送,推送服务整体不会被拖垮。
长连接服务是有状态服务,在出现实例异常的情况下,客户端会根据心跳及时探测到连接异常,并重新进行接入点调度,调度到新的可用节点上,重新建立长连接,长连接异常切换过程在秒级完成。异常时间段的消息均会进行缓存,并在长连接重连后重新投递,保证长连接的高可用。
推送服务和长连接服务均做了纵向切分,业务流量之间从物理上进行了隔离,保证不会因为任意一个业务服务的异常导致其他服务的不可用。
伸缩性:
架构设计的伸缩性主要依赖的是伴鱼基础架构及基础运维的伸缩能力。服务的容器化部署,由k8s统一管控,可以随时增减服务的实例。依赖的基础架构中的缓存(codis集群)和消息队列(kafka集群)均有很好的伸缩性。
pushgateway在消息处理的伸缩能力,使用自身动态的配置可以根据推送消息量随时调整topic的数量以及消息处理的资源消耗。
扩展性:
推送整体的分层比较细致,将im、推送、长连接全部分开处理,这样给业务提供了各个层的基础能力。业务层可以根据需要依赖到任意一层。
单就推送业务场景来看扩展性,通过推送网关将业务的各类推送屏蔽,可以随时根据业务需求配置不同的推送策略。推送服务自身的实现也是依赖动态配置根据不同的推送类型制定不同的推送策略,比如一个人推几个设备,是否推apns等,均可进行动态配置。
推送网关从设计之初就不只是面向app推送,未来可以将所有触达类(短信、微信)的推送都通过网关进行相应的路由和流控。
推送网关从设计之初就是面向所有触达类的消息推送。目前先实现网关能力中的路由和流控。推送网关处理流程示意如图
推送请求流量到推送网关后,根据推送参数中消息业务标识(src),推送类型(ptype)信息进行路由,找到对应消息队列topic,将消息写入后结束。网关服务自身会根据不同优先级的topic情况启动不同资源配置的协程池按照一定速度消费消息队列中的消息,交由消息执行器处理。消息执行器按照src、ptype路由到相应的目标推送服务执行推送。
整个流程看最主要的包括路由(队列路由&推送路由)和围绕消息队列的限速消费。系统整体实现是使用go语言栈,rpc接口使用grpc。路由信息的实现全部基于配置信息,配置信息我们依赖基础架构提供的apollo动态配置中心。消息队列使用kafka集群。自研的限速消费及协程池。
队列路由
消息队列的topic分有两个维度一个是topic的标识,一个是topic的优先级。系统初始会在kafka中申请三个topic:default_high,default_normal,default_low分别对应默认的高优先级消息、中优先级消息及低优先级消息。可以看出topic的完整名称是由“topic标识+优先级”组成。所以队列路由解决的问题就是根据入参能够找到topic标识及优先级,即kafka的topic。规则默认所有找不到topic标识的都使用default,找不到优先级的均使用high。
队列路由配置信息依赖apollo的key=value方式配置,配置的格式为
在收到推送请求后根据请求中的src,ptype从路由配置中找到对应topic标识和优先级的值。配置中[.$ptype]表示该部分可选,若不配置这部分意味着这个src下的所有ptype都使用相同的配置,以此简化路由配置规则。
推送路由
推送路由的实现与队列路由基本一致,只是配置信息中的value值是多个src,格式如
根据请求中src和ptype可以知道当前的推送目标业务需要推到少儿和绘本
限速消费
限速主要通过控制从kafka中消费消息的qps达到限速的目的。通过qps计算出一个消息处理最少需要的时间,当处理消息时间少于这个最小时间的时候,协程进行阻塞等待,直到时间达到最小时间才进行下一个消息消费。qps的信息也是依赖apollo的配置管理。配置格式如
对每个topic均可以指定速度,如果不指定也有默认的速度值。
速度限制在实际推送过程作用非常重要。底层资源有可能会被海量的推送消耗完,直接影响正常业务。一个典型的资源就是专线,流量不做限制的话,消息推送的瞬间流量可以轻松达到百兆,专线资源是非常昂贵的,一般不会为这类脉冲式流量提供非常大的带宽资源。这时候就得通过速度控制住下游流量。
协程池
系统在启动时就会为不同topic分配一个协程池用以消费消息队列中的消息。协程池资源大小的分配按照 high:normal:low=5:2:1的比例进行分配。这样保证高优先级消息占用较多的资源,低优先级的消息也能得到被处理的机会。
推送服务主要实现设备绑定和消息推送功能
设备绑定
设备绑定的流程如图
设备绑定信息是典型的读多写少的数据,非常适合使用缓存处理,此处设备信息除了存储db也会在缓存中保留一份。绑定请求至少有用户标识(uid),业务标识(src),通道的客户端标识(token)和通道类型(ttype)。其中src表示不同的业务,如:伴鱼少儿英语、伴鱼绘本等;token如前文所述每个推送通道在客户端建立长连接之后,使用该token表示长连接;ttype是token的类型,用该参数表示当前要绑定的通道是APNS、GCM、华为、小米或者是自建的长连接。每个用户会有多条绑定记录,相同的推送通道也可能有多条记录,数据量会随着用户增长递增。因此这里需要考虑分库分表的策略,具体策略可以根据当前的业务情况选择。目前我们不同业务的数据量差别较大,为减少相互影响,此处使用了src进行分表处理。用户设备绑定信息变更的时候,为减少数据不一致,我们采用了直接删除缓存的操作。
消息推送
消息推送流程如图
收到的推送请求至少包含用户标识(uid)、业务标识(src)、推送场景(ptype)、消息信息(msg)。其中ptype是表示不同的推送场景,比如 im聊天消息的推送、营销消息推送等,对于不同场景的推送会配置不同的推送策略。
推送策略目前主要考虑到的是根据ptype配置推送通道、推送通道的通知设备数(如:用户在多个苹果手机都绑定过同一用户,某些业务场景只需要最近的设备收到,则可以通过该配置支持)、推送客户端的版本范围(如:有些推送只能在某些固定客户端版本才有支持,其他版本可能带来异常等,可以通过版本范围控制)等,也有不同推送通道的其他配置信息。通过推送策略配置信息获取到需要推送的通道信息之后,针对各个通道并行执行推送。
各个推送通道的处理流程基本一样。首先根据uid在设备绑定信息中获取token,前面已经提到设备信息是读多写少特点,会先从缓存读取,缓存没有则从数据库将数据加载到缓存中。然后根据不同推送通道的协议,拼装各通道推送需要的相关参数,并调用各推送通道服务执行推送。流程中在获取token时,我们讲到了先缓存后db,而我们在实现时,并不是严格按照这个过程。试想两种场景:在线课堂的推送和业务营销推送。在线课堂的场景中,用户基本是在线状态,用户会在上课这段时间内收到大量推送,这种场景使用缓存是再合适不过的,但是对于营销消息,可能大量的用户是冷数据,冷数据都会先访问一次缓存,判断缓存不存在,再访问一次数据库,然后再将数据写入到缓存,至少三次网络io操作,并且没有减轻db的压力,对于这个情况我们实现时考虑了两个方案:一个方案是保证设备绑定信息大量存在缓存中,设置较长的过期时间,这样可以减少db的访问;另一个方案是直接跳过缓存使用db,将三次io减少为一次io。对于方案一设计上看可能更为合理,但这样会消耗大量的缓存资源,而方案二我们通过将集群规模扩大增加更多用于读的从节点,一样可以达到效果。鉴于当前资源配备情况,我们选择了方案二,并且比较好的实现了业务目标。