今天继续探秘系列,扒一扒一次RPC请求在Dubbo中经历的核心流程。
本文是基于Dubbo3.x版本进行讲解
一个简单的Demo
这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,这里快速讲一讲Dubbo一个简单的Demo。
在Dubbo中RPC调用过程中主要分为以下两个角色:
- 服务提供者:提供一个接口给消费者远程调用。
- 服务消费者:调用生产者提供的接口。
于是一个简单的Dubbo示例工程就如下所示:
示例工程的创建步骤、使用配置、第三方的依赖等详细内容可参考官网:https://cn.dubbo.apache.org/zh-cn/overview/quickstart/java/spring-boot/
接口层,提供者消费者都需要依赖,服务提供者实现,服务消费者调用
图片
服务提供者单独一个工程,实现DemoService接口,通过@DubboService表明提供DemoService这个服务。
图片
服务消费者单独一个工程,这里使用单元测试,通过@DubboReference注解表明消费DemoService这个服务接口。
图片
启动服务提供者,运行消费者单元测试,结果如下:
成功实现远程服务调用
服务提供者暴露
所谓的服务提供者暴露,主要就是指在项目启动时服务提供者去做的两件事
第一件事就是,由于需要对外提供调用服务,接受消费者的请求
所以在启动时需要根据使用协议,以及协议对应的端口启动一个对应的服务
就拿前面DemoService来举例,由于@DubboService注解没有指定任何信息
所以DemoService默认就是使用Dubbo框架自己写的通信协议,也就是Dubbo协议,这个协议默认使用的端口就是20880
之后如果要调用DemoService的方法时,就可以按照Dubbo协议要求组装数据格式
向20880端口发送请求,从而就实现远程服务调用,如下图所示:
图片
当然除了默认的Dubbo协议之外,Dubbo还支持其它的通信协议,后面会详细介绍:
虽然第一件事成功让接口可以对外提供访问,但是对于消费者来说,它其实还是无法访问接口。
因为消费者并不知道接口使用的是哪个通信协议、端口,也不知道接口所在的服务器的ip。
于是,在启动时就会去做第二件事。
第二件事是将每个接口的详细信息,包括接口的全限定名、方法名称、方法参数、服务器的ip、端口、通信协议等等按照一定的格式组装好。
存放到元数据中心和服务提供者本地缓存中。
注意这是3.x版本时的存储情况,跟2.x有点不同。并且元数据中心其实就是使用的Nacos或者Zookeeper来实现的,所以你可以认为就是存储在Nacos或者Zookeeper中。
之后消费者需要调用接口时,就可以从元数据中心或者服务提供者本地缓存中获取到接口的详细信息(具体从哪取决于配置,默认是从本地缓存中获取)。
图片
这里你肯定有疑问消费者是如何从服务提供者本地缓存获取,这就涉及到Dubbo3.x应用级服务注册的逻辑了,所以就不详细展开了,不过立个flag,如果本篇文章点赞达到38个,就再来一篇,单独讲一讲Dubbo3.x应用级服务注册的原理。
当需要发起调用时,就可以按照接口使用的协议组装数据,向接口所在的服务器ip和端口发送请求。
所以总的来说,服务提供者暴露主要就是这两件事:
- 根据接口使用协议和端口开启服务,对外提供接口访问。
- 将当前服务支持的接口,以及每个接口使用的协议、端口、服务器ip等信息存到元数据中心或者本地缓存,供消费者获取。
消费者引用
前面提到,如果消费者想引用远程服务,可以通过@DubboReference注解触发引用的逻辑。
消费者引用也会去做两件事。
第一件事我们都知道,那就是创建接口的动态代理。
由于消费者使用的DubboService是一个接口,所以会给DubboService创建一个动态代理。
这个动态代理最终也会发送请求RPC请求。
Dubbo支持两种动态搭理生成方式:
- JDK动态搭理
- Javassist动态生成字节码
默认使用的Javassist动态生成字节码的方式。
除了创建动态搭理之外,还会去获取服务提供者的接口详细信息。
上面一节说了,可以从元数据中心或者是服务提供者本地缓存中获取到。
当获取到接口详情数据之后,会为之后的RPC调用做一些准备工作。
比如如果接口使用的是Dubbo通信协议的话,准备工作就是消费者会跟服务提供者机器建立长连接。
好了,到这里我们就把服务者暴露和消费者引用都讲完了。
接下来就会进入本文的主题,一次RPC调用,也就是调用动态代理之后在Dubbo中会经历哪些环节。
参数封装
熟悉JDK动态代理的同学肯定知道,当调用动态代理方法时,最终会走到InvocationHandler的实现。
图片
在Dubbo中,调用消费者动态代理的时候,不论是JDK动态代理还是使用Javassist方式生成动态代理。
最终都会走到InvokerInvocationHandler这个InvocationHandler的实现。
图片
所以这个整个RPC调用的起点就是invoke方法的实现;
图片
如图所示,首先将RPC调用的接口、方法名、参数封装到RpcInvocation中。
接着会走到下面这行代码。
invoker.invoke(rpcInvocation)
而这看似简简单单一行代码就会触发RPC调用的整个核心流程:
ClusterFilter过滤
当参数封装完成之后,接下来就会走到ClusterFilter过滤环节。
图片
ClusterFilter本质是一种责任链模式,是Dubbo提供的一个重要扩展点。
通过实现invoke方法对请求进行自定义预处理操作。
Dubbo默认提供了几种实现。
图片
比如就拿MonitorClusterFilter来说:
这个实现主要是去统计每个接口的每个方法调用成功多少次,调用失败多少次等等调用的信息。
除了默认实现之外,很多我们熟悉的一些框架也是通过这个扩展点跟Dubbo进行整合的。
就比如常见的流控框架Sentinel!
图片
集群调用逻辑决策
当走完ClusterFilter之后,接下来就会来到集群调用逻辑决策的环节。
这个集群调用逻辑决策是什么意思呢?
在实际生产环境中,一般服务都会以集群的方式来部署。
这就会产生一个问题,面对多服务情况下,怎么去调用?
图片
举个例子,按图上所示,有三个服务
那么集群调用逻辑就是去决定。
应该每个服务都去调用一次,还是只去调用其中一个?
如果只调用其中一个,比如调用服务1,如果失败了,那么此时是直接抛异常还是选择继续去调用服务2,还是做其它的事?
所以集群调用逻辑就是解决多服务实例下,应该怎样合理地调用服务实例。
Dubbo提供了以下几种集群调用逻辑:
- 广播,也就是每个服务都调用(broadcast)。
- 调用前会去判断服务是不是可用,如果可用,那么就直接进行调用(available)。
- 调用失败,会开启定时任务进行重试调用,最大重试3次(failback)。
- 调用失败就直接抛出异常(failfast)。
- 调用失败直接调用其它服务进行重试,故障转移(failover)。
- 调用失败不会抛异常,而是直接返回(failsafe)。
- 同时调用指定个数的服务,直接最快返回结果当做这个调用的结果(forking)。
- 调用每个服务,合并服务返回的数据作为调用的结果,结果怎么合并需要我们自定义相关逻辑(mergeable)。
在默认情况下使用的就是failover,也就是出现异常时会调用其它的服务再返回结果。
当然我们也可以按照如下的方式指定调用策略。
图片
路由策略
上一节是解决集群中众多实例时应该如何调用的问题。
而路由策略其实是选择允许调用哪些服务实例。
因为并不是所有的服务实例都符合调用要求。
什么意思呢?
举个例子,现在有个灰度发布的场景。
假设所有的服务都处于同一套环境中,有一群机器运行者之前正式版本的服务,有一群机器运行着灰度版本的服务,如下图所示:
图片
那么对于处于灰度的消费者肯定要调用处于灰度的服务提供者。
但是由于在同一套环境,那么处于灰度的消费者其实是能获取到处于之前正式环境的服务接口信息。
如果就这么直接调用,那么处于灰度的消费者就可能调用非灰度的服务提供者。
这肯定是不允许的。
所以必须在调用前过滤掉非灰度发布的服务。
而这种情况就可以交给路由来过滤。
假设如果想做到灰度区分,可以使用Dubbo提供了一种叫tag的路由策略。
灰度的服务提供者可以指定自己的tag属性为gray(灰色的意思),如下所示:
图片
而对于处于灰度的消费者,只需要指定消费tag为gray的服务提供者,如下所示:
图片
这样在真正调用前就会通过tag路由的方式过滤出处于灰度的服务提供者。
所以集群调用逻辑所能使用的服务实例只能是经过路由策略选择出来。
图片
除了tag路由策略之外,Dubbo还提供了以下几种路由策略:
- 条件路由,可以指定某些条件下可以调用哪些服务实例。
- 脚本路由,可以写一段JavaScript脚本,更加灵活地选择哪些服务实例。
顺带说一句,这个路由功能可以用来实现一个更高大上的功能,叫做流量管控。
负载均衡
所谓的负载均衡就是说,面对多个服务实例,我们应该按照何种算法选择一个供我们调用。
Dubbo提供了以下几种负载均衡策略:
- 随机(random),随机选择一个
- 轮询(roundrobin),每次调用按照顺序选择一个
- 最少活跃优先(leastactive),优先选择被最少调用的服务
- 最短响应优先(shortestresponse),优先选择响应时间断的服务调用
- 一致性Hash(consistenthash)
在没有指定的情况下,默认使用的就是随机(random)算法:
如果想进行修改,可以按照如下方式:
图片
这里你肯定有疑问;
这个负载均衡和集群调用策略有什么关系?感觉这两者有点像,又感觉这两者有点冲突。
其实集群调用策略的优先级会大于负载均衡。
比如说如果集群调用策略选择默认,也就是故障转移(failover)。
那么对于路由策略过滤出来的服务实例,会根据负载均衡算法选择一个进行调用。
但是如果集群调用策略选择的是广播调用(broadcast)。
那么对于路由策略过滤出来的服务实例,实际上每个都需要去调用。
所以此时压根不需要走负载均衡策略,因为没有意义,即使你配置了,也不会生效。
所以需不需要负载均衡这件事,取决于使用什么集群调用策略。
总的来说,集群调用策略、路由策略、负载均衡策略它们一步一步去决定本次RPC调用具体应该调用哪个或者哪些服务实例:
三者关系入下图所示:
图片
Filter过滤
经过上面的几步,终于知道本地RPC请求需要请求哪个或者哪些具体的服务实例。
接下来只需要向对应的服务实例发送请求就可以了。
不过在发送请求前,Dubbo还预留了一个扩展点,叫做Filter。
本质也是一种责任链模式。
图片
通过Filter,我们可以在RPC调用前对整个请求再进行自定义扩展。
这里你肯定又会有一个疑问?
Filter和前面提到的ClusterFilter有什么区别?
的确它两真的很像,甚至都继承同一个接口BaseFilter,但是它两还有一些区别。
第一点,两者作用时机不同。
通过讲解顺序我们可以看出,ClusterFilter作用在路由和负载均衡前,而Filter在路由和负载均衡后。
所以只要我们愿意,我们可以通过ClusterFilter去影响后面的路由和负载均衡,而Filter是做不到的。
第二点就是Filter是跟服务实例走的。
在调用每个服务实例之前,Filter一定会都会重新调用一遍。
比如假设这次RPC最终需要选择调用两个服务实例,那么Filter会走两遍。
但是对于ClusterFilter,在整个调用过程中它仅仅只会执行一次。
所以官方也是建议,在无特殊情况下,优先选择使用ClusterFilter而不是Filter。
到这,画一张图总结一下前面整个调用环节用。
图片
通信协议
当Filter责任链走完之后,接下来就到了向服务实例发送请求的时候了。
一旦涉及到服务与服务之间的调用,那么就离不开通信协议。
所谓的通信协议,讲的简单点就是发送方把需要发送的数据按照一定的格式组装好之后再发送给接收方。
Dubbo需要发送数据包括调用但不限于接口全限定名、调用的方法名、调用参数等等。
而接收方在获取到数据时再使用对应的格式去解析,从而获取到请求数据。
前面提到,Dubbo默认使用的通信协议是Dubbo自己的写的,叫做Dubbo协议。
除了Dubbo协议之外,Dubbo还支持以下几种通信协议:
- Rest
- gRPC
- Triple
- ...
Rest,就是我们说的Http协议。
当使用这种协议的时候,Dubbo在启动的时候会去创建一个Http的服务。
默认使用的是Jetty,当然也支持切换成Tomcat。
gRPC,谷歌开源的高性能RPC框架。
当然使用gRPC的时候,服务提供者会启动一个gRPC的服务端。
这里你可能有疑问,Dubbo是RPC框架,gRPC也是RPC框架,为什么要集成gRPC。
其实这是因为Dubbo和gRPC定位不同。
Dubbo其实不仅仅是一个RPC框架,它其实是一套微服务解决方案,会承担更多的服务治理相关的逻辑。
而gRPC的定位是通信协议与实现,是一款纯粹的RPC框架。
Triple协议就比较厉害了,它是Dubbo在3.x时发布的通信协议。
Triple完全兼容gRPC协议,可同时运行在HTTP/1和HTTP/2传输协议之上,让你可以直接使用curl、浏览器访问后端Dubbo服务。
如果要想使用上面的这些协议,代码可能需要进行一些改动,这里就不演示了。
序列化协议
上一节提到,数据在发送的时候需要根据通信协议按照要求去组装数据。
但是我们都知道,数据在网络中传输使用的是二进制。
所以在实际开发中,要想发送数据,一般都是先将需要传输的数据转换成字节序列(数组),之后再交由操作系统转换成二进制进行传输。
于是就有了一个问题,比如我们想传输一个对象的数据,那么我们应该按照什么样的格式将对象的数据转换成字节序列呢?
而这个按照什么样的格式就被称为序列化协议。
整个转换过程就被称为序列化,也可以被称为编码。
图片
既然有序列化,那么就有反序列化。
所谓反序列化就是根据序列化协议将字节序列转换成数据,也被称为解码。
当通信协议使用Dubbo协议时,Dubbo支持以下几种序列化协议:
- Java原生
- Hessian2
- Fastjson2
- ...
Dubbo在3.2.0版本之前默认使用的Hessian2协议,3.2.0之后默认使用Fastjson2作为序列化协议。
到这里其实就算讲完了消费者整个调用的过程了。
因为当序列化完成之后,接下来就只需要将字节序列通过网络发送出去即可。
服务提供者处理请求
当服务提供者监听到有请求时,会获取到请求的字节序列。
然后根据通信协议,序列化协议反序列化出传输的数据。
从而获取到消费者需要调用的、接口、方法以及入参等数据。
之后就可以找到调用接口对应的实现,通过反射进行调用,获取结果。
然后再将结果序列化成字节数组,返回给消费者。
这样服务提供者就处理完成了一次请求。
不过这里面有一个小细节,那就是在调用接口的实现之前,也会经过Filter过滤。
所以Filter过滤其实在提供者和消费者两者都有。
图片
但是需要注意的是,两边的Filter不一定相同,具体取决于这个Filter是作用在消费者端还是提供者端,可通过如下方式配置
图片
总结
到这终于讲完了一次RPC请求在Dubbo中经历整个核心流程。
不知道你看完有什么感受。
这里我再来画一张图总结整个调用过程。
图片
值得注意是,上面提到的所有调用环节,注意说的是所有,Dubbo都留了对应的扩展点。
也就是说,小到一个Filter,大到整个通信协议你都可以进行自定义扩展。
从这也可以看出,Dubbo在设计上的优秀之处。