本文由喜马拉雅技术团队李乾坤原创,原题《推送系统实践》,感谢作者的无私分享。
1、引言
1.1 什么是离线消息推送
对于IM的开发者来说,离线消息推送是再熟悉不过的需求了,比如下图就是典型的IM离线消息通知效果。
1.2 Andriod端离线推送真心不易
移动端离线消息推送涉及的端无非就是两个——iOS端和Andriod端,iOS端没什么好说的,APNs是唯一选项。
Andriod端比较奇葩(主要指国内的手机),为了实现离线推送,各种保活黑科技层出不穷,随着保活难度的不断升级,可以使用的保活手段也是越来越少,有兴趣可以读一读我整理的下面这些文章,感受一下(文章是按时间顺序,随着Andriod系统保活难度的提升,不断进阶的)。
- 《应用保活终极总结(一):Android6.0以下的双进程守护保活实践》
- 《应用保活终极总结(二):Android6.0及以上的保活实践(进程防杀篇)》
- 《应用保活终极总结(三):Android6.0及以上的保活实践(被杀复活篇)》
- 《Android P正式版即将到来:后台应用保活、消息推送的真正噩梦》
- 《全面盘点当前Android后台保活方案的真实运行效果(截止2019年前)》
- 《2020年了,Android后台保活还有戏吗?看我如何优雅的实现!》
- 《史上最强Android保活思路:深入剖析腾讯TIM的进程永生技术》
- 《Android进程永生技术终极揭密:进程被杀底层原理、APP应对被杀技巧》
- 《Android保活从入门到放弃:乖乖引导用户加白名单吧(附7大机型加白示例)》
上面这几篇只是我整理的这方面的文章中的一部分,特别注意这最后一篇《Android保活从入门到放弃:乖乖引导用户加白名单吧(附7大机型加白示例)》。是的,当前Andriod系统对APP自已保活的容忍度几乎为0,所以那些曾今的保活手段在新版本系统里,几乎统统都失效了。
自已做保活已经没戏了,保离线消息推送总归是还得做。怎么办?按照现时的最佳实践,那就是对接种手机厂商的ROOM级推送通道。具体我就不在这里展开,有兴趣的地可以详读《Android P正式版即将到来:后台应用保活、消息推送的真正噩梦》。
自已做保活、自建推送通道的时代(这里当然指的是Andriod端啦),离线消息推送这种系统的架构设计相对简单,无非就是每台终端计算出一个deviceID,服务端通过自建通道进行消息透传,就这么点事。
而在自建通道死翘翘,只能依赖厂商推送通道的如今,小米、华为、魅族、OPPO、vivo(这只是主流的几家)等等,手机型号太多,各家的推送API、设计规范各不相同(别跟我提什么统一推送联盟,那玩意儿我等他3年了——详见《万众瞩目的“统一推送联盟”上场了》),这也直接导致先前的离线消息推送系统架构设计必须重新设计,以适应新时代的推送技术要求。
1.3 怎么设计合理呢
那么,针对不同厂商的ROOM级推送通道,我们的后台推送架构到底该怎么设计合理呢?
本文分享的离线消息推送系统设计并非专门针对IM产品,但无论业务层的差别有多少,大致的技术思路上都是相通的,希望借喜马拉雅的这篇分享能给正在设计大用户量的离线消息推送的你带来些许启发。
* 推荐阅读:喜马拉雅技术团队分享的另一篇《长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践》,有兴趣也可以一并阅读。
学习交流: - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 - 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK
2、技术背景
首先介绍下在喜马拉雅APP中推送系统的作用,如下图就是一个新闻业务的推送/通知。
离线推送主要就是在用户不打开APP的时候有一个手段触达用户,保持APP的存在感,提高APP的日活。
我们目前主要用推送的业务包括:
- 1)主播开播:公司有直播业务,主播在开直播的时候会给这个主播的所有粉丝发一个推送开播提醒
- 2)专辑更新:平台上有非常多的专辑,专辑下面是一系列具体的声音,比如一本儿小说是一个专辑,小说有很多章节,那么当小说更新章节的时候给所有订阅这个专辑的用户发一个更新的提醒:
- 3)个性化、新闻业务等。
既然想给一个用户发离线推送,系统就要跟这个用户设备之间有一个联系的通道。
做过这个的都知道:自建推送通道需要App常驻后台(就是引言里提到的应用“保活”),而手机厂商因为省电等原因普遍采取“激进”的后台进程管理策略,导致自建通道质量较差。目前通道一般是由“推送服务商”去维护,也就是说公司内的推送系统并不直接给用户发推送(就是上节内容的这篇里提到的情况:《Android P正式版即将到来:后台应用保活、消息推送的真正噩梦》)。
这种情况下的离线推送流转流程如下:
国内的几大厂商(小米、华为、魅族、OPPO、vivo等)都有自己官方的推送通道,但是每一家接口都不一样,所以一些厂商比如小米、个推提供集成接口。发送时推送系统发给集成商,然后集成商根据具体的设备,发给具体的厂商推送通道,最终发给用户。
给设备发推送的时候,必须说清楚你要发的是什么内容:即title、message/body,还要指定给哪个设备发推送。
我们以token来标识一个设备, 在不同的场景下token的含义是不一样的,公司内部一般用uid或者deviceId标识一个设备,对于集成商、不同的厂商也有自己对设备的唯一“编号”,所以公司内部的推送服务,要负责进行uid、deviceId到集成商token 的转换。
3、整体架构设计
如上图所示,推送系统整体上是一个基于队列的流式处理系统。
上图右侧:是主链路,各个业务方通过推送接口给推送系统发推送,推送接口会把数据发到一个队列,由转换和过滤服务消费。转换就是上文说的uid/deviceId到token的转换,过滤下文专门讲,转换过滤处理后发给发送模块,最终给到集成商接口。
App 启动时:会向服务端发送绑定请求,上报uid/deviceId与token的绑定关系。当卸载/重装App等导致token失效时,集成商通过http回调告知推送系统。各个组件都会通过kafka 发送流水到公司的xstream 实时流处理集群,聚合数据并落盘到mysql,最终由grafana提供各种报表展示。
4、业务过滤机制设计
各个业务方可以无脑给用户发推送,但推送系统要有节制,因此要对业务消息有选择的过滤。
过滤机制的设计包括以下几点(按支持的先后顺序):
- 1)用户开关:App支持配置用户开关,若用户关闭了推送,则不向用户设备发推送;
- 2)文案排重:一个用户不能收到重复的文案,用于防止上游业务方发送逻辑出错;
- 3)频率控制:每一个业务对应一个msg_type,设定xx时间内最多发xx条推送;
- 4)静默时间:每天xx点到xx点不给用户发推送,以免打扰用户休息。
- 5)分级管理:从用户和消息两维度进行分级控制。
针对第5点,具体来说就是:
- 1)每一个msg/msg_type有一个level,给重要/高level业务更多发送机会;
- 2)当用户一天收到xx条推送时,不是重要的消息就不再发给这些用户。
5、分库分表下的多维查询问题
很多时候,设计都是基于理论和经验,但实操时,总会遇到各种具体的问题。
喜马拉雅现在已经有6亿 用户,对应的推送系统的设备表(记录uid/deviceId到token的映射)也有类似的量级,所以对设备表进行了分库分表,以 deviceId 为分表列。
但实际上:经常有根据 uid/token 的查询需求,因此还需要建立以 uid/token 到 deviceId 的映射关系。因为uid 查询的场景也很频繁,因此uid副表也拥有和主表同样的字段。
因为每天会进行一两次全局推,且针对沉默用户(即不常使用APP的用户)也有专门的推送,存储方面实际上不存在“热点”,虽然使用了缓存,但作用很有限,且占用空间巨大。
多分表以及缓存导致数据存在三四个副本,不同逻辑使用不同副本,经常出现不一致问题(追求一致则影响性能), 查询代码非常复杂且性能较低。
最终我们选择了将设备数据存储在tidb上,在性能够用的前提下,大大简化了代码。
6、特殊业务的时效性问题
6.1 基本概念
推送系统是基于队列的,“先到先推”。大部分业务不要求很高的实时性,但直播业务要求半个小时送达,新闻业务更是“欲求不满”,越快越好。
若进行新闻推送时:队列中有巨量的“专辑更新”推送等待处理,则专辑更新业务会严重干扰新闻业务的送达。
6.2 这是隔离问题?
一开始我们认为这是一个隔离问题:比如10个消费节点,3个专门负责高时效性业务、7个节点负责一般业务。当时队列用的是rabbitmq,为此改造了 spring-rabbit 支持根据msytype将消息路由到特定节点。
该方案有以下缺点:
- 1)总有一些机器很忙的时候,另一些机器在“袖手旁观”;
- 2)新增业务时,需要额外配置msgType到消费节点的映射关系,维护成本较高;
- 3)rabbitmq基于内存实现,推送瞬时高峰时占用内存较大,进而引发rabbitmq 不稳定。
6.3 其实是个优先级问题
后来我们觉察到这是一个优先级问题:高优先级业务/消息可以插队,于是封装kafka支持优先级,比较好的解决了隔离性方案带来的问题。具体实现是建立多个topic,一个topic代表一个优先级,封装kafka主要是封装消费端的逻辑(即构造一个PriorityConsumer)。
备注:为描述简单,本文使用 consumer.poll(num) 来描述使用 consumer 拉取 num 个消息,与真实 kafka api 不一致,请知悉。
PriorityConsumer实现有三种方案,以下分别阐述。
1)poll到内存后重新排序:java 有现成的基于内存的优先级队列PriorityQueue 或PriorityBlockingQueue,kafka consumer 正常消费,并将poll 到的数据重新push到优先级队列。
- 1.1)如果使用有界队列,队列打满后,后面的消息优先级再高也put 不进去,失去“插队”效果;
- 1.2)如果使用无界队列,本来应堆在kafka上的消息都会堆到内存里,OOM的风险很大。
2)先拉取高优先级topic的数据:只要有就一直消费,直到没有数据再消费低一级topic。消费低一级topic的过程中,如果发现有高一级topic消息到来,则转向消费高优先级消息。
该方案实现较为复杂,且在晚高峰等推送密集的时间段,可能会导致低优先级业务完全失去推送机会。
3)优先级从高到低,循环拉取数据:
一次循环的逻辑为:
consumer-1.poll(topic1-num); cosumer-i.poll(topic-i-num); consumer-max.priority.poll(topic-max.priority-num)
如果topic1-num=topic-i-num=topic-max.priority-num,则该方案是没有优先级效果的。topic1-num 可以视为权重,我们约定:topic-高-num=2 * topic-低-num,同一时刻所有topic 都会被消费,通过一次消费数量的多少来变相实现“插队效果”。具体细节上还借鉴了“滑动窗口”策略来优化某个优先级的topic 长期没有消息时总的消费性能。
从中我们可以看到,时效问题先是被理解为一个隔离问题,后被视为优先级问题,最终转化为了一个权重问题。
7、过滤机制的存储和性能问题
在我们的架构中,影响推送发送速度的主要就是tidb查询和过滤逻辑,过滤机制又分为存储和性能两个问题。
这里我们以xx业务频控限制“一个小时最多发送一条”为例来进行分析。
第一版实现时:redis kv 结构为 <deviceId_msgtype,已发送推送数量>。
频控实现逻辑为:
- 1)发送时,incr key,发送次数加1;
- 2)如果超限(incr命令返回值>发送次数上限),则不推送;
- 3)若未超限且返回值为1,说明在msgtype频控周期内第一次向该deviceId发消息,需expire key设置过期时间(等于频控周期)。
上述方案有以下缺点:
- 1)目前公司有60 推送业务, 6亿 deviceId,一共6亿*60个key ,占用空间巨大;
- 2)很多时候,处理一个deviceId需要2条指令:incr expire。
为此,我们的解决方法是:
- 1)使用pika(基于磁盘的redis)替换redis,磁盘空间可以满足存储需求;
- 2)委托系统架构组扩充了redis协议,支持新结构ehash。
ehash基于redis hash修改,是一个两级map <key,field,value>,除了key 可以设置有效期外,field也可以支持有效期,且支持有条件的设置有效期。
频控数据的存储结构由<deviceId_msgtype,value>变为 <deviceId,msgtype,value>,这样对于多个msgtype,deviceId只存一次,节省了占用空间。
incr 和 expire 合并为1条指令:incr(key,filed,expire),减少了一次网络通信:
- 1)当field未设置有效期时,则为其设置有效期;
- 2)当field还未过期时,则忽略有效期参数。
因为推送系统重度使用 incr 指令,可以视为一条写指令,大部分场景还用了pipeline 来实现批量写的效果,我们委托系统架构组小伙伴专门优化了pika 的写入性能,支持“写模式”(优化了写场景下的相关参数),qps达到10w以上。
ehash结构在流水记录时也发挥了重要作用,比如<deviceId,msgId,100001002>,其中 100001002 是我们约定的一个数据格式示例值,前中后三个部分(每个部分占3位)分别表示了某个消息(msgId)针对deviceId的发送、接收和点击详情,比如头3位“100”表示因发送时处于静默时间段所以发送失败。(本文同步发布链接是:http://www.52im.net/thread-3621-1-1.html)