探探的 IM 长连接技术实践:技术选型、架构设计、性能优化
本文由探探服务端高级技术专家张凯宏分享,原题“探探长链接项目的 Go 语言实践”,因原文内容有较多错误,有修订和改动。
1、引言
即时通信长连接服务处于网络接入层,这个领域非常适合用 Go 语言发挥其多协程并行、异步 IO 的特点。
探探自长连接项目上线以后,对服务进行了多次优化:GC 从 5ms 降到 100 微秒(Go 版本均为 1.9 以上),主要gRPC接口调用延时 p999 从 300ms 下降到 5ms。在业内大多把目光聚焦于单机连接数的时候,我们则更聚焦于服务的 SLA(服务可用性)。
本文将要分享的是陌生人社交应用探探的 IM 长连接模块从技术选型到架构设计,再到性能优化的整个技术实践过程和经验总结。
学习交流:
- 移动端 IM 开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源 IM 框架源码:https://github.com/JackJiang2011/MobileIMSDK
2、关于作者
张凯宏:担任探探服务端高级技术专家。
6 年 Go 语言开发经验,曾用 Go 语言构建多个大型 Web 项目,其中涉及网络库、存储服务、长连接服务等。专注于 Go 语言实践、存储服务研发及大数据场景下的 Go 语言深度优化。
3、项目缘起
我们这个项目是 2018 年下半年开始,据今天大概 1 年半时间。
当时探探遇到一些技术痛点,最严重的就是严重依赖第三方 Push,比如说第三方有一些故障的话,对实时 IM 聊天的 KPS 有比较大的影响。
当时通过 push 推送消息,应用内的 push 延时比较高,平均延时五六百毫秒,这个时间我们不能接受。
而且也没有一个 Ping Pland 机制(心跳检查机制?),无法知道用户是否在线。
当时产品和技术同学都觉得是机会搞一个长连接了。
4、一个小插曲
项目大概持续了一个季度时间,首先是拿 IM 业务落地,我们觉得长连接跟 IM 绑定比较紧密一些。
IM 落地之后,后续长连接上线之后,各个业务比较依赖于长连接服务。
这中间有一个小插曲,主要是取名字那一块。
项目之初给项目起名字叫 Socket,看到 socket 比较亲切,觉得它就是一个长连接,这个感觉比较莫名,不知道为什么。但运维提出了异议,觉得 UDP 也是 Socket,我觉得 UDP 其实也可以做长连接。
运维提议叫 Keepcom,这个是出自于 Keep Alive 实现的,这个提议还是挺不错的,最后我们也是用了这个名字。
客户端给的建议是 Longlink,另外一个是 Longconn,一个是 IOS 端技术同事取的、一个是安卓端技术同事取的。
最后我们都败了,运维同学胜了,运维同学觉得,如果名字定不下来就别上线的,最后我们妥协了。
5、为什么要做长连接?
为什么做长连接?
如上图所示:看一下对比挺明显,左边是长连接,右边是短长连接。
对于长连接来说,不需要重新进入连接,或者是释放连接,一个 X 包只需要一个 RTT 就完事。右边对于一个短连接需要三次握手发送一个 push 包,最后做挥手。
结论:如果发送 N 条消息的数据包,对于长连接是 2+N 次的 RTT,对于短连接是 3N 次 RTT,最后开启 Keep Alive,N 是连接的个数。
6、长连接技术优势
我们决结了一下,长连接有以下四大优势:
1)实时性:长连接是双向的通道,对消息的推送也是比较实时;
2)有状态:长连接本身维护用户的状态,通过 KeepAlive 方式,确定用户是否在线;
3)省流程:长连接比较省流量,可以做一些用户自定义的数据压缩,本身也可以省不少的归属包和连接包,所以说比较省流量;
4)更省电:减少网络流量之后,能够进一步降低移动客户端的耗电。
7、TCP 在移动端能胜任吗?
在项目开始之前,我们做了比较多的考量。
首先我们看一下对于移动端的长连接来说,TCP 协议是不是能够 Work?
对于传统的长连接来说,Web 端的长连接 TCP 可以胜任,在移动端来说 TCP 能否胜任?这取决于 TCP 的几个特性。
首先 TCP 有慢启动和滑动窗口的特性,TCP 通过这种方式控制 PU 包,避免网络阻塞。
TCP 连接之后走一个慢启动流程,这个流程从初始窗大小做 2 个 N 次方的扩张,最后到一定的域值,比如域值是 16 包,从 16 包开始逐步往上递增,最后到 24 个数据包,这样达到窗口最大值。
一旦遇到丢包的情况,当然两种情况。一种是快速重传,窗口简单了,相当于是 12 个包的窗口。如果启动一个 RTO 类似于状态连接,窗口一下跌到初始的窗口大小。
如果启动 RTO 重传的话,对于后续包的阻塞蛮严重,一个包阻塞其他包的发送。
(▲ 上图引用自《迈向高阶:优秀Android程序员必知必会的网络基础》)
有关 TCP 协议的基础知识,可以读读以下资料:
8、TCP 还是 UDP?
(▲ 上图引用自《移动端IM/推送系统的协议选型:UDP还是TCP?》)
TCP 实现长连接的四个问题:
1)移动端的消息量还是比较稀疏,用户每次拿到手机之后,发的消息总数比较少,每条消息的间隔比较长。这种情况下 TCP 的间连和保持长链接的优势比较明显一些;
2)弱网条件下丢包率比较高,丢包后 Block 后续数据发送容易阻塞;
3)TCP 连接超时时间过长,默认 1 秒钟,这个由于 TCP 诞生的年代比较早,那会儿网络状态没有现在好,当时定是 1s 的超时,现在可以设的更短一点;
4)在没有快速重传的情况下,RTO 重传等待时间较长,默认 15 分钟,每次是 N 次方的递减。
为何最终还是选择 TCP 呢?因为我们觉得 UDP 更严重一点。
首先 UDP 没有滑动窗口,无流量控制,也没有慢启动的过程,很容易导致丢包,也很容易导致在网络中间状态下丢包和超时。
UDP 一旦丢包之后没有重传机制的,所以我们需要在应用层去实现一个重传机制,这个开发量不是那么大,但是我觉得因为比较偏底层,容易出故障,所以最终选择了 TCP。
TCP 还是 UDP?这一直是个比较有争议的话题:
如果你对 UDP 协议还不了解,可以读读这篇:《TCP/IP详解 - 第11章·UDP:用户数据报协议》。
9、选择 TCP 的更多理由
我们罗列一下,主要有这 3 点:
1)目前在移动端、安卓、IOS 来说,初始窗口大小比较大默认是 10,综合 TCP 慢启动的劣势来看;
2)在普通的文本传输情况下,对于丢包的严重不是很敏感(并不是说传多媒体的数据流,只是传一些文本数据,这一块对于丢包的副作用 TCP 不是特别严重);
3)我们觉得 TCP 在应用层用的比较多。
关于第“3)”点,这里有以下三个考量点。
第一个考量点:
基本现在应用程序走 HTP 协议或者是 push 方式基本都是 TCP,我们觉得 TCP 一般不会出大的问题。
一旦抛弃 TCP 用 UDP 或者是QUIC协议的话,保不齐会出现比较大的问题,短时间解决不了,所以最终用了 TCP。
第二个考量点:
我们的服务在基础层上用哪种方式做 LB,当时有两种选择,一种是传统的 LVS,另一种是 HttpDNS(关于 HttpDNS 请见《全面了解移动端DNS域名劫持等杂症:原理、根源、HttpDNS解决方案等》)。
最后我们选择了 HttpDNS,首先我们还是需要跨机房的 LB 支持,这一点 HttpDNS 完全胜出。其次,如果需要跨网端的话,LVS 做不到,需要其他的部署方式。再者,在扩容方面,LVS 算是略胜一筹。最后,对于一般的 LB 算法,LVS 支持并不好,需要根据用户 ID 的 LB 算法,另外需要一致性哈希的 LB 算法,还需要根据地理位置的定位信息,在这些方面 HttpDNS 都能够完美的胜出,但是 LVS 都做不到。
第三个考量点:
我们在做 TCP 的饱和机制时通过什么样的方式?Ping 包的方式,间隔时间怎么确定,Ping 包的时间细节怎么样确定?
当时比较纠结是客户端主动发 ping 还是服务端主动发 Ping?
对于客户端保活的机制支持更好一些,因为客户端可能会被唤醒,但是客户端进入后台之后可能发不了包。
其次:APP 前后台对于不同的 Ping 包间隔来保活,因为在后台本身处于一种弱在线的状态,并不需要去频繁的发 Ping 包确定在线状态。
所以:在后台的 Ping 包的时间间隔可以长一些,前端可以短一些。
再者:需要 Ping 指数增长的间隔支持,在故障的时候还是比较救命的。
比如说:服务端一旦故障之后,客户端如果拼命 Ping 的话,可能把服务端彻底搞瘫痪了。如果有一个指数级增长的 Ping 包间隔,基本服务端还能缓一缓,这个在故障时比较重要。
最后:Ping 包重试是否需要 Backoff,Ping 包重新发 Ping,如果没有收到 Bang 包的话,需要等到 Backoff 发 Ping。
10、动态 Ping 包时间间隔算法
PS:在 IM 里这其实有个更专业的叫法——“智能心跳算法”。
我们还设计了一个动态的 Ping 包时间间隔算法。
因为国内的网络运营商对于 NIT 设备有一个保活机制,目前基本在 5 分钟以上,5 分钟如果不发包的话,会把你的缓存给删掉。基本上各运营商都在 5 分钟以上,只不过移动 4G 阻碍了。基本可以在 4 到 10 分钟之内发一个 Ping 包就行,可以维持网络运营商设备里的缓存,一直保持着,这样就没有问题,使长连接一直保活着。
增加 Ping 包间隔可以减少网络流量,能够进一步降低客户端的耗电,这一块的受益还是比较大的。
在低端安卓设备的情况下,有一些 DHCP 租期的问题。这个问题集中在安卓端的低版本上,安卓不会去续租过期的 IP。
解决问题也比较简单,在 DHCP 租期到一半的时候,去及时向 DHCP 服务器续租一下就能解决了。
限于篇幅,我就不在这里展开了,有兴趣可以读这些资料:
11、服务架构
11.1 基本介绍
服务架构比较简单,大概是四个模块:
1)首先是 HttpDNS;
2)另一个是 Connector 接入层,接入层提供 IP,
3)然后是 Router,类似于代理转发消息,根据 IP 选择接入层的服务器,最后推到用户;
4)最后还有认证的模块 Account,我们目前只是探探 APP,这个在用户中心实现。
11.2 部署
部署上相当于三个模块:
1)一个是 Dispatcher;
2)一个是 Redis;
3)一个是 Cluser。
如下图所示:客户端在连接的时候:
1)需要拿到一个协议;
2)第二步通过 HttpDNS 拿到 ConnectorIP;
3)通过 IP 连长连接,下一步发送 Auth 消息认证;
4)连接成功,后面发送 Ping 包保活;
5)之后断开连接。
11.3 消息转发流程
消息转发的流程分为两个部分。
首先是消息上行:服务端发起一个消息包,通过 Connector 接入服务,客户端通过 Connector 发送消息,再通过 Connector 把消息发到微服务上,如果不需要微服务的话直接去转发到 Vetor 就行的,这种情况下 Connector 更像一个 Gateway。
对于下行:业务方都需要请求 Router,找到具体的 Connector,根据 Connector 部署消息。
各个公司都是微服务的架构,长连接跟微服务的交互基本两块。一块是消息上行时,更像是 Gateway,下行通过 Router 接入,通过 Connector 发送消息。
11.4 一些实现细节
下面是一些是细节,我们用了 GO 语言 1.13.4,内部消息传输上是gRPC,传输协议是 Http2,我们在内部通过 ETCD 做 LB 的方式,提供服务注册和发现的服务。
如下图所示:Connector 就是状态,它从用户 ID 到连接的一个状态信息。
我们看下图的右边:它其实是存在一个比较大的 MAP,为了防止 MAP 的锁竞争过于严重,把 MAP 拆到 2 到 56 个子 MAP,通过这种方式去实现高读写的 MAP。对于每一个 MAP 从一个 ID 到连接状态的映射关系,每一个连接是一个 Go Ping,实现细节读写是 4KB,这个没改过。
我们看一下 Router:它是一个无状态的 CommonGRPC 服务,它比较容易扩容,现在状态信息都存在 Redis 里面,Redis 大概一组一层,目前峰值是 3000。
我们有两个状态:一个是 Connector,一个是 Router。
首先以 Connector 状态为主,Router 是状态一致的保证。
这个里面分为两种情况:如果连接在同一个 Connector 上的话,Connector 需要保证向 Router 复制的顺序是正确的,如果顺序不一致,会导致 Router 和 Connector 状态不一致。通过统一 Connector 的窗口实现消息一致性,如果跨 Connector 的话,通过在 Redis Lua 脚本实现 Compare And Update 方式,去保证只有自己 Connector 写的状态才能被自己更新,如果是别的 Connector 的话,更新不了其他人的信心。我们保证跨 Connector 和同一 Connector 都能够去按照顺序通过一致的方式更新 Router 里面连接的状态。
Dispatche 比较简单:是一个纯粹的 Common Http API 服务,它提供 Http API,目前延时比较低大概 20 微秒,4 个 CPU 就可以支撑 10 万个并发。
目前通过无单点的结构实现一个高可用:首先是 Http DNS 和 Router,这两个是无障碍的服务,只需要通过 LB 保证。对于 Connector 来说,通过 Http DNS 的客户端主动漂移实现连接层的 Ordfrev,通过这种方式保证一旦一个 Connector 出问题了,客户端可以立马漂到下一个 Connector,去实现自动的工作转移,目前是没有单点的。
12、性能优化
12.1 基本情况
后续有优化主要有以下几个方面:
1)网络优化:这一块拉着客户端一起做,首先客户端需要重传包的时候发三个嗅探包,通过这种方式做一个快速重传的机制,通过这种机制提高快速重传的比例;
2)心跳优化:通过动态的 Ping 包间隔时间,减少 Ping 包的数量,这个还在开发中;
3)防止劫持:是通过客户端使用 IP 直连方式,回避域名劫持的操作;
4)DNS 优化:是通过 HttpDNS 每次返回多个 IP 的方式,来请求客户端的 HttpDNS。
12.2 网络优化
对于接入层来说,其实 Connector 的连接数比较多,并且 Connector 的负载也是比较高。
我们对于 Connector 做了比较大的优化,首先看 Connector 最早的 GC 时间到了 4、5 毫秒,惨不忍睹的。
我们看一下下面这张图(图上)是优化后的结果,大概平均 100 微秒,这算是比较好。第二张图(图下)是第二次优化的结果,大概是 29 微秒,第三张图大概是 20 几微秒。
12.3 消息延迟
看一下消息延迟,探探对 im 消息的延迟要求比较高,特别注重用户的体验。
这一块刚开始大概到 200ms,如果对于一个操作的话,200ms 还是比较严重的。
第一次优化之后(下图-上)的状态大概 1 点几毫秒,第二次优化之后(下图-下)现在降到最低点差不多 100 微秒,跟一般的 Net 操作时间维度上比较接近。
12.4 Connector 优化过程
优化过程是这样的:
1)首先需要关键路径上的 Info 日志,通过采样实现 Access Log,info 日志是接入层比较重的操作;
2)第二通过 Sync.Poll 缓存对象;
3)第三通过 Escape Analysis 对象尽可能在线上分配。
后面还实现了 Connector 的无损发版:这一块比较有价值。长连接刚上线发版比较多,每次发版对于用户来说都有感,通过这种方式让用户尽量无感。
实现了 Connector 的 Graceful Shutdown 的方式,通过这种方式优化连接。
首先:在 HttpDNS 上下线该机器,下线之后缓慢断开用户连接,直到连接数小于一定阈值。后面是重启服务,发版二进制。
最后:是 HttpDNS 上线该机器,通过这种方式实现用户发版,时间比较长,当时测了挺长时间,去衡量每秒钟断开多少个连接,最后阈值是多少。
后面是一些数据:刚才 GC 也是一部分,目前连接数都属于比较关键的数据。首先看连接数单机连接数比较少,不敢放太开,最多是 15 万的单机连接数,大约 100 微秒。
Goroutine 数量跟连接数一样,差不多 15 万个:
看一下内存使用状态,下图(上)是 GO 的内存总量,大概是 2:3,剩下五分之一是属于未占用,内存总量是 7.3 个 G。
下图是 GC 状态,GC 比较健康,红线是 GC 每次活跃内存数,红线远远高于绿线。
看到 GC 目前的状况大概是 20 几微秒,感觉目前跟 GO 的官方时间比较能对得上,我们感觉 GC 目前都已经优化到位了。
12.5 后续要做的优化
最后是规划后续还要做优化。
首先:对系统上还是需要更多优化 Connector 层,更多去减少内存的分配,尽量把内存分配到堆上而不是站上,通过这种方式减少 GC 压力,我们看到 GO 是非 Generational Collection GE,堆的内存越多的话,扫的内存也会越多,这样它不是一个线性的增长。
第二:在内部更多去用 Sync Pool 做短暂的内存分配,比如说 Context 或者是临时的 Dbyle。
协议也要做优化:目前用的是 WebSocket 协议,后面会加一些功能标志,把一些重要信息传给服务端。比如说一些重传标志,如果客户端加入重传标志的话,我们可以先校验这个包是不是重传包,如果是重传包的话会去判断这个包是不是重复,是不是之前发过,如果发过的话就不需要去解包,这样可以少做很多的服务端操作。
另外:可以去把 Websocket 目前的 Mask 机制去掉,因为 Mask 机制防止 Web 端的改包操作,但是基本是客户端的传包,所以并不需要 Mask 机制。
业务上:目前规划后面需要做比较多的事情。我们觉得长连接因为是一个接入层,是一个非常好的地方去统计一些客户端的分布。比如说客户端的安卓、IOS 的分布状况。
进一步:可以做用户画像的统计,男的女的,年龄是多少,地理位置是多少。大概是这些,谢谢!
13、热门问题回复
* 提问:刚才说连接层对话重启,间接的过程中那些断掉的用户就飘到其他的,是这样做的吗?
张凯宏:目前是这样的,客户端做自动飘移。
* 提问:现在是 1 千万日活,如果服务端往客户端一下推 100 万,这种场景怎么做的?
张凯宏:目前我们没有那么大的消息推送量,有时候会发一些业务相关的推送,目前做了一个限流,通过客户端限流实现的,大概三四千。
* 提问:如果做到后端,意味着会存在安全隐患,攻击者会不停的建立连接,导致很难去做防御,会有这个问题吗?因为恶意的攻击,如果攻击的话建立连接就可以了,不需要认证的机制。
张凯宏:明白你的意思,这一块不只是长连接,短连接也有这个问题。客户端一直在伪造访问结果,流量还是比较大的,这一块靠防火墙和 IP 层防火墙实现。
* 提问:长连接服务器是挂在最外方,中间有没有一层?
张凯宏:目前接着如下层直接暴露在外网层,前面过一层 IP 的防 DNSFre 的防火墙。除此之外没有别的网络设备了。
* 提问:基于什么样的考虑中间没有加一层,因为前面还加了一层的情况。
张凯宏:目前没有这个计划,后面会在 Websofte 接入层前面加个 LS 层可以方便扩容,这个收益不是特别大,所以现在没有去计划。
* 提问:刚刚说的断开重传的三次嗅探那个是什么意思?
张凯宏:我们想更多的去触发快速重传,这样对于 TCP 的重传间隔更短一些,服务端根据三个循环包判断是否快速重传,我们会发三个循环包避免一个 RTO 重传的开启。
* 提问:探探最开始安卓服务器是使用第三方的吗?
张凯宏:对的,刚开始是极光推送的。
* 提问:从第三方的安卓服务器到自研。
张凯宏:如果极光有一些故障的话,对我们影响还是蛮大。之前极光的故障频率挺高,我们想是不是自己能把服务做起来。第二点,极光本身能提供一个用户是否在线的判断,但是它那个判断要走通道,延时比较高,本身判断是连接把延时降低一些。
* 提问:比如说一个新用户上线连接过来,有一些用户发给他消息,他是怎么把一线消息拿到的?
张凯宏:我们通过业务端保证的,未发出来的消息会存一个 ID 号,当用户重新连的时候,业务端再拉一下。
14、参考资料
[3] 为何基于TCP协议的移动端IM仍然需要心跳保活机制?
[4] 一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等
[5] 微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)
[6] 移动端IM实践:实现Android版微信的智能心跳机制
[7] 迈向高阶:优秀Android程序员必知必会的网络基础
[8] 全面了解移动端DNS域名劫持等杂症:原理、根源、HttpDNS解决方案等
[9] 技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解
[10] 新手入门一篇就够:从零开发移动端IM
[11] 长连接网关技术专题(二):知乎千万级并发的高性能长连接网关技术实践
[12] 长连接网关技术专题(三):手淘亿级移动端接入层网关的技术演进之路
[13] 长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践
[14] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等
[15] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等
[16] 从新手到专家:如何设计一套亿级消息量的分布式IM系统
(本文已同步发布于:http://www.52im.net/thread-3780-1-1.html)
评论