网易云信 IM 推送保障及网络优化实践

sai1019 7年前
   <p>今天,我想和大家分享的是云信在保障 IM 推送和移动网络优化方面的一些实践经验。</p>    <p>对于移动 App 来说,除了简单的工具类 App 外,IM 功能可谓非常重要,它能够创建起人与人之间的连接。社交类产品中,用户与用户之间的沟通可以产生出更好的用户粘性。电商类产品,用户与商家的连接能极大的促进沟通效率,降低沟通成本。教育类产品,学生与老师的连接让在线教育犹如课堂。医疗类 App,医生与患者的沟通让医生把脉更加准确。</p>    <p>IM 应用如此之广泛,那么,IM 是什么,有些什么要求呢?IM 由两个字组成:Instant,Messaging。即时性要求有新消息时能够立即收到,如果程序在后台,则要能立即收到推送通知。而通信则要求稳定可靠,系统不宕机,程序不崩溃,安全,传递消息时不会被拦截监听,消息不丢,顺序不乱,不重复,如果包含音视频聊天,则要求延迟低,流畅不卡顿。这两点说起来容易,但是,要真正做出一套稳定可靠的商用级IM系统,挑战非常之多。下面,我会挑选影响 IM 即时性和消息稳定性最核心的两个问题,来看看都有哪些障碍,以及云信是如何去实践克服这些障碍的。</p>    <p>首先第一个问题是消息推送,在 iOS 端有 APNS 做推送,相当稳定。Android 本身也有 GCM 可以用,但是在国内,我们有一个叫作“墙”的东西,直接就把 GCM 等 Google 服务全部挡在外面了,导致国内根本无法运用。对于 IM,当 App 退到后台,是必须还能够收到新消息提醒的,没有 GCM,怎么办?唯一能做的,就是后台运行了。Android 从设计上,就是支持真后台运行的,后台运行的特性也是 Android 现在能如此成功的原因之一。但另一面,Android 长久以来一直摆脱不了的卡顿、耗电等坏名声,后台运行也拖不了干系。因此,系统对于后台运行也不会放任自流。App 想要在后台运行,需要面对不少的障碍。</p>    <p>第一个障碍是 Android 的 Low Memory Killer 机制。手机的内存毕竟是有限的,当后台运行的进程越来越多,内存剩余量也就随之减少。当有一个新的 App 想要启动,如果此时内存不够了,LMK 机制就会启动,从正在运行的进程中挑选一个清理掉,释放出空间,然后新的 App 就可以云信了。这个挑选过程显然不会随机拼人品,LMK 有两个尺度去评判。一个是进程优先级,优先级越低,被清理的可能性越大,另一个是内存占用,占的内存越多,被清理的权重自然也越大。</p>    <p>因为 LMK 机制的存在,虽然 App 允许在后台运行,但同样也面临随时被清理的风险。因此,我们需要在被清理后及时的重新启动。常规的,有 4 种方式能够做到:</p>    <ol>     <li>sticky service,就是在 Service 的 onStartCommand 中返回 sticky flag,这样当 service 被 kill 掉后,系统会将它加入重启的 pending 列表,在后面合适的时机再把 service 重启;</li>     <li>alarm,闹钟,有循环闹钟和一次性闹钟两种,在闹钟触发后启动对应的组件;</li>     <li>在 Manifest 文件中静态注册的 Receiver,通过监听各种系统事件,比如开机、网络变化、mount/unmounts 等。在这些事件发生时启动组件,因为这种方式会造成在这些事件发生时系统容易卡顿,在7.0里面,Android增加了限制;</li>     <li>JobScheduler,这是在 5.0 里面新增的,允许 App 在特定事件发生时做一些动作,比如充电、切换到 Wi-Fi 等。</li>    </ol>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/ad709708a16de192dc45197be5b71910.jpg"></p>    <p>虽说无论怎么做,App 终究免不了一死,但通过对照 LMK 的评判准则,我们还是可以降低 App 被清理的概率的。第一个就是降低进程的内存占用。如果采用单进程的模式,由于进程中包含了 UI、Webview、各种图片缓存等内容,内存必然会居高不下,降不下来。IM 软件一般都会采用双进程甚至多进程的策略,将 push 进程独立出来,在 push 进程里只处理网络连接和 push 业务,不参与任何其他业务逻辑,更不包含任何 UI。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/f2e064ebd83a6a99a0e94c0961c0344a.jpg"></p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/d8242b8daa207bfa706eef9edff147c0.jpg"></p>    <p>我们来看一下云信 Demo 的进程内存占用情况。上面一个是主进程,看第四列 PSS 的数据,内存占用是 50M 左右,下面一个是 push 进程,内存占用只有 10M 左右。当处于后台时,push 进程被清理概率比 UI 主进程低很多。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/c0adf5af6c3a85ea5cbc92d4b2f18979.jpg"></p>    <p>降低被清理概率的第二个手段是提升进程优先级。我们先看这个例子,这是绿色守护的一个截图,我们评价其他 App 的行为,也不打广告,所以这里对 icon 和名字做了模糊处理。看最上面,这一组是“暂不自动休眠”,因为这里列出的两个 App 的状态都是工作中,对应的进程优先级是“可视进程”。但这两个 App 并没有提供桌面小部门在运行,也没有指示前台服务的常驻通知栏提醒,事实上,它们就只是在后台运而已。通常进程退到后台后,其进程优先级类型就变成了较低的后台进程,而不是这样的“可视进程”,它们是通过什么方法来提升优先级,降低被清理概率的呢?</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/e4b6240b34e058374772c553a90b378d.jpg"></p>    <p>Android 在设计前台服务上有一个漏洞,通过两个服务配合,我们就能创建一个隐形的前台服务。这里有两个已经启动的 service: A 和 B。先在 A 中调用 startForeground,提供一个 NOTIFY_ID, 然后 A 就变成前台服务了,同时有了一个 ID 为 NOTIFY_ID 的常驻通知栏提醒,然后我们在 B 中也调用 startForeground,提供相同的 NOTIFY_ID, B 也变成了前台服务,因为两个通知 ID 相同,因此这一次就不会创建新的通知栏提醒了。然后再在 A 中调用 stopForeground,A 的前台属性被取消,同时,常驻通知栏提醒也会被移除,但是,service B 并不会受到任何影响,还是前台服务,这是再把 A 停掉,进程就只剩下前台服务 B 了,进程也变成了前台进程,但用户不会有任何感知。</p>    <p>正常来说,做了上面三步之后,我们的进程就能够比较稳定地在后台运行了。但是后来发现,在有些情况下,我们的推送进程却永远起不来。跟踪之后发现,除了系统能够杀掉后台运行的进程外,用户也一样是可以杀死进程的。用户杀掉进程的方式有两种,一种是在最近任务列表中将 App 划掉,这种方式和系统杀掉进程效果相同。另外一种就是通过这里的 force stop,这种方式比系统清理更加彻底。不但 App 正在运行的进程会被清理,App 当前在重启列表中的待重启服务,注册的各种闹钟、事件监听组件等都会被移除,除非用户在主动点击或者系统重启等外力,App 没法再自己重新爬起来了。</p>    <p>我们后来还发现,在有些国内的像 MIUI 一类的 ROM 上,用户从最近任务列表中将 App 移除,效果竟然也是 force stop。正常来说,如果是用户主动操作,我们 App 本身也不应该再重启了。但有些时候这个并不是用户本意。况且,对于 IM 软件来说,消息提送是一定要得到保障的,否则不明正确的吃瓜群众们会觉得是我们软件不行,连消息推送都做不好。因此,这时候还是应该想办法继续维持后台运行。那么,又有哪些办法呢?</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/e793e4b0f9d74398bf7120401fd9e133.jpg"></p>    <p>第一个是通过两次 fork 加上 exec 的方式。两个 fork 后,第一次 fork 的进程退出,第二次 fork 出来的进程就会被 init 进程领养。用户此时再 force stop,因为这个进程父进程是 init,而不是 Zygote,因此不会被清理。由于这个进程还是从 Android 进程 fork 出来的,带有 Android 运行时环境以及父进程的资源,所以内存会比较大。这里可以再通过 exec 命令,打开一个纯 Linux 的可执行文件,开启一个 daemon 进程,其内存占用大概只有100K+,对用户也就完全无感了。利用这个后台进程,可以定时地将 push 进程拉起来。此种方式只在 5.0 以下的系统中有效,在 4.4 及以上系统中,SELinux 特性是强制开启的,exec 没有权限执行,同时在 5.0 之后,ActivityManager 在做 force stop 以及移除任务时,只要是具有相同的 uid 的进程,就会全部清理掉,不再漏掉没有虚拟机环境的进程。</p>    <p>最后一个后台保活的手段是一个大杀器,也是带有强烈的中国特色。因为前面所列的所有保活手段都不是那么保险,因此想出来这么一个互相保活的方式。当一个 App 进程起来后,它就去扫描已安装的应用列表,看看有没有自己的兄弟姐妹。比如说同一个长的 App,或者是集成了同一个 SDK 的 App,如果有,就把这些 App 都拉起来。这也就是现在比较出名的“全家桶”方案。虽说这种方法确实能够带来较高的后台存活率,特别是那些大厂和应用广泛的 SDK,但是这种方式对于用户的伤害也非常大,如果有后台推送的必要性,且不会对用户体验造成太大伤害时,此方式还可以使用,但如果只是为了推广告,则会对用户造成伤害,反过来,也可能会导致用户直接卸载 App。</p>    <p>现在,因为“全家桶”实在是太令人讨厌,现在各种手机管理软件都会对这种唤醒方式做限制,特别是在 Root 过的机器上,可以做到完全切断这些唤醒路径。同时,很多 ROM 也会自带管理软件,限制后台运行和后台唤醒,以便给设备换取更长的续航。在目前国内的 Android 生态环境中,无论采用什么方式,想要一直在后台运行时越来越难了,我们需要重新想另外的办法来保障消息推送。另一方面,我们作为开发者,也有义务为用户提供更好体验的软件,而不是无休止的在后台浪费用户的资源。</p>    <p>其实,对于 IM 来说,及时的消息推送和较低的电量消耗也并非不可兼得。在传统上,每个 IM 客户端都会各自维护一条与服务器的长连接,自己的消息和信令都在这条长连接上传递,每个 App 也独自去心跳,断线重连等事情。这种模式比较简单,不同的 App 也是完全隔离的,不会互相影响。但它的缺点也非常明显,首先是做了很多重复的事情,造成了流量和电量的无谓消耗,第二是要保证所有的进程都能在后台运行很难。优化的方向也就非常明显了,那就是共享连接,现在绝大部分推送 SDK 也是这么做的。</p>    <p>从这些 App 里选出一个当前正在运行的,或者是被杀概率最低的 App 作为总代理,只由这个代理和服务器建立连接,一个手机上的所有其他 App 都通过这个代理中转与服务器通信。但是,IM有一个很基本的要求在这种模式下无法得到满足:安全。所有 App 的消息都经过代理中转,代理到服务器的连接是加密、安全的,但到了代理这里,消息都被解开了,因此代理理论上可以看到其他所有 App 的来往消息。因此,这种共享长连接的方式并不适用于 IM。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/28f0d73eab5b41f973466854b8bfb2be.jpg"></p>    <p><em>长连接+推送</em></p>    <p>虽然共享长连接方式不合适,但仍然给我们提供了一个优化的思路。在此基础上,我们想到了另外一个可以脱敏共享连接的方式:安全长连接加推送连接模式。每个 App 在使用和真正传递数据时,仍然独立使用自己的安全长连接。而当 App 退到后台一段时间之后,则断开长连接,然后每个 App 开启一个推送代理,并选择其中一个和云信的推送服务器建立连接,之后当 App 有新消息时,就通过这个推送连接传递。 App 可以自己控制发出的推送消息的安全级别,可以是包含说话人和消息内容,可以只包含说话人,或者只是一条简单的有新消息到达的提醒文案。推送到达后,如果是代理 App 自己的消息,直接传递给代理 App 即可。如果是其他 App 消息,前面说到过,直接唤醒可能会失败,而且会导致无谓的电量消耗,所以这里并不直接将提醒传递给目标 App ,而是由带来发出一条通知栏提醒。等用户去点击通知栏提醒后,才会把目标 App 唤醒。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/32c68cf94fab704ebb26b883a513e587.jpg"></p>    <p><em>系统推送</em></p>    <p>现在国内的ROM中,华为和小米的系统本来是带有推送系统,且开放给了第三方 App 的。在这两个系统上,使用系统的推送通道明显会更加稳定,也更加节省资源。因此在MIUI上,从长连接到推送通道的切换流程仍然和前面的一样,只是不再使用自己的推送连接,而是将消息转发到MIUI的推送服务器,然后转给MIUI系统的推送代理,然后传递给云信的 App 。华为的推送系统流程也是一样。不过现在华为和MIUI在推送实现上有一些区别,例如MIUI的通知栏提醒是在自己的推送代理里完成的,而华为却是将提醒通知交给 App 自己去完成的,另外,他们的通知栏提醒的管理接口也有很多区别。在 App 没有被禁用的情况下,两者都可以收到推送,而如果 App 已经被禁用了,MIUI的通知栏提醒方式还可以将推送送达,而其他的推送方式则不能送达了。</p>    <p>以上就是在保障消息推送方面我们所能够做的所有事情了。如果以后有更多的系统开放自己的推送系统,我们也可以选择逐步接入,以提高推送到达即时性,减少资源消耗。不过相应的,我们也要承受不断加入各种系统的推送SDK,增大发布包体积的缺点。期望Android拥有统一推送平台的那一天早点到来吧。</p>    <p>相对于PC的网络环境,我总结的手机网络有三个特点:</p>    <ul>     <li>第一个是 <strong>慢</strong> ,尤其是2G,3G网络,慢的令人发指。当我们收发图片视频这类比较大的文件时,就会看到蛋疼的菊花一圈一圈不停的转。</li>     <li>第二个是 <strong>断</strong> ,手机跟着人不停的移动,网络也不停的在切换,从 Wi-Fi 到移动网络,从一个基站到另一个基站,从有信号到没信号,都可能导致网络中断。有些制式的网络,接打电话也会导致数据网络断开。另外,移动基站还有 NAT 超时,到一个连接上长时间空闲后,基站就会默默的将连接断开,没有任何通知。</li>     <li>第三个是 <strong>贵</strong> ,这个就不用多说,看中国移动每天净赚一个亿就知道了。</li>    </ul>    <p>在云信整个通信系统中,我们有三种类型的连接:TCP,UDP,HTTP。虽说这三个并不是同一层的协议,不过毕竟都在我们的应用的更下层,因此这么划分也无妨。3种类型的协议对应了不同的业务应用。TCP主要是用户长连接,也就是普通IM消息和信令的传输,UDP用于传输实时音视频数据流,而HTTP则主要用在音频,图片等文件的上传下载上。对于不同的业务,我们的优化的关注点会有一些不相同。</p>    <p>长连接是云信所有业务的基础,使用量也是最大的,因此优化也是从基础开始。 在这里我们举两个例子。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/0464391f84ab3fcb1cf99060b8027d86.jpg"></p>    <p>第一个是协议的选择。前面说,长连接的使用量是最大,选择一个合适的协议至关重要。如果是刚开始接触IM开发,一般会选择一些开源的协议,比如XMPP,SIP等。这是XMPP协议的一个请求样例,可以看到是一段XML格式的文本数据。这是基于SIP的SIMPLE协议的一个请求样例,可以看到是一段类似HTTP协议的文本数据。这些协议的优势在于开源,有成熟的解决方案可以使用,扩展性好,甚至还可以和其他系统互联互通,协议的可读性也非常好。但是在普遍比较臃肿,冗余字段很多,在昂贵的移动网络里面用起来会让人觉肉疼。云信采用的是私有的二进制协议,这是一个请求的数据样例,这里是把二进制数据转为了16进制显示出来,每个字节这里显示为两个字符。可以看到二进制协议的特点在于完全失去了可读性,但是,却带来极高的表达效率,相对于文本协议,可以节省非常多的数据流量。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/a26623fc423a194e4a7b0af731e5af71.jpg"></p>    <p>另一个例子是登录的优化。由于移动网络经常断开,所以登录常常是心跳之外交互最多的协议了。使用量越大,优化就越有意义。一般而言,登录会经过这么几步。</p>    <p>第一步是LBS。这里的LBS不是经常说的基于地址位置的服务,在不同的厂商可能也有不同的叫法,反正作用都是获取服务器的IP地址。像云信这种需要提供全球服务的系统,在世界各地都要部署服务器,用户登录时,肯定要选择一台最优的服务器接入服务。通过lbs,客户端可以获取离自己最近,连通性最好的服务器连接机IP地址,服务器也可以据此做负载均衡。</p>    <p>拿到服务器连接机IP后,客户端就去连接该服务器。</p>    <p>连接成功,需要有一次握手。这个握手不是TCP的三次握手,而是为了建立安全连接,同服务器协商加密算法和加密密钥。</p>    <p>然后就发送登录请求,这里会带上用户认证信息,本机设备信息等数据。</p>    <p>登录成功之后,就是同步数据,包括离线消息,用户信息,群组信息等。一般而言,这里不会去做全量同步,而是采用基于时间戳的增量同步。</p>    <p>在移动网络上,每一次交互都需要比较长的时间,同时,每一次网络请求电量消耗也是很大的。所以,优化的方向就是尽量减少交互次数,而方法则是合并请求,并行操作以及省略请求。</p>    <p>LBS和连接这两个步骤是可以并行完成的。如果前面已经获取过LBS,这里可以有之前的缓存地址,如果没有,可以先连一个默认地址。</p>    <p>其次是握手和登录也可以并行操作。在握手包中,就可以把加密后的登录包直接带上去了。如果是断线重连,我们还可以简化登录,直接带上上一次登录的会话ID,一来减少服务器鉴权压力,二则可以直接带回在断线期间是否有未读消息等数据,如果没有,则能直接将同步这一步省略掉。如果有,同步也可以只做部分同步,只去拉去离线消息即可。等到 App 切换到前台,才去同步其他的信息。</p>    <p>通过这些优化,登录时间可以降为原来的1/2到1/3,登录的流量消耗也可以节省30%左右。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/93ee11e3c2e532ed34ef0b539c4923c5.jpg"></p>    <p>实时音视频对实时性要求很高,但可以容忍一定的丢包,所以我们选择 UDP 私有协议来作为底层的传输协议。如果只是普通的IM消息,对网络情况其实不是太敏感,最多也就是慢一点,菊花转得久一点。但对于这种视频电话,如果网络差了,发生了经常性卡顿,或者是延迟很高,图像出现花屏,音视频不同步了,这个功能其实也就相当于废弃了。而且,音视频数据量本身也比较大,在弱网环境下发生问题的概率就更大了。</p>    <p>UDP 协议是不可靠,为了提高弱网下的实时音视频的通话效果,需要使用相关方案来做 QoS 保障:主要包括了基于 UDP 协议的拥塞控制、前向纠错 FEC 技术及相关的重传技术。同时网络层需要能够实时的探测到网络状态,作为底层调整 QoS 策略的依据,同时需要回调上层,来动态调整音视频的码率,做到音视频码率自适应。通过上面的 QoS 保障,我们实际测试在 20% 的随机丢包弱网环境下,音视频通话还能够正常进行。</p>    <p>第二是音频,我们的音频编解码主要以Opus为主,它具备高音质,高压缩率,高抗丢包等特性,非常适合移动网络。我们使用智能的jitterbuffer算法来平滑由于网络抖动引起的声音卡顿和延迟累计问题。配合PLC丢包补偿算法,来降低音频丢包后的爆音。同时,我们使用自研的高性能降噪算法,配合回声消除、自动增益和舒适噪音等音频处理算法来进一步保证音频部分的质量。</p>    <p>对于视频,我们使用时域分层的H264视频编码器,来降低丢包对视频流畅性的影响,同时支持动态帧率和动态分辨率,方便上层根据业务需求进行切换。现在用户对于视频的清晰度要求越来越高,我们的实时通话系统当前能够支持720p。720p下纯软件编解码对CPU开销过大,因此在可以开启硬件编解码的机器上,对于需要720p清晰度的都尽量使用硬件编解码。</p>    <p>由于音视频的网络优化如果全部细说,恐怕再加1个小时也讲不完,所以这里我只提了一些优化的方向供大家参考,就不一一展开了。</p>    <p>下面再来看看对于HTTP的优化。图片语音是IM的必需元素,而且本身数据比较大。在弱网环境下,快速的上传下载,更少的等待时间可以带来更好的用户体验。</p>    <p>断点续传可以减少因网络原因导致的重复传输,减少传输时间,节省流量。</p>    <p>图片预加载技术可以根据不用网络情况,在收到消息后,就加载不同素质的预览图片,甚至直接将原图预加载,做到用户点开即看。</p>    <p>上面两个是比较基础的优化措施,下面两个则比较高级一点。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/479d6f23d597435ef06a3bb735dd2d6f.jpg"></p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/6e4ae04c51b085db7f3919ddb18554ed.jpg"></p>    <p>图片和语音这种文件我们并没有通过长连接收发,而是通过HTTP去做上传下载。传统上通过HTTP上传时,文件会分为一片一片,传完一片,收到回包,才会穿下一个分片,一直到最终传输完成。可以看到,服务器返回ack这段时间,上传通道其实是空闲的,如果把这段时间利用起来,可以节约不少上传时间。Pipeline就是为此而来。通过重叠利用http请求的响应等待时间,加快传输速度。使用pipeline,需要修改HttpClient,同时还需要服务器提供支持。视网络具体情况,使用pipeline后,一次上传可以减少20%至30%的时间。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/3192a042e5150dc708a8e5508324e441.jpg"></p>    <p>常规发送语音消息需要这几步,先录音,然后计算hash值,然后上传,上传完毕后,服务器计算一下校验和,通过后语音消息发送成功。在前面录制语音时,网络其实也是空闲的。把这段时间利用起来,则可以减少后面上传步骤的时间。优化后,流程就变成这样。在录制的过程中,每录完一段,就作为一个分片直接上传。直到最后录完,计算好hash,再把最后一个分片带上hash信息上传。这里除了客户端的改动,也是需要服务器支持。服务器在开始接收时,很多信息都不明确,需要开辟缓存来记录整次上传过程。对于比较差的网络,边录边传的效果会更好,毕竟纯语音的比特率并不高,基本都能做到录完就传完。</p>    <p>以上就是我今天分享的全部内容。提升消息推送达到率和到达速度,优化网络利用效率,节省系统资源一直都是Android开发的核心和基础,新技术,新方法都在不停的涌现,也欢迎大家一起讨论,进步,谢谢大家。</p>    <p> </p>    <p> </p>    <p>来自:http://geek.csdn.net/news/detail/106242</p>    <p> </p>