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

说起微服务架构,就一定要知道RPC

时间:2023-03-20 22:11:24 科技观察

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