七叶笔记 » golang编程 » Go语言「消息服务」单实例300w长连接是怎么做到的?

Go语言「消息服务」单实例300w长连接是怎么做到的?

分享人:全栈技术管理员之一,花椒直播技术总监周洋。

今天晚上内容分成以下几个部分:

  • 关于push系统对比与性能指标的讨论~
  • 消息系统架构介绍
  • 哪些因素决定了推送系统的效果?
  • go语言开发问题与解决方案
  • 消息系统的运维,测试

360消息系统更确切的说是 长连接 push系统,目前服务于360内部50多个产品,开发平台数千款app,也支持部分聊天业务场景,单通道多app复用,支持上行数据,提供接入方不同粒度的上行数据和用户状态回调服务。

目前整个系统按不同业务分成9个功能完整的集群,部署在10个idc上(每个集群覆盖不同的idc),实时在线2亿量级.

通常情况下,pc,手机,甚至是智能硬件上的360产品的push消息,基本上是从我们系统发出的~ 期望大家收到的push都是自己关心的… 如果不是,请相信,“我们不生产消息,我们只是消息的搬运工”~

很多同行比较关心go语言在实现push系统上的性能问题,单机性能究竟如何,能否和其他语言实现的类似系统做对比么?甚至问如果是创业,第三方云推送平台,推荐哪个? (广告招商~)

其实各大厂都有类似的push系统,市场上也有类似功能的云服务。包括我们公司早期也有erlang,nodejs实现的类似系统,也一度被公司要求做类似的对比测试。

我感觉在讨论对比数据的时候,很难保证大家环境和需求的统一,我只能说下我这里的体会,数据是有的,但这个数据前面估计会有很多定语~

比如一个重要指标:单机的连接数指标~

做过长连接的同行,应该有体会,如果在稳定连接情况下,连接数这个指标,在没有网络吞吐情况下对比,其实意义往往不大,维持连接消耗cpu资源很小,每条连接tcp协议栈会占约4k的内存开销,系统参数调整后,我们单机测试数据,最高也是可以达到单实例300w长连接。

但做更高的测试,我个人感觉意义不大。因为实际网络环境下,单实例300w长连接,从理论上算压力就很大:因为实际弱网络环境下,移动客户端的断线率很高,假设每秒有1000分之一的用户断线重连。300w长连接,每秒新建连接达到3w,这同时连入的3w用户,要进行注册,加载离线存储等对内 rpc 调用,另外300w长连接的用户心跳需要维持,假设心跳300s一次,心跳包每秒需要1w tps。

单播和多播数据的转发,广播数据的转发,本身也要响应内部的rpc调用,300w长连接情况下,gc带来的压力,内部接口的响应延迟能否稳定保障。

这些集中在一个实例中,可用性是一个挑战。所以线上单实例不会hold很高的长连接,实际情况也要根据接入客户端网络状况来决定。

第二个重要指标: 消息系统的内存使用量指标~

这一点上,使用go语言情况下,由于协程的原因,会有一部分额外开销。但是要做两个推送系统的对比,也有些需要确定问题。

比如系统从设计上是否需要全双工(即读写是否需要同时进行)如果半双工,理论上对一个用户的连接只需要使用一个协程即可(这种情况下,对用户的断线检测可能会有延时),如果是全双工,那读/写各一个协程。两种场景内存开销是有区别的.

另外测试数据的大小往往决定我们对连接上设置的读写buffer是多大,是全局复用的,还是每个连接上独享的,还是动态申请的。另外是否全双工也决定buffer怎么开. 不同的策略,可能在不同情况的测试中表现不一样。

第三个指标 每秒消息下发量

这一点上,也要看我们对消息到达的QoS级别(回复ack策略区别),另外架构策略,每种策略有其更适用的场景,是纯粹推,还是推拉结合。甚至是否开启了消息日志,日志库的实现机制,缓冲开多大,flush策略,这些都影响整个系统的吞吐量。另外为了HA,增加了内部通信成本,为了避免一些小概率事件,提供闪断补偿策略,这些都要考虑进去。如果所有的都去掉,那就是比较基础库的性能了.

所以我只能给出大概数据,24核,64G的服务器上,在Qos为message at least,纯粹推的情况,消息体256B~1kB情况下,单个实例100w实际用户(200w+)协程,峰值可以达到2~5w的QPS…

内存可以稳定在25G左右,gc时间在200~800ms左右.(还有优化空间) 我们正常线上单实例用户控制在80w以内,单机最多两个实例~

事实上,整个系统在推送的需求上,对高峰的输出不是提速,往往是进行限速,以防push系统瞬时的高吞吐量,转化成对接入方业务服务器的ddos攻击

所以对于性能上,我感觉大家可以放心使用,至少在我们这个量级上,经受过考验,go1.5到来后,确实有之前投资又增值了的感觉~

下面是对消息系统的大概介绍,之前一些同学可能在gopher china上可以看到分享,这里简单讲解下架构和各个组件功能,额外补充一些当时遗漏的信息:

架构图如下,所有的service都 written by golang.

几个大概重要组件介绍如下:

  1. dispatcher service,根据客户端请求信息,将应网络和区域的长连接服务器的,一组IP传送给客户端。客户端根据返回的IP,建立长连接,连接Room service.
  2. room Service,长连接网关,hold用户连接,并将用户注册进register service,本身也做一些接入安全策略、白名单、IP限制等
  3. register service 是我们全局session存储组件,存储和索引用户的相关信息,以供获取和查询。
  4. coordinator service 用来转发用户的上行数据,包括接入方订阅的用户状态信息的回调,另外做需要协调各个组件的异步操作,比如kick用户操作,需要从register拿出其他用户做异步操作.
  5. saver service 是存储访问层,承担了对redis和mysql的操作,另外也提供部分业务逻辑相关的内存 缓存 ,比如广播信息的加载可以在saver中进行缓存。另外一些策略,比如客户端sdk由于被恶意或者意外修改,每次加载了消息,不回复ack,那服务端就不会删除消息,消息就会被反复加载,形成死循环,可以通过在saver中做策略和判断。(客户端总是不可信的)。
  6. center service 提供给接入方的内部api服务器,比如单播或者广播接口,状态查询接口等一系列api,包括运维和管理的api.

举两个常见例子,了解工作机制

比如发一条单播给一个用户,center先请求Register获取这个用户之前注册的连接通道标识、room实例地址,通过room service下发给长连接。

Center Service比较重的工作如全网广播,需要把所有的任务分解成一系列的子任务,分发给所有center,然后在所有的子任务里,分别获取在线和离线的所有用户,再批量推到Room Service。通常整个集群在那一瞬间压力很大。

7.deployd/agent service 用于部署管理各个进程,收集各组件的状态和信息,zookeeper和keeper用于整个系统的配置文件管理和简单调度

关于推送的服务端架构?

常见的推送模型有长轮训拉取,服务端直接推送(360消息系统目前主要是这种),推拉结合(推送只发通知,推送后根据通知去拉取消息).

拉取的方式不说了,现在并不常用了,早期很多是nginx+lua+redis,长轮训,主要问题是开销比较大,时效性也不好, 能做的优化策略不多。

直接推送的系统,目前就是360消息系统这种,消息类型是消耗型的,并且对于同一个用户并不允许重复消耗,如果需要多终端重复消耗,需要抽象成不同用户。

推的好处是实时性好,开销小,直接将消息下发给客户端,不需要客户端走从接入层到存储层主动拉取.

但纯推送模型,有个很大问题,由于系统是异步的,他的时序性无法精确保证。这对于push需求来说是够用的,但如果复用推送系统做im类型通信,可能并不合适。

对于严格要求时序性,消息可以重复消耗的系统,目前也都是走推拉结合的模型,就是只使用我们的推送系统发通知,并附带id等给客户端做拉取的判断策略,客户端根据推送的key,主动从业务服务器拉取消息。并且当主从同步延迟的时候,跟进推送的key做延迟拉取策略. 同时也可以通过消息本身的QoS,做纯粹的推送策略,比如一些“正在打字的”低优先级消息,不需要主动拉取了,通过推送直接消耗掉。

这里还有一个常见话题,哪些因素决定推送系统的效果?

首先是sdk的完善程度,sdk策略和细节完善度,往往决定了弱网络环境下最终推送质量.

1.SDK选路策略,最基本的一些策略如下:

有些开源服务可能会针对用户hash一个该接入区域的固定ip,实际上在国内环境下不可行,最好分配器(dispatcher)是返回散列的一组,而且端口也要参开,必要时候,客户端告知是retry多组都连不上,返回不同idc的服务器。因为我们会经常检测到一些case,同一地区的不同用户,可能对同一idc内的不同ip连通性都不一样,也出现过同一ip不同端口连通性不同,所以用户的选路策略一定要灵活,策略要足够完善.

另外在选路过程中,客户端要对不同网络情况下的长连接ip做缓存,当网络环境切换时候(wifi 2G,3G),重新请求分配器,缓存不同网络环境的长连接ip.

2.客户端对于数据心跳和读写超时设置,完善断线检测重连机制

针对不同网络环境,或者客户端本身消息的活跃程度,心跳要自适应的进行调整并与服务端协商,来保证链路的连通性。并且在弱网络环境下,除了网络切换(wifi切3G)或者读写出错情况,什么时候重新建立链路也是一个问题。客户端发出的ping包,不同网络下,多久没有得到响应,认为网络出现问题,重新建立链路需要有个权衡。另外对于不同网络环境下,读取不同的消息长度,也要有不同的容忍时间,不能一刀切。好的心跳和读写超时设置,可以让客户端最快的检测到网络问题,重新建立链路,同时在网络抖动情况下也能完成大数据传输。

3.结合服务端做策略

另外系统可能结合服务端做一些特殊的策略,比如我们在选路时候,我们会将同一个用户尽量映射到同一个room service实例上。断线时,客户端尽量对上次连接成功的地址进行重试。主要是方便服务端做闪断情况下策略,会暂存用户闪断时实例上的信息,重新连入的时候,做单实例内的迁移,减少延时与加载开销.

4.客户端保活策略

很多创业公司愿意重新搭建一套push系统,确实不难实现,其实在协议完备情况下(最简单就是客户端不回ack不清数据),服务端会保证消息是不丢的。但问题是为什么在消息有效期内,到达率上不去?

往往因为自己app的push service存活能力不高。选用云平台或者大厂的,往往sdk会做一些保活策略,比如和其他app共生,互相唤醒,这也是云平台的push service更有保障原因~ 我相信很多云平台旗下的sdk,多个使用同样sdk的app,为了实现服务存活,是可以互相唤醒和保证活跃的~ 另外现在push sdk本身是单连接,多app复用的,这为sdk实现,增加了新的挑战~

综上,对我来说,选择推送平台,优先会考虑客户端sdk的完善程度~ 对于服务端,选择条件稍微简单,要求部署接入点(IDC)越要多,配合精细的选路策略,效果越有保证,至于想知道哪些云服务有多少点,这个群里来自各地的小伙伴们,可以合伙测测~

这个系统在开发过程中遇到挑战和优化策略,给大家看下当年的一张图,在第一版优化方案上线前一天截图~

可以看到,内存最高占用69G,GC时间单实例最高时候高达3~6s.这种情况下,试想一次悲剧的请求,经过了几个正在执行gc的组件,后果必然是超时… gc照成的接入方重试,又加重了系统的负担。遇到这种情况当时整个系统最差情况每隔2,3天就需要重启一次~

当时出现问题,现在总结起来,大概以下几点

1.散落在协程里的I/O,Buffer和对象不复用。

当时由于对go的gc效率理解有限,比较奔放,程序里大量short live的协程,对内通信的很多io操作,由于不想阻塞主循环逻辑或者需要及时响应的逻辑,通过单独go协程来实现异步。这回会gc带来很多负担。

针对这个问题,应尽量控制协程创建,对于长连接这种应用,本身已经有几百万并发协程情况下,很多情况没必要在各个并发协程内部做异步io,因为程序的并行度是有限,理论上做协程内做阻塞操作是没问题。

如果有些需要异步执行,比如如果不异步执行,影响对用户心跳或者等待response无法响应,最好通过一个任务池,和一组常驻协程,来消耗,处理结果,通过channel再传回调用方. 使用任务池还有额外的好处,可以对请求进行打包处理,提高吞吐量,并且可以加入控量策略.

2.网络环境不好引起激增

go协程相比较以往高并发程序,如果做不好 流控 ,会引起协程数量激增。早期的时候也会发现,时不时有部分主机内存会远远大于其他服务器,但发现时候,所有主要profiling参数都正常了。

后来发现,通信较多系统中,网络抖动阻塞是不可免的(即使是内网),对外不停accept接受新请求,但执行过程中,由于对内通信阻塞,大量协程被创建,业务协程等待通信结果没有释放,往往瞬时会迎来协程暴涨. 但这些内存在系统稳定后,virt和res都并没能彻底释放,下降后,维持高位。

处理这种情况,需要增加一些流控策略,流控策略可以选择在rpc库来做,或者上面说的任务池来做,其实我感觉放在任务池里做更合理些,毕竟rpc通信库可以做读写数据的限流,但它并不清楚具体的限流策略,到底是重试还是日志还是缓存到指定队列。任务池本身就是业务逻辑相关的,它清楚针对不同的接口需要的流控限制策略。

3.低效和开销大的rpc框架

早期rpc通信框架比较简单,对内通信时候使用的也是短连接。这本来短连接开销和性能瓶颈超出我们预期,短连接io效率是低一些,但端口资源够,本身吞吐可以满足需要,用是没问题的,很多分层的系统,也有http短连接对内进行请求的。但早期go版本,这样写程序,在一定量级情况,是支撑不住的。短连接大量临时对象和临时buffer创建,在本已经百万协程的程序中,是无法承受的。所以后续我们对我们的rpc框架作了两次调整。

第二版的rpc框架,使用了连接池,通过长连接对内进行通信(复用的资源包括client和server的:编解码Buffer、Request/response),大大改善了性能。

但这种在一次request和response还是占用连接的,如果网络状况ok情况下,这不是问题,足够满足需要了,但试想一个room实例要与后面的数百个的register,coordinator,saver,center,keeper实例进行通信,需要建立大量的常驻连接,每个目标机几十个连接,也有数千个连接被占用。

非持续抖动时候(持续逗开多少无解),或者有延迟较高的请求时候,如果针对目标ip连接开少了,会有瞬时大量请求阻塞,连接无法得到充分利用。第三版增加了Pipeline操作,Pipeline会带来一些额外的开销,利用tcp的全双特性,以尽量少的连接完成对各个服务集群的rpc调用。

4.Gc时间过长

Go的Gc仍旧在持续改善中,大量对象和buffer创建,仍旧会给gc带来很大负担,尤其一个占用了25G左右的程序。之前go team的大咖邮件也告知我们,未来会让使用协程的成本更低,理论上不需要在应用层做更多的策略来缓解gc.(最新版本的Go,GC 已经优化了很多)

改善方式,一种是多实例的拆分,如果公司没有端口限制,可以很快部署大量实例,减少gc时长,最直接方法。不过对于360来说,外网通常只能使用80和433。因此常规上只能开启两个实例。当然很多人给我建议能否使用SO_REUSEPORT,不过我们内核版本确实比较低,并没有实践过。另外能否模仿nginx,fork多个进程监控同样端口,至少我们目前没有这样做,主要对于我们目前进程管理上,还是独立的运行的,对外监听不同端口程序,还有配套的内部通信和管理端口,实例管理和升级上要做调整。

解决gc的另两个手段,是 内存池 和对象池,不过最好做仔细评估和测试,内存池、对象池使用,也需要对于代码可读性与整体效率进行权衡。

这种程序一定情况下会降低并行度,因为用池内资源一定要加互斥锁或者原子操作做CAS,通常原子操作实测要更快一些。CAS可以理解为可操作的更细行为粒度的锁(可以做更多CAS策略,放弃运行,防止忙等)。这种方式带来的问题是,程序的可读性会越来越像C语言,每次要malloc,各地方用完后要free,对于对象池free之前要reset,我曾经在应用层尝试做了一个分层次结构的“无锁队列”

上图左边的数组实际上是一个列表,这个列表按大小将内存分块,然后使用atomic操作进行CAS。但实际要看测试数据了,池技术可以明显减少临时对象和内存的申请和释放,gc时间会减少,但加锁带来的并行度的降低,是否能给一段时间内的整体吞吐量带来提升,要做测试和权衡…

在我们消息系统,实际上后续去除了部分这种黑科技,试想在百万个协程里面做自旋操作申请复用的buffer和对象,开销会很大,尤其在协程对线程多对多模型情况下,更依赖于golang本身调度策略,除非我对池增加更多的策略处理,减少忙等,感觉是在把runtime做的事情,在应用层非常不优雅的实现。普遍使用开销理论就大于收益。

但对于rpc库或者codec库,任务池内部,这些开定量协程,集中处理数据的区域,可以尝试改造~

对于有些固定对象复用,比如固定的心跳包什么的,可以考虑使用全局一些对象,进行复用,针对应用层数据,具体设计对象池,在部分环节去复用,可能比这种无差别的设计一个通用池更能进行效果评估.

下面介绍消息系统的架构迭代和一些迭代经验,由于之前在其他地方有过分享,后面的会给出相关链接,下面实际做个简单介绍,感兴趣可以去链接里面看

架构迭代~

根据业务和集群的拆分,能解决部分灰度部署上线测试,减少点对点通信和广播通信不同产品的相互影响,针对特定的功能做独立的优化.

消息系统架构和集群拆分,最基本的是拆分多实例,其次是按照业务类型对资源占用情况分类,按用户接入网络和对idc布点要求分类(目前没有条件,所有的产品都部署到全部idc)

简要介绍下我们的运维系统

我们利用Go原生的profiling工具,做了些可视化工作。可以对比多次上线出现的问题,通过压测程序复现部分瓶颈。定位cpu或者内存消耗的瓶颈。

另外,我们也可以对基础库代码做内嵌,将RPC库,Redis库,内存池命中数据等,做可视化的展示,统计它的QPS、网络带宽占用、idle与working,各种出错情况。然后再通过各种压测手段,观察优化性能点,上线前后是否有影响。如果一个系统不可评估就无法优化,利用压测评估就会发现一些潜在的问题。

系统的测试

go语言在并发测试上有独特优势。

在功能测试上,系统分成两套,一套是我们针对自己的功能,自己设计并测试,但这样难免会有问题。另一套是公司的自动化测试部门(python实现的~)根据我们的功能来测试。双保险~

对于压力测试,目前主要针对指定的服务器,选定线上空闲的服务器做长连接压测。然后结合可视化,分析压测过程中的系统状态。但压测早期用的比较多,但实现的统计报表功能和我理想有一定差距。理论上压测完后,可以根据协议版本,汇总每一次压测进程详细数据,业务的QPS数量、每秒钟建立连接数量,极限状态下的cpu和内存消耗,等每一个考核细节。现在只是能看一个大概趋势.对于细微的性能提升,没法评估,我们后续准备结合自己写的中央管理组件keeper,做这个数据收集和展示~

对于go语言适用的场景。之前在gopher china上,有过我个人的理解和概括:适用于重逻辑的io密集型应用.

我觉得最近出的golang开源产品都符合这种场景, go写网络并发程序给大家带来的便利,让大家把以往为了降低复杂度,拆解或者分层协作的组件,又组合在了一起。

比如go的web框架是在做负责并发的webserver和负责业务处理cgi程序,放在了webserver中。新近的一些go写的“智能”代理或者中间件,把很多原先分层控制或者不同功能但类似的子系统,以各种形式组装起来,reborn一个新的中间件或者新产品~包括之前百度放出的go-bfe也是把重逻辑和io密集型很好结合的产物~(群里有bfe的同学么,求更多资料)

个人感觉在国内互联网创业公司爆发环境下,大厂的复杂设备,很多将被golang重新打包成适用在一定量级下的“全能”工具箱~

以上就是今天的go分享~ 谢谢大家,欢迎提问~

相关文章

  1. 架构设计和概要( 1小时的分享 文字总结)
  2. 关于基础库实现和性能参数对比表格:
  3. 关于长连接服务需要注意事项,常见问题:(已无效):

提问环节

提问:对比kafaka这些消息系统 有什么优点?

回答:我们是消息推送系统 指的对客户端的高并发推送的 面对场景不一样

提问:hadoop生态圈的大数据,算重逻辑和IO密集型应用不?

回答:不算 Hadoop主要是计算

提问:这种推送的方式如何保证消息是有序到达的?

回答:如果要保证有序 需要重新设计协议 对消息编号 先存储 后拉取 可以按人存储也可以按订阅关系 目前花椒的im系统 属于这种场景 有机会大家感兴趣可以分享升级版本聊天架构

提问:golang适不适合写web应用

回答:这个可以单独一讲回答 我的建议新的应用 大并发的场景可以考虑 老项目酌情改写替换

提问:推送的消息会有多级转发吗?还是说客户端直接跟一个server保持长链接就完事了?

回答:当流量大到一定程度 可以多级转发 类似cdn架构 目前花椒直播的推送系统支持这种场景 为idc流量做分级转发 普通场景不需要

提问:极光推送跟这种推送的应用场景就是差不多吧?

回答:方案都类似 协议复杂度会不同 比如为了保证一致性采取编号 先存储后发送

提问:多级之间也是长链接吧

回答:多级别只是rpc调用 rpc通常keepalive

提问:推送系统目前支持什么协议呢? mqtt?web socket?

回答:分传输层和协议层 传输层web socket 没问题 mqtt是一个具体交互协议了 不具备通用可比性

提问:推送系统里面有用到epoll吗?有没有开源地址?

回答:用的golang; go原生就是对epoll封装…目前不开源 安全公司理解下

本文由「Go语言中文网」整理

相关文章