亿级在线系统二三事-网络编程/RPC框架

火丁笔记 · · 975 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

亿级在线系统二三事-网络编程/RPC框架

由于 火丁笔记攻略组 投诉我文章太晦涩,所以后续我们可能把大的主题,拆分成多个系列进行讨论。大的背景是拾忆多年前在360构建的亿级在线的Push/IM系统,把一些经验性和知识性的内容分享给大家。回忆梳理了下,构建一个稳定亿级在线系统,涉及下面四种基本能力,我们后续会分系列进行详细追述~ 今天讨论网络编程中的RPC框架的设计~
  1. 网络编程

    • RPC框架设计

  2. 分布式系统设计

  3. 网络和应用层协议栈的理解(TroubleShooting)

  4. 客户端实现与策略

网络编程/RPC框架设计

随着开源技术的活跃,grpc库基本统一了golang分布式系统的rpc框架。近2年很少有团队重新构建自己的rpc库。但你看到的是结果,如果想深入问题本质,真正能够稳定维持和构建一个亿级的实时在线长连接系统,其核心技术 rpc框架的构建和实现,还是有很大价值的。并且裸写一套高性能的rpc框架,也并没有想象那么难。
文中提供的360Push/IM系统使用的rpc框架,也是一个你可以选择的迭代的雏形。在我的laptop上,可以轻松跑出7w QPS的吞吐。16核的服务器跑出double也是正常的。如果说grpc是内饰高端性能稳定的6缸自然吸气豪华型SUV,我这个原型绝对是改装的8缸涡轮增压小钢炮。
很遗憾我们构建长连服务时候,go语言本身还没有成型的开源方案(标准库里面rpc也还没成型),对于rpc库先后开发3个版本,才完善成型。名字当时定的很大,就叫了gorpc。
库设计确实比较早了,现在golang的内存管理和GC优化了很多,所以里面涉及的开销数据会有些失准。但我觉得优化和迭代的思路还是可以借鉴的。

我个人的版本和公司目前使用还是有一定差别的,但作为一个原型版本是足够用的~ 欢迎大家参考:

https://github.com/johntech-o/gorpc

在我的laptop上,1000个协程并发,连接池max connection设为30,测试数据如下:
go version go1.11.2 darwin/amd64
HeapObjects: 3308696 - 20890 = 3287806TotalAlloc: 789886472 - 50113728 = 739772744PauseTotalMs: 1 - 0 = 1NumGC: 12 - 2 = 10server call status:  {"Result":{"Call/s":65750,"CallAmount":959675,"Err/s":0,"ErrorAmount":0,"ReadBytes":48935121,"ReadBytes/s":3353250,"WriteBytes":29742884,"WriteBytes/s":2038281},"Errno":0}client conn status:  {"Result":{"127.0.0.1:6668":{"creating":0,"idle":30,"readAmount":1000008,"working":0}},"Errno":0} 10% calls consume less than 13 ms 20% calls consume less than 13 ms 30% calls consume less than 14 ms 40% calls consume less than 14 ms 50% calls consume less than 14 ms 60% calls consume less than 14 ms 70% calls consume less than 14 ms 80% calls consume less than 15 ms 90% calls consume less than 15 ms100% calls consume less than 81 msrequest amount: 1000000, cost times : 14 second, average Qps: 68074Max Client Qps: 72250--- PASS: TestEchoStruct (15.94s)gorpc_test.go:241: TestEchoStruct result: hello echo struct ,count1000000PASSok github.com/johntech-o/gorpc (cached)


浅谈rpc

一个基本的RPC库的设计(基于TCP),涉及到几个核心的概念:

  • 用户调用接口形式

  • 传输编解码

  • 连接信道的利用

用户调用形式

按过程分为:调用者发起请求(call),等待远端完成工作,获取对端响应,三个过程。具体设计上可分为:

  1. 同步调用:发送请求,等待结果,结果返回调用方。Golang基本上是这种形式。

  2. 异步调用:发送请求,通信和调用交给底层框架处理,用户可以处理其他逻辑,再通过之前返回handler来直接查询和获取处理结果。我们的rpc库里面不涉这种形式的应用层接口,在golang阻塞编程的习惯下,这种设计略反直觉。

  3. 同步通知:发送请求,但调用方只关心数据是否送达,并不关心结果,无需等待服务端逻辑处理的结果,服务端发现是这种类型调用,直接返回空response到调用方,释放请求方资源。

  4. 细节上通常提供不同级别的超时控制,rpc的接口级别(controller/action),单次调用的超时(同一个接口,每次超时等待不固定),或者针对某一个rpc server的超时控制(remote address),便于用户控制接口的响应时间。

  5. 提供context上下文,比如允许用户显式终止对某些接口的请求,特别是一些流式的数据传输,业务层由于某些原因不关心了,通信层能及时检测到进行终止。(12年时候,还没这个,所以代码看起来轻松好多~) 

传输编解码
  1. 我们要在client和Server端传输需要操作的数据对象,比如一个struct,map,或者嵌套的struct,在网络通信的时候,需要编码进行描述,在解码的时候,再根据描述,重新构建内存中的struct对象,即encode生成request,服务端decode。具体编码方式,比如常见的protobuf,msgPack,bson,json,xml,gob等。

  2. 如果要对编解码分类,可能分为文本型和二进制型,比如json,xml这些属于文本型,protobuf,gob属于二进制型。长连接系统主要使用了go语言原生的gob编码,好处是简单,使用的数据结构可以直接gob传输,相比较protobuf减少了写描述环节。缺点就是不能跨语言了~ 当然如果你要将我们的gorpc库改写成pb编码的,也是很快的。

  3. 具体编码的实现上,对于一个高性能rpc框架来说,性能是一个瓶颈点。可以仔细查看源码,是否有编解码过程中的内存复用,与对象复用。否则编解码开销很大,另外看好复用的上下文是什么,是全局的,还是针对连接的。全局的是否有竞争,针对连接的,是否要使用长连接,什么时候释放资源。

连接信道利用

  1. 用户调用的协成与具体物理连接的对应关系,通常在golang环境下,同一个remote address的所有rpc调用,全局共享一个连接池。

  2. 连接池内连接管理,数量的控制,包括连接的动态扩增,连接状态监测,连接回收。

  3. 高效的读写超时的控制(read/write deadline),及其优化,据说现在应用层不需要做太多的策略,底层做了timer管理的sharding了。没试过,但大家可以抱着学习态度看下timewheel的实现。

  4. 类似多路复用的支持与设计,所有协成仍旧是阻塞等待输出,但底层会汇总所有协成的请求数据,复用连接批量发送给目的地址,目的服务器返回的数据,在rpc底层分发给调用方。由于不是1对1的映射物理连接,一个连接上的所有出错操作,也必须能告知所有复用连接的调用协程。所谓的小包打满万兆网卡,其实就是要充分利用好连接池连接的通信效率,汇聚数据,统一传输。减少系统调用次数。

早期RPC框架

早期的长连接系统,使用的策略是:同步调用+短连接+动态创建所有buffer+动态创建所有对象这种方式,在初期很快的完成了原型,并且在项目初期,通信比较少情况下,稳定跑了近半年时间。通信时序图如下:

image
  • 随着业务放量,推送使用频率的增长,业务逻辑的复杂。瓶颈出现,100w连,稳定服务后,virt 50G,res 40G,左右。gc时间一度达到3~6s,整个系统负载也比较高。(12年,1.0.3版本)

  • 其实早期在实现的时候,选择动态创建buffer和object,也不奇怪,主要是考虑到go在runtime已经实现了tcmalloc,并且我们相信它效率很高,无需在应用层实现缓存和对象池。(当年没有sync.pool)

  • 但实际上高并发下,使用短连接,pprof时候可以看到,应用层编解码过程建立大量对象和buffer外,go的tcp底层创建tcpConnection也会动态创建大量对象,具体瓶颈在newfd操作。整体给GC造成很大压力。

RPC通信框架第一次迭代

针对这种情况,我对通信库做了第一次迭代改造。

  1. 使用长连接代替短连接,对每一个远端server的address提供一个连接池,用户调用,从连接池中获取连接,对应下图中get conn环节。用户的一次request和response请求获取后,将连接放入连接池,供其他用户调用使用。因此系统中能并行处理请求的数量,在调度器不繁忙的情况下,取决于连接池内连接数量。假设用户请求一次往返加服务端处理时间,需要消耗10ms,连接池内有100个连接,那每秒钟针对一个server的qps为1w(100*(1000ms/10ms)) qps。这是一个理解想情况。实际上受server端处理能力影响,响应时间不一定是平均的,网络状况也可能发生抖动。这个数据为后面讨论pipeline做准备。

  2. 对连接绑定buffer,这里需要两个buffer,一个用于解码(decode),从socket读缓冲获取的数据放入decode buffer,用户对读到的数据进行解码,即反序列化成应用层数据结构。一个用户编码(encode),即对用户调用传入的所有参数进行编码操作,通过这个缓冲区,缓存编码后的一个完整序列化数据包,再将数据包写入socket 写缓冲。

  3. 使用object池,对编解码期间产生的中间数据结构进行重复利用,注意这里并不是对用户传递的参数进行复用,因为这个是由调用用户进行维护的,底层通信框架无法清楚知道,该数据在传输后是否能够释放。尤其在使用pipeline情况下,中间层数据结构也占了通信传输动态创建对象的一大部分。我们当时开发时候,还么有sync.Pool~,看过实现,方式类似,我觉得性能应该类似。随着golang优化,我的对象池也从代码里面移除了,但可以看mempool路径的实现,有些针对性场景肯定还是有效果的。

  4. 改动后,通信图如下,上图中红线所带来的开销已经去除,换成各种粒度的连接池和部分数据结构的对象池。具体细节,后面说明

rpc框架第二版使用策略:同步调用+连接池+单连接复用编解码buffer +复用部分对象

image
  • 这种方式,无疑大大提高了传输能力,另外解决了在重启等极限情况下,内部通信端口瞬时会有耗尽问题。内存从最高res 40G下降到20G左右。gc时间也减少3倍左右。这个版本在线上稳定服务了接近一年。(13年左右数据)

RPC通信框架二次迭代

但这种方式对连接的利用率并不高,举例说明,用户调用到达后,从连接池获取连接,调用完成后,将连接放回,这期间,这个连接是无法复用的。设想在连接数量有限情况下,由于个别请求的服务端处理延时较大,连接必须等待用户调用的响应后,才能回放到连接池中给其他请求复用。用户调用从连接池中获取连接,发送request,服务端处理10ms,服务端发送response,假设一共耗时14ms,那这14ms中,连接上传输数据只有4ms,同一方向上传输数据只有2m,大部分时间链路上都是没有数据传输的。但这种方式也是大多目前开源软件使用的长连接复用方案,并没有充分利用tcp的全双工特性,通信的两端同时只有一方在做读写。这样设计好处是client逻辑很简单,传输的数据很纯粹,没有附加的标记。在连接池开足够大的情况下,网络状况良好,用户请求处理开销时长平均,这几个条件都满足情况下,也可以将server端的qps发挥到极限(吃满cpu)。

另一种方案,是使整个框架支持pipeline操作,做法是对用户请求进行编号,这里我们称做sequence id,从一个连接上发送的所有request,都是有不同id的,并且client需要维护一个请求id与用户调用handler做对应关系。服务端在处理数据后,将request所带的请求的sequence id写入对应请求的response,并通过同一条连接写回。client端拿到带序号的response后,从这个连接上找到之前该序号对应的用户调用handler,解除用户的阻塞请求,将response返回给request的调用方。

对比上面说的两个方案,第二个方案明显麻烦许多。当你集群处于中小规模时候,开足够的连接池使用第一种方案是没问题的。问题是当系统中有几百个上千个实例进行通信的时候,对于一个tcp通信框架,会对几百个甚至上千个需要通信的实例建立连接,每个目标开50个到100个连接,相乘后,整个连接池的开销都是巨大的。而rpc请求的耗时对于通信框架是透明的,肯定会有耗时的请求,阻塞连接池中的连接,针对这种情况调用者可以针对业务逻辑做策略,不同耗时接口的业务开不同的rpc实例。但在尽量少加策略的情况下,使用pipline更能发挥连接的通信效率。
pipeline版本的rpc库,还加入了其他设计和考虑,这里只在最基础的设计功能,进行了讨论,下图是,第三版本rpc库,对比版本二的不同。

image
  1. 如上图所示,两次rpc调用可以充分利用tcp全双工特性,在14ms内,完成2次tcp请求。在server端处理能力非饱和环境下,用户调用在连接池的利用上提高一个量级,充分利用tcp全双工特性,让连接保持持续活跃。其目的是可以用最少的连接实现最大程度并发,在集群组件tcp互联通信的情况下,减少因为请求阻塞造成的连接信道浪费。

  2. 以上对消息系统rpc通信框架的迭代和演进进行了说明。只是对通信过程中基础环节和模型做了粗线条介绍。第三版本的rpc通信框架,其实为了适应分布式系统下的需求,需要辅助其他功能设计。每个细节都决定这个库能否在中大规模分布式环境中下是否试用,或者说是否可控,我们将在下一章里面详细介绍。

小结

以上就是设计环节~,我们后续可能讨论下一个rpc库的具体实现~ 看代码确实会通俗易懂很多,欢迎关注公众号,了解后续内容。
代码地址再发下,与公司版本有差别~ 但可以使用。
https://github.com/johntech-o/gorpc

欢迎关注公众号,收听其他章节~ 



有疑问加站长微信联系(非本文作者)

本文来自:微信公众平台

感谢作者:火丁笔记

查看原文:亿级在线系统二三事-网络编程/RPC框架

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:1006366459

975 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传