当前位置: 首页 > 科技观察

应该知道的RPC内核细节(值得收藏)!!!

时间:2023-03-20 20:59:12 科技观察

微服务分层架构,之前讲了很多,微服务离不开RPC框架,RPC框架的原理、实践和细节,今天和大家聊一聊。文章较长,1万字左右,建议提前收藏。服务化有什么好处?服务化的一个好处是不限制服务商使用的技术选型,可以实现跨大公司团队的技术解耦,如下图:服务A:欧洲团队维护,技术背景为Java;服务B:美国团队维护,C++实现;服务C:中国团队维护,技术栈是go;服务的上游调用者可以根据接口和协议完成对远程服务的调用。但实际上,大多数互联网公司的研发团队有限,而且大多使用同一个技术体系来实现服务:在这种情况下,如果没有统一的服务框架,每个团队的服务商都需要实现一套系列化、反向序列化、网络框架、连接池、收发线程、超时处理、状态机等“业务外”的重复性技术劳动,导致整体效率低下。因此,统一的服务框架统一实现上述“业务外”工作,是服务首先要解决的问题。什么是RPC?RemoteProcedureCallProtocol,远程过程调用。什么是“远”,为什么是“远”?我们先来看看什么是“近”,即“局部函数调用”。当我们写下时发生了什么:intresult=Add(1,2);这行代码?传递两个输入参数;调用本地代码段中的函数执行操作逻辑;返回一个输出参数;这三个动作都发生在同一个进程空间,也就是局部函数调用。有没有办法调用跨进程函数?通常,此过程部署在另一台服务器上。最容易想到的是两个进程约定一个协议格式,使用Socket通信来传递:输入参数;调用哪个函数;输出参数;如果可以实现,那么这是一个“远程”过程调用。Socket通信只能传输连续的字节流。如何将输入的参数和函数放入连续的字节流中?假设,设计一个11字节的请求报文:前3个字节填入函数名“add”;用第一个参数“1”填充中间4个字节;用第二个参数“2”填充最后4个字节;类似地,可以设计一个4字节的响应报文:4字节填充处理结果“3”;调用者的代码可能变成:request=MakePacket(“add”,1,2);SendRequest_ToService_B(request);response=RecieveRespnse_FromService_B();intresult=unMakePacket(respnse);这4个步骤是:将传入的参数转换为字节流;将字节流发送给服务B;从服务B接收并返回字节流;将返回的字节流转为传出参数;服务器的代码可能会变成:request=RecieveRequest();args/function=unMakePacket(request);result=Add(1,2);response=MakePacket(result);SendResponse(response);5个步骤也很好理解:服务端收到字节流;将字节流转换成函数名和参数;在本地调用函数获取结果;将结果转换为字节流;将字节流发送给调用者;这个过程用图说明如下:调用者和服务端的处理步骤很清楚。这个过程中最大的问题是什么?调用者太麻烦了,每次都要关注很多底层细节:输入参数到字节流的转换,即序列化应用层协议的细节;socket传输,即网络传输协议的细节;插座接收;字节流对输出参数的转换,即反序列化应用层协议的细节;调用层可以不注意这个细节吗?是的,RPC框架解决了这个问题,它使调用者能够“像调用本地函数一样调用远程函数(服务)”。说到这里,大家对RPC、序列化、连载有没有感触呢?向下滚动以获取更多底层详细信息。RPC框架的职责是什么?RPC框架需要对调用者屏蔽各种复杂性,也对服务提供者屏蔽各种复杂性:服务调用客户端感觉就像调用本地函数来调用服务;服务提供者服务器感觉就像实现了一个本地功能一样实现了服务;所以整个RPC框架分为client部分和server部分。实现上述目标并屏蔽复杂性是RPC框架的职责。如上图,业务方的职责是:调用方A,传入参数,执行调用,获取结果;服务器B,接收参数,执行逻辑,返回结果;RPC框架的职责是,中间的大蓝框部分:客户端:序列化、反序列化、连接池管理、负载均衡、故障转移、队列管理、超时管理、异步管理等;服务器端:服务器组件、服务器收发包队列、io线程、工作线程、序列化和反序列化等;服务器端的技术大家都很了解,接下来我们将重点介绍客户端的技术细节。我们先来看看RPC-client部分的“序列化和反序列化”部分。为什么要序列化?工程师通常使用“对象”来操作数据:classUser{std::Stringuser_name;uint64_t用户ID;uint32_t用户年龄;};Useru=newUser("shenjian");u.setUid(123);u.setAge(35);但是当需要存储或传输数据时,“对象”就没那么好用了。经常需要将数据转换成连续空间的“二进制字节流”。一些典型的场景是:数据库索引磁盘存储:数据库的索引在内存中是一棵b+树,但是这种格式不能直接存储在磁盘上,所以b+树需要先转换成连续空间的二进制字节流它可以存储在磁盘上;cachedKVStorage:redis/memcache是??KV类型的缓存,缓存中存储的值必须是连续空间的二进制字节流,而不是User对象;数据网络传输:socket发送的数据必须是连续空间的二进制字节流,不能是对象;所谓序列化(Serialization)就是将“对象”形式的数据转换为“连续空间二进制字节流”形式的数据的过程。这个过程的逆过程称为反序列化。如何序列化?这是一个非常详细的问题。如果让你把一个“对象”转换成字节流,你会怎么做?一个容易想到的方法是xml(或json)等自描述标记语言:规定转换规则,发送端很容易将User类的对象序列化成xml,服务端收到xml二进制流后,也很容易序列化成User对象画外音:当语言支持反射时,这个工作很容易.第二种方法是自己实现二进制协议进行序列化,或者以上面的User对象为例,可以设计这样一个通用的协议:前4个字节代表序号;序号后4个字节代表key的长度m;接下来的m个字节代表key的value;接下来的4个字节代表value的长度n;接下来的n个字节代表va价值的线索;像xml一样递归,直到描述整个对象;上面这个协议描述的User对象可能是这样的:第一行:序列号是4个字节(设置0表示类名),类名的长度是4个字节(长度为4),而接下来的4个字节是类名(“User”),一共12个字节;第二行:4字节为序号(1表示第一个属性),4字节为属性长度(长度为9),接下来的9字节为属性名(“user_name”),属性值length为4字节(长度为8),属性值为8字节(值为“shenjian”),共29字节;第三行:4个序号字节(2表示第二个属性),属性长度为4字节(长度为7),接下来的7字节为属性名(“user_id”),属性值为4字节length(长度为8),属性值为8字节(值为123),共27字节;第四行:序号为4字节(3表示第三个属性),属性长度为4字节(长度为8),然后8字节为属性名(“user_name”),属性值长度为4字节(长度为4),属性值为4字节(值为35),共24字节;整个二进制字节流一共有12+29+27+24=92个字节。实际的序列化协议需要考虑的细节远不止于此,例如:强类型语言不仅要还原属性名、属性值,还要还原属性类型;复杂对象不仅要考虑普通类型,还要考虑对象嵌套类型等,不管怎么说,序列化的思路都是类似的。序列化协议应该考虑哪些因素?无论使用成熟的协议xml/json还是自定义的二进制协议来序列化对象,在设计序列化协议时都需要考虑以下因素:解析效率:这应该是序列化协议应该考虑的首要因素,比如由于xml/json解析比较耗时,需要解析doom树,二进制自定义协议解析效率很高;压缩率、传输效率:同一个对象,xml/json传输大量的xml标签,信息有效性低,二进制自定义协议占用的空间相对较小;扩展性和兼容性:增加字段是否方便,老版本的客户端增加字段后是否需要强制升级,都是需要考虑的问题,xml/json和上面的Binary协议都可以轻松实现扩展;可读性和可调试性:这个很容易理解,xml/json的可读性比二进制协议好很多;跨语言:以上两种协议是跨语言的,有些序列化协议与开发语言密切相关。比如dubbo的序列化协议只能支持JavaRPC调用;通用性:xml/json很通用,还有不错的第三方解析库,各种语言都能轻松解析。很方便。虽然上面的自定义二进制协议可以跨语言,但是必须为每种语言编写一个简单的协议客户端;常见的序列化方式有哪些?xml/json:解析效率和压缩率较差,扩展性、可读性和通用性较好;节约;protobuf:谷歌出品,必属精品,各方面都不错,强烈推荐,属于二进制协议,可读性稍差,但也有类似to-string的协议,可以帮助调试问题;阿芙罗;科尔巴;mc_pack:懂的同学就懂,不懂的同学就不懂。我是2009年用的,据说各方面都超过了protobuf。同学们可以谈谈现状;...RPC-client除了序列化和反序列化部分(上图中的1、4),还包括发送字节流和接收字节流的部分(上图中的2、3)上图)这部分分为同步调用和异步调用两种方式,下面会一一介绍。画外音:要弄到一个透明的RPC-client,真的不容易。同步调用的代码片段为:Result=Add(Obj1,Obj2);//异步调用的代码片段在获取Result之前处于阻塞状态:Add(Obj1,Obj2,callback);//直接返回调用后不等待结果处理结果回调为:callback(Result){//这个回调函数会在得到处理结果后调用...}这两种调用的实现方式完全不同在RPC客户端中。RPC-client同步调用架构如何?所谓同步调用,在得到结果之前一直处于阻塞状态,会一直占用一个工作线程。上图简要说明了组件、交互和流程步骤:左边的大方框代表左边粉红色的调用者的一个工作线程右边的橙色方框代表RPC-client组件,以及RPC-server的两个蓝色小框代表同步RPC-client的两个核心组件,序列化组件和连接池组件的白色进程小框,箭头序号1-10代表串行执行步骤整个工作线程的:1)业务代码发起RPC调用:Result=Add(Obj1,Obj2)2)序列化组件将对象调用序列化为二进制字节流,可以理解为要发送的数据包packet1;3)通过连接池组件获取一个可用的连接连接;4)通过connection连接将数据包packet1发送给RPC-server;5)网络传输时将数据包发送给RPC-server;6)响应包在网络上传输并返回给RPC-client;7)通过connection连接从RPC-server接收响应包packet2;8)通过连接池组件将连接放回连接池;9)序列化组件并发送packet2Fan被序列化为Result对象返回给调用者;10)业务代码获取Result结果,工作线程继续往下走;画外音:请参考架构图中的步骤1-10阅读。连接池组件有什么作用?RPC帧锁支持的负载均衡、故障转移、发送超时等特性都是通过连接池组件实现的。一个典型的连接池组件提供的接口是:intConnectionPool::init(…);ConnectionConnectionPool::getConnection();intConnectionPool::putConnection(Connectiont);初始化是做什么的?与下游的RPC-server(通常是一个集群)建立N个longtcp连接,也就是所谓的连接“池”。getConnection做什么?从连接“池”中获取一个连接,将其锁定(设置一个标志),并将其返回给调用者。putConnection做什么?将分配的连接放回连接“池”,解锁它(也设置一个标志)。如何实现负载均衡?在连接池中建立到RPC-server集群的连接,返回连接时连接池需要是随机的。如何实现故障转移?在连接池中建立到RPC服务器集群的连接。当连接池发现某台机器的连接异常时,需要排除这台机器的连接,恢复正常连接。机器恢复后,重新添加连接。如何实现发送超时?因为是同步阻塞调用,所以在获得连接后,使用send/recvwithtimeout实现超时发送和接收。总的来说,同步RPC-client的实现比较容易,序列化组件和连接池组件可以用多线程实现。RPC-client异步回调架构怎么样?所谓的异步回调,在没有得到结果之前,不会处于阻塞状态。理论上,任何时候都没有线程被阻塞。因此,异步回调模型理论上只需要很少的工作线程连接到服务就可以达到高性能。吞吐量,如上图所示:左边的框架是少量工作线程(只是少数)调用和回调。中间的粉红色框架代表RPC客户端组件。右侧的橙色框代表蓝色的RPC服务器。六个小框代表异步RPC-client的六个核心组件:上下文管理器、超时管理器、序列化组件、下游收发队列、下游收发线程、连接池组件、白进程小框、箭头1-17.代表整个工作线程的串行执行步骤:1)业务代码发起异步RPC调用;Add(Obj1,Obj2,callback)2)上下文管理器存储请求、回调和上下文;3)Serializescomponents,将对象调用序列化为二进制字节流,可以理解为要发送的数据包;4)下游收发队列,将消息放入“待发送队列”,此时调用返回,不阻塞工作线程;5)下游收发线程从“待发送队列”中取出消息,通过连接池组件获取一个可用的连接连接;6)通过connection连接将数据包packet1发送给RPC-server;7)将数据包发送到网络进行传输,发送到RPC-server;8)响应包在网络上传输并返回给RPC-client;9)通过连接connection从RPC-server接收响应包packet2;10)下游收发线程将数据包放入“接受队列”,通过连接池组件,将连接放回连接池;11)在下游收发队列中,取出消息,并且此时会开始回调,不会阻塞工作线程;12)序列化组件将packet2规范序列化为Result对象;13)上下文管理器取出结果、回调和上下文;14)回调业务代码通过callback,返回Result结果,工作线程继续往下走;如果请求长时间没有返回,处理流程为:15)ContextManager,请求长时间没有返回;16)超时管理器获取超时上下文;17)通过timeout_cb回调业务代码,工作线程继续往下走;画外音:这个过程请用架构图仔细看几遍。T上面介绍了序列化组件和连接池组件,发送接收队列和发送接收线程就比较容易理解了。下面重点介绍上下文管理器和超时管理器这两个通用组件。为什么需要上下文管理器?由于请求包的发送,响应包的回调是异步的,甚至不在同一个工作线程中完成。需要一个组件来记录请求的上下文,匹配请求-响应-回调等一些信息。如何匹配请求-响应-回调信息?这是一个非常有趣的问题。通过一个连接向下游服务发送三个请求包a、b、c,异步接收三个响应包x、y、z:你怎么知道哪个请求包对应哪个响应包呢??怎么知道哪个响应包对应哪个回调函数呢?通过“requestid”可以实现request-response-callback的串联。整个处理流程如上,通过requestid,contextmanager对应request-response-callback的映射关系:1)生成requestid;2)生成请求上下文context,包含发送时间time、回调函数callback等信息;3)上下文管理器记录req-id和context的映射关系;4)将请求包中的req-id发送给RPC-server;5)RPC-server在响应包中返回req-id;6)使用响应包中的req-id通过contextmanager找到原来的context;7)从context上下文中获取回调函数callback;8)回调返回Result,促进业务的进一步执行;如何实现负载均衡、故障转移?类似于同步连接池的思想,不同的是同步连接池采用阻塞方式发送和接收,需要与一个服务的一个ip建立多个连接;异步的,一个service的一个ip只需要建立少量的连接(比如,一个tcp连接);如何实现超时发送和接收?超时发送接收的实现与同步阻塞发送接收的实现不同:同步阻塞超时可以直接使用send/recvwithtimeout来实现;异步非阻塞nio网络消息收发,因为连接不会一直等待返回包,Timeouts由超时管理器实现;超时管理器是如何实现超时管理的?超时管理器,用于实现请求包超时回调处理。当每个请求发送到下游RPC-server时,req-id和上下文信息将存储在上下文管理器中。上下文中保存了很多与请求相关的信息,如req-id、返回包回调、超时回调、发送时间等,超时管理器启动定时器扫描上下文管理器中的上下文,查看发送时间上下文中的请求太长。如果太长,则不再等待返回包,直接回调timeout,推动业务流程继续往下。上下文被删除。如果执行超时回调后正常返回包到达,通过req-id在contextmanager中找不到context,则直接丢弃该请求。画外音:上下文无法恢复,因为处理已超时。不管怎么说,相对于同步回调,除了序列化组件和连接池组件之外,还会多出上下文管理器、超时管理器、下游收发队列、下游收发线程等组件,这些都会产生影响关于来电者的通话习惯。.画外音:编程习惯,从同步到回调。异步回调可以提高系统的整体吞吐量。具体实现RPC-client的方式可以根据业务场景选择。小结(一)什么是RPC调用?像调用本地函数一样调用远程服务。(2)为什么需要RPC框架?RPC框架用于屏蔽RPC调用时的序列化、网络传输等技术细节。让调用者只专注于调用,服务器只专注于实现调用。(3)什么是序列化?为什么需要序列化?将对象转换为连续的二进制流的过程称为序列化。磁盘存储、缓存存储、网络传输只能对二进制流进行操作,因此必须进行序列化。(4)同步RPC-client的核心组件有哪些?同步RPC客户端的核心组件是序列化组件和连接池组件。它通过连接池实现负载均衡和故障转移,通过阻塞发送和接收实现超时处理。(5)异步RPC-client的核心组件是什么?异步RPC-client的核心组件是序列化组件、连接池组件、发送和接收队列、发送和接收线程、上下文管理器和超时管理器。它通过“requestid”关联请求包-响应包-回调函数,使用上下文管理器管理上下文,使用超时管理器中的定时器触发超时回调,促进业务流程的超时处理。想法比结论更重要。