RPC全称是RemoteProcedureCall,即远程过程调用,其对应的是我们的本地调用。
远程其实指的就是需要网络通信,可以理解为调用远程机器上的方法。
那可能有人说:我用HTTP调用不就是远程调用了,那不也叫RPC了?
不是的,RPC的目的是:让我们调用远程方法像调用本地方法一样无差别。
来看下代码就很清晰,比如本来没有拆分服务都是本地调用的时候方法是这样写的:
publicStringgetSth(Stringstr){returnyesService.get(str);}
如果yesSerivce被拆分出去,此时需要远程调用了,如果用HTTP方式,可能就是:
publicStringgetSth(Stringstr){RequestParamparam=newRequestParam();......returnHttpClient.get(url,param,.....);}
此时需要关心远程服务的地址,还需要组装请求等等,而如果采用RPC调用那就是:
publicStringgetSth(Stringstr){//看起来和之前调用没差?哈哈没唬你,//具体的实现已经搬到另一个服务上了,这里只有接口。//看完下面就知道了。returnyesService.get(str);}
所以说RPC其实就是用来屏蔽远程调用网络相关的细节,使得远程调用和本地调用使用一致,让开发的效率更高。
在了解了RPC的作用之后,我们来看看RPC调用需要经历哪些步骤。
RPC调用基本流程
按上面的例子来说,yesService服务实现被移到了远程服务上,本地没有具体的实现只有一个接口。
那这时候我们需要调用yesService.get(str),该怎么办呢?
我们所要做的就是把传入的参数和调用的接口全限定名通过网络通信告知到远程服务那里。
然后远程服务接收到参数和接口全限定名就能选中具体的实现并进行调用。
业务处理完之后再通过网络返回结果,这就搞定了!
image
上面的操作这些就是由触发的。
不过我们知道yesService就是一个接口,没有实现的,所以这些操作是怎么来的?
是通过动态代理来的。
RPC会给接口生成一个代理类,所以我们调用这个接口实际调用的是动态生成的代理类,由代理类来触发远程调用,这样我们调用远程接口就无感知了。
动态代理想必大家都比较熟悉,最常见的就是Spring的AOP了,涉及的有JDK动态代理和cglib。
在Dubbo中用的是Javassist,至于为什么用这个其实梁飞大佬已经写了博客说明了。
他当时对比了JDK自带的、ASM、CGLIB(基于ASM包装)、Javassist。
经过测试最终选用了Javassist。
梁飞:最终决定使用JAVAASSIST的字节码生成代理方式。虽然ASM稍快,但并没有快一个数量级,而JAVAASSIST的字节码生成方式比ASM方便,JAVAASSIST只需用字符串拼接出Java源码,便可生成相应字节码,而ASM需要手工写字节码。
可以看到选择一个框架的时候性能是一方面,易用性也很关键。
说回RPC。
现在我们知道动态代理屏蔽了RPC调用的细节,使得用户无感知的调用远程服务,那调用的细节有哪些呢?
序列化
像我们的请求参数都是对象,有时候是定义的DTO,有时候是Map,这些对象是无法直接在网络中传输的。
你可以理解为对象是“立体”的,而网络传输的数据是“扁平”的,最终需要转化成“扁平”的二进制数据在网络中传输。
image
你想想,各对象分配在内存不同位置,各种引用,这看起来是不是有种立体的感觉?
最终都是要变成一段01组成的数字传输给对方,这种就01组成的数字看起来是不是很“扁平”?
把对象转化成二进制数据的过程称为序列化,把二进制数据转化成对象的过程称为反序列化。
当然如何选择序列化格式也很重要。
比如采用二进制的序列化格式数据更加紧凑,采用JSON等文本型序列化格式可读性更佳,排查问题比较方便。
还有很多序列化选择,一般需要综合考虑通用性、性能、可读性和兼容性。
具体本文就不分析了,之后再专门写一篇分析各种序列化协议的。
RPC协议
刚才也提到了只有二进制数据才能在网络中传输,那一堆二进制在底层看来是连起来的,它可不会管你哪些数据是哪个请求的。
但接收方得知道呀,不然就不能顺利的把二进制数据还原成对应的一个个请求了。
于是就需要定义一个协议,来约定一些规范,制定一些边界使得二进制数据可以被还原。
比如下面一串数字按照不同位数来识别得到的结果是不同的。
image
所以协议其实就定义了到底如何构造和解析这些二进制数据。
我们的参数肯定比上面的复杂,因为参数值长度是不定的,而且协议常常伴随着升级而扩展,毕竟有时候需要加一些新特性,那么协议就得变了。
一般RPC协议都是采用协议头+协议体的方式。
协议头放一些元数据,包括:魔法位、协议的版本、消息的类型、序列化方式、整体长度、头长度、扩展位等。
协议体就是放请求的数据了。
通过魔法位可以得知这是不是咱们约定的协议,比如魔法位固定叫,一看我们就知道这是协议。
然后协议的版本是为了之后协议的升级。
从整体长度和头长度我们就能知道这个请求到底有多少位,前面多少位是头,剩下的都是协议体,这样就能识别出来,扩展位就是留着日后扩展备用。
贴一下Dubbo协议:
image
可以看到有Magic位,请求ID,数据长度等等。
网络传输
组装好数据就等着发送了,这时候就涉及网络传输了。
网络通信那就离不开网络IO模型了。
image
网络IO分为这四种模型,具体以后单独写文章分析,这篇就不展开了。
一般而言我们用的都是IO多路复用,因为大部分RPC调用场景都是高并发调用,IO复用可以利用较少的线程hold住很多请求。
一般RPC框架会使用已经造好的轮子来作为底层通信框架。
例如Java语言的都会用Netty,人家已经封装的很好了,也做了很多优化,拿来即用,便捷高效。
小结
RPC通信的基础流程已经讲完了,看下图:
image
响应返回就没画了,反正就是倒着来。
我再用一段话来总结一下:
服务调用方,面向接口编程,利用动态代理屏蔽底层调用细节将请求参数、接口等数据组合起来并通过序列化转化为二进制数据,再通过RPC协议的封装利用网络传输到服务提供方。
服务提供方根据约定的协议解析出请求数据,然后反序列化得到参数,找到具体调用的接口,然后执行具体实现,再返回结果。
这里面还有很多细节。
比如请求都是异步的,所以每个请求会有唯一ID,返回结果会带上对应的ID,这样调用方就能通过ID找到对应的请求塞入相应的结果。
有人会问为什么要异步,那是为了提高吞吐。
当然还有很多细节,会在之后剖析Dubbo的时候提到,结合实际中间件体会才会更深。
真正工业级别的RPC
以上提到的只是RPC的基础流程,这对于工业级别的使用是远远不够的。
生产环境中的服务提供者都是集群部署的,所以有多个提供者,而且还会随着大促等流量情况动态增减机器。
因此需要注册中心,作为服务的发现。
调用者可以通过注册中心得知服务提供者们的IP地址等元信息,进行调用。
调用者也能通过注册中心得知服务提供者下线。
还需要有路由分组策略,调用者根据下发的路由信息选择对应的服务提供者,能实现分组调用、灰度发布、流量隔离等功能。
还需要有负载均衡策略,一般经过路由过滤之后还是有多个服务提供者可以选择,通过负载均衡策略来达到流量均衡。
当然还需要有异常重试,毕竟网络是不稳定的,而且有时候某个服务提供者也可能出点问题,所以一次调用出错进行重试,较少业务的损耗。
还需要限流熔断,限流是因为服务提供者不知道会接入多少调用者,也不清楚每个调用者的调用量,所以需要衡量一下自身服务的承受值来进行限流,防止服务崩溃。
而熔断是为了防止下游服务故障导致自身服务调用超时阻塞堆积而崩溃,特别是调用链很长的那种,影响很大。
比如A=B=C=D=E,然后E出了故障,你看ABCD四个服务就傻等着,慢慢的资源就占满了就崩了,全崩。
image
大致就是以上提到的几点,不过还能细化,比如负载均衡的各种策略、限流到底是限制总流量还是根据每个调用者指定限流量,还是上自适应限流等等。
这个在之后分析Dubbo的时候都会提到,等着哈。
最后
我之前面过一个同学,两年经验,简历写着熟悉SpringCloudAlibaba然后了解Dubbo。
我问他RPC的调用原理,他问我什么是RPC,没听过这个名词。
这就太浮在表面了。
理解原理还是很重要的,像我上面提到的动态代理也不是一定是要的,像C++就没有动态代理,gRPC框架用的是代码生成。
反正最终只要能屏蔽调用细节,不需要使用者关心即可,至于用什么方式达到这个目的,影响不大。
还有上面提到面向接口,其实有时候就是没接口,例如一些服务网关,暴露出HTTP调用的方式给调用者来调用后端RPC服务。
image
网关是要接入很多后端服务的,所以不可能依赖后端的接口,不然就不灵活了。
这里就有个泛化调用的概念。
其实只要你理解了请求方无非就是告知服务提供方我要调哪个方法,参数都是哪些,你就能很容易的理解什么叫泛化调用。
也就能理解其实不需要接口我们也能进行RPC调用。
具体泛化调用是什么之后写Dubbo会提到。
所以听起来好像很高级的玩意,如果你理解了本质,其实也就这么点东西。
万变不离其宗。