探探长链接项目的 Go 语言实践

长链接服务处于网络接入层,这个领域非常适合用Go语言发挥其多协程并行,异步IO特点。探探自长链接项目上线以后,对服务进行了多次优化:GC从5ms降到100微秒(Go版本均为1.9以上),主要Grpc接口调用延时p999从300ms下降到5ms。在业内大多把目光聚焦于单机连接数的时候,我们则更聚焦于服务的SLA。

张凯宏|探探高级技术专家 

担任探探服务端高级技术专家。6年Go语言开发经验,曾用Go语言构建多个大型Web项目,其中涉及网络库、存储服务、长链接服务等。专注于Go语言实践、存储服务研发及大数据场景下的Go语言深度优化。

01

项目背景

缘起

我们这个项目是2018年下半年开始,据今天大概1年半时间。当时探探遇到几个问题,首先是比较严重依赖第三方Push,比如说第三方有一些故障的话,对聊天的KPS有比较大的影响。当时通过push推送消息,应用内的push延时比较高,平均延时五六百毫秒,这个时间我们不能接受。   

当时没有一个 Ping Pland 机制,无法知道用户是否在线。当时产品和技术同学都觉得是机会搞一个长链接了。

项目经历

项目大概持续了一个季度时间,首先是拿IM业务落地,我们觉得长链接跟IM绑定比较紧密一些。IM落地之后,后续长链接上线之后,各个业务比较依赖于长链接服务。这中间有一个小插曲,主要是取名字那一块。项目之初给项目起名字叫Socket,看到socket比较亲切,觉得它就是一个长链接,这个感觉比较莫名,不知道为什么。运维提出了异议,觉得UDP也是Socket,我觉得UDP其实也可以做长链接。运维提议叫Keppcom,这个是出自于Keep Alive实现的,这个提议还是挺不错的,最后我们也是用了这个名字。客户端给的建议是Longlink,另外一个是Longconn,一个是IOS版,一个是安卓版。最后我们都败了,运维同学胜了,运维同学觉得,如果名字定不下来就别上线的,最后我们妥协了。

做长链接的原因

为什么做长链接?看一下对比挺明显,左边是长链接,右边是短长链接。对于长链接来说,不需要重新进入链接,或者是释放链接,一个X包只需要一个RTT就完事。右边对于一个短链接需要三次握手发送一个push包,最后做挥手。

结论,如果发送N条消息的数据包,对于长链接是2+N次的RTT,对于短链接是3N次RTT,最后开启Keep Alive,N是链接的个数。

长链接的四大优势

第一,实时性方面,长链接是双向的通道,对消息的推送也是比较实时。

第二,长链接本身维护用户的状态,通过KeepAlive方式,确定用户是否在线。

第三,长链接比较省流量,可以做一些用户自定义的数据压缩,本身也可以省不少的归属包和连接包,所以说比较省流量。在这个前提下,客户端就比较省量了。

第四,减少网络流量之后,能够进一步降低客户端的耗电。

02

设计细节

想说一些设计细节,在项目开始之前做了比较多的考量,最终定位一个详细设计的报告。首先我们看一下对于移动端的长链接来说,TCP协议是不是能够Work?在传统的长链接来说,Web端的长链接TCP可以胜任,在移动端来说TCP能否胜任?取决于TCP几个特性,首先TCP有慢启动和滑动窗口的特性,TCP通过这种方式控制PU包,避免网络阻塞。

TCP链接之后走一个慢启动流程,这个流程从初始窗大小做2个N次方的扩张,最后到一定的域值,比如域值是16包,从16包开始逐步往上递增,最后到24个数据包,这样达到窗口最大值。一旦遇到丢包的情况,当然两种情况。一种是快速重传,窗口简单了,相当于是12个包的窗口。如果启动一个RTO类似于状态链接,窗口一下跌到初始的窗口大小。

如果启动RTO重传的话,对于后续包的阻塞蛮严重,一个包阻塞其他包的发送。

03

TCP实现长链接的四个问题

1、移动端的消息量还是比较稀疏,用户每次拿到手机之后,发的消息总数比较少,每条消息的间隔比较长。这种情况下TCP的间连和保持长链接的优势比较明显一些。

2、弱网条件下丢包率比较高,丢包后Block后续数据发送容易阻塞。

3、TCP连接超时时间过长,默认1秒钟,这个由于TCP诞生的年代比较早,那会儿网络状态没有现在好,当时定是1s的超时,现在可以设的更短一点。

4、在没有快速重传的情况下,RTO重传等待时间较长,默认15分钟,每次是N次方的递减。

为何最终还是选择TCP呢?因为我们觉得UDP更严重一点。首先UDP没有滑动窗口,无流量控制,也没有慢启动的过程,很容易导致丢包,也很容易导致在网络中间状态下丢包和超时。

UDP一旦丢包之后没有重传机制的,所以我们需要在应用层去实现一个重传机制,这个开发量不是那么大,但是我觉得因为比较偏底层,容易出故障,所以最终选择了TCP。

04

选择TCP的三个理由

1、目前在移动端、安卓、IOS来说,初始窗口大小比较大默认是10,综合TCP慢启动的劣势来看。

2、在普通的文本传输情况下,对于丢包的严重不是很敏感,并不是说传多媒体的数据流,只是传一些文本数据,这一块对于丢包的副作用TCP不是特别严重。

3、我们觉得TCP在应用层用的比较多,这里有三个考量点。第一个考量点:基本现在应用程序走HTP协议或者是push方式基本都是TCP,我们觉得TCP一般不会出大的问题。一旦抛弃TCP用UDP或者是Q协议的话,保不齐会出现比较大的问题,短时间解决不了,所以最终用了TCP。第二个考量点:我们的服务在基础层上用哪种方式做LB,当时有两种选择,一种是传统的LVS,另一种是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。

05

动态Ping包时间间隔算法

我们还设计了一个动态的Ping包时间间隔算法,国内的网络运营商对于NIT设备有一个保活机制,目前基本在5分钟以上,5分钟如果不发包的话,会把你的缓存给删掉。基本运营商都在5分钟以上,只不过移动4G阻碍了。基本可以在4到10分钟之内发一个Ping包就行,可以维持网络运营商设备里的缓存,一直保持着,这样就没有问题,使长链接一直保活着。可以减少网络流量,能够进一步降低客户端的耗电,这一块的受益还是比较大的。

在低端安卓设备的情况下,有一些DHCP租期的问题,这个问题集中在安卓端的低版本上,安卓不会去续租过期的IP。 解决问题也比较简单,在DHCP租期到一半的时候,去及时向DHCP服务器续租一下就能解决了。

下一个设计的细节对于长链接来说是比较关键的,一般分为两个部分,一个是Header,还有Payload。Header一般是硬程,Payload是编程,当然Header也是有编程的,Header协议基本组成里面丢包括Header里面,Header会包括一些比较重要的控制字段和Flack字段,这些字段首先会按数据包的功能是哪几个,并且会做出一些功能的取舍。对于协议来说,还需要考虑扩展性和安全性。

我们现在用的是Websocket,Websocket协议跟着Web1.1诞生,时间比较早,有些特性用不着了。 Websocket协议首先是Finolway,这个Way表示数据包是不是一个结束包,刚才三个RCEWay主要是扩展,后面四个Way主要是表示这个包是做什么用的,比如说是连接包、调开包或者是push的消息包,后面包是Mask包,MaskWay就是表示这个包是不是经过数据的Mask操作,后面是7个位的Payload长度,如果不够用的话,后面还有10个位的Payload长度,如果Payload长度小于127位的话。

MOTT协议稍微复杂一些,我们看到MOTT本身支持变程的Header,我们这边是定程的Header。 左边是Header详细协议,左边有四位是指定Context包的信息,它是一个间连的包还是断连的包,右边是四个Falway,目前基本的MOTT协议就用到四个,后面跟着两个是QS,QS等级是多少,MOTT一般三个等级,最少一次,最多一次,正好一次的三个语义,最后一个V是保留V,这个消息是不是保持在服务端,以便下一次再连接的时候,传给客户端。

对于MQTT协议来说,本身涉及比较地道的地方,一般看到长连接会实现正好一次传输的语义,对这个语义做了简化。 因为MQTT这种实现更复杂一些,很难理解,我们做了简化。

比方说我们认为一个语义这么实现,客户端反复重试,直到服务端收到为止。服务端通过消息的ID去做屈从,服务端保证AT Mose Once,客户端保证至少一次,这样就能够输出正好一次的语义,这样算是比较简单。我们的消息分为两种类型,一个是上位消息,从发送方和客户端推到业务端去,这种方式客户端就是发送方,由客户端保证AT Least Once的语义。通过传输链路最低端,业务微服务保证AT LeastOnce的语义。对于下行消息,一般业务方发送的时候也会保证最少一次的语义,这样实现在传输链路上各个端都保证至少一次语义的话,需要在接收方去保证最多一次就行了。下面是服务架构。

服务架构比较简单,大概是四个模块。

首先是HttpDNS,一个是Connector接入层,接入层提供IP,然后是Router,类似于代理转发消息,根据IP选择接入层的服务器,最后推到用户。

最后还有认证的模块Account,我们目前只是探探APP,这个在用户中心实现。

部署上相当于三个模块,一个是Dispatcher,一个是Redis,一个是Cluser。   

客户端在连接的时候,需要拿到一个协议,第二步通过HttpDNS拿到ConnectorIP,通过IP连长链接,下一步发送Auth消息认证,连接成功,后面发送Ping包保活,之后断开连接。

后面是消息转发的流程,分为两个部分。

首先是消息上行,服务端发起一个消息包,通过Connector接入服务,客户端通过Connector发送消息,再通过Connector把消息发到微服务上,如果不需要微服务的话直接去转发到Vetor就行的,这种情况下Connector更像一个Gateway。

对于下行,业务方都需要请求Router,找到具体的Connector,根据Connector部署消息。

各个公司都是微服务的架构,长链接跟微服务的交互基本两块,一块是消息上行时,更像是Gateway,下行通过Router接入,通过Connector发送消息。

下面是一些是细节,我们用了GO语言1.13.4,内部消息传输上是gRPC,传输协议是Http2,我们在内部通过ETCD做LB的方式,提供服务注册和发现的服务。

这是Connector的一个内室图,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,去实现自动的工作转移,目前是没有单点的。         

06

后续优化

第一,网络优化;这一块拉着客户端一起做,首先客户端需要重传包的时候发三个嗅探包,通过这种方式做一个快速重传的机制,通过这种机制提高快速重传的比例。

第二个是通过动态的Ping包间隔时间,减少Ping包的数量,这个还在开发中。

第三个是通过客户端使用IP直连方式,回避域名劫持的操作。

第四个是通过HttpDNS每次返回多个IP的方式,来请求客户端的HttpDNS。

对于接入层来说,其实Connector的连接数比较多,并且Connector的负载也是比较高。 我们对于Connector做了比较大的优化,首先看Connector最早的GC时间到了4、5毫秒,惨不忍睹的。 我们看一下这张图是优化后的结果,大概平均100微秒,这算是比较好。 第二张图是第二次优化的结果,大概是29微秒,第三张图大概是20几微秒。

看一下消息延迟,探探对消息的延迟要求比较高,特别注重用户的体验。 这一块刚开始大概到200ms,如果对于一个操作的话,200ms还是比较严重的。 第一次优化之后上一张图的状态大概1点几毫秒,第二次优化之后现在降到最低点差不多100微秒,跟一般的Net操作时间维度上比较接近。

优化过程是这样的:

首先需要关键路径上的Info日志,通过采样实现Access Log,info日志是接入层比较重的操作;

第二通过Sync.Poll缓存对象;

第三通过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目前都已经优化到位了。

最后是后续要做的事情,规划后续还要做优化,首先对系统上还是需要更多优化Connector层,更多去减少内存的分配,尽量把内存分配到堆上而不是站上,通过这种方式减少GC压力,我们看到GO是非Generational Collection GE,堆的内存越多的话,扫的内存也会越多,这样它不是一个线性的增长。

第二,在内部更多去用Sync Pool做短暂的内存分配,比如说Context或者是临时的Dbyle。

协议也要做优化,目前用的是Websocket协议,后面会加一些功能标志,把一些重要信息传给服务端。比如说一些重传标志,如果客户端加入重传标志的话,我们可以先校验这个包是不是重传包,如果是重传包的话会去判断这个包是不是重复,是不是之前发过,如果发过的话就不需要去解包,这样可以少做很多的服务端操作。

第二点,可以去把Websocket目前的Mask机制去掉,因为Mask机制防止Web端的改包操作,但是基本是客户端的传包,所以并不需要Mask机制。

业务上,目前规划后面需要做比较多的事情。 我们觉得长连接因为是一个接入层,是一个非常好的地方去统计一些客户端的分布。 比如说客户端的安卓、IOS的分布状况。

第二步可以做用户画像的统计,男的女的,年龄是多少,地理位置是多少。大概是这些,谢谢!

提问:刚才说连接层对话重启,间接的过程中那些断掉的用户就飘到其他的,是这样做的吗?

张凯宏: 目前是这样的,客户端做自动飘移。

提问: 现在是1千万日活,如果服务端往客户端一下推100万,这种场景怎么做的?

张凯宏: 目前我们没有那么大的消息推送量,有时候会发一些业务相关的推送,目前做了一个限流,通过客户端限流实现的,大概三四千。

提问: 如果做到后端,意味着会存在安全隐患,攻击者会不停的建立连接,导致很难去做防御,会有这个问题吗? 因为恶意的攻击,如果攻击的话建立连接就可以了,不需要认证的机制。

张凯宏:明白你的意思,这一块不只是长链接,短链接也有这个问题。客户端一直在伪造访问结果,流量还是比较大的,这一块靠防火墙和IP层防火墙实现。

提问: 长链接服务器是挂在最外方,中间有没有一层?

张凯宏:目前接着如下层直接暴露在外网层,前面过一层IP的防DNSFre的防火墙。除此之外没有别的网络设备了。

提问: 基于什么样的考虑中间没有加一层,因为前面还加了一层的情况。

张凯宏:目前没有这个计划,后面会在Websofte接入层前面加个LS层可以方便扩容,这个收益不是特别大,所以现在没有去计划。

提问: 刚刚说的断开重传的三次嗅探那个是什么意思?

张凯宏:我们想更多的去触发快速重传,这样对于TCP的重传间隔更短一些,服务端根据三个循环包判断是否快速重传,我们会发三个循环包避免一个RTO重传的开启。

提问: 探探最开始安卓服务器是使用第三方的吗?

张凯宏:对的,刚开始是极光推送的。

提问: 从第三方的安卓服务器到自研。

张凯宏:如果极光有一些故障的话,对我们影响还是蛮大。之前极光的故障频率挺高,我们想是不是自己能把服务做起来。第二点,极光本身能提供一个用户是否在线的判断,但是它那个判断要走通道,延时比较高,本身判断是通过用户的Ping包来做权衡,我们觉得偏高一些,我们想自己实现长链接把延时降低一些。

提问: 比如说一个新用户上线连接过来,有一些用户发给他消息,他是怎么把一线消息拿到的?

张凯宏:我们通过业务端保证的,未发出来的消息会存一个ID号,当用户重新连的时候,业务端再拉一下。