image.png

什么是 RPC

RPC(Remote Procedure Call)—远程过程调用,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。比如两个不同的服务 A、B 部署在两台不同的机器上,那么服务 A 如果想要调用服务 B 中的某个方法该怎么办呢?使用 HTTP请求 当然可以,但是可能会比较慢而且一些优化做的并不好。 RPC 的出现就是为了解决这个问题。
最终解决的问题:让分布式或者微服务系统中不同服务之间的调用像本地调用一样简单。

为什么用 RPC,不用 HTTP

首先需要指正,这两个并不是并行概念。RPC 是一种设计,就是为了解决不同服务之间的调用问题,完整的 RPC 实现一般会包含有 传输协议序列化协议 这两个。
而 HTTP 是一种传输协议,RPC 框架完全可以使用 HTTP 作为传输协议,也可以直接使用 TCP,使用不同的协议一般也是为了适应不同的场景。
使用 TCP 和使用 HTTP 各有优势:
传输效率

  • TCP,通常自定义上层协议,可以让请求报文体积更小
  • HTTP:如果是基于HTTP 1.1 的协议,请求中会包含很多无用的内容

性能消耗,主要在于序列化和反序列化的耗时

  • TCP,可以基于各种序列化框架进行,效率比较高
  • HTTP,大部分是通过 json 来实现的,字节大小和序列化耗时都要更消耗性能

跨平台

  • TCP:通常要求客户端和服务器为统一平台
  • HTTP:可以在各种异构系统上运行

动态代理和静态代理的区别

静态代理的代理对象和被代理对象在代理之前就已经确定,它们都实现相同的接口或继承相同的抽象类。静态代理模式一般由业务实现类和业务代理类组成,业务实现类里面实现主要的业务逻辑,业务代理类负责在业务方法调用的前后作一些你需要的处理,以实现业务逻辑与业务方法外的功能解耦,减少了对业务方法的入侵。静态代理又可细分为:基于继承的方式和基于聚合的方式实现。
静态代理模式的代理类,只是实现了特定类的代理,代理类对象的方法越多,你就得写越多的重复的代码。动态代理就可以动态的生成代理类,实现对不同类下的不同方法的代理。
JDK 动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用业务方法前调用InvocationHandler 处理。代理类必须实现 InvocationHandler 接口,并且,JDK 动态代理只能代理实现了接口的类

JDK 动态代理的步骤

使用 JDK 动态代理类基本步骤:
1、编写需要被代理的类和接口
2、编写代理类,需要实现 InvocationHandler 接口,重写 invoke() 方法;
3、使用Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)动态创建代理类对象,通过代理类对象调用业务方法。

如果想代理没有实现接口的对象

CGLIB 框架实现了对无接口的对象进行代理的方式。JDK 动态代理是基于接口实现的,而 CGLIB 是基于继承实现的。它会对目标类产生一个代理子类,通过方法拦截技术对过滤父类的方法调用。代理子类需要实现 MethodInterceptor 接口。
CGLIB 底层是通过 asm 字节码框架实时生成类的字节码,达到动态创建类的目的,效率较 JDK 动态代理低。Spring 中的 AOP 就是基于动态代理的,如果被代理类实现了某个接口,Spring 会采用 JDK 动态代理,否则会采用 CGLIB。

  1. interface DemoInterface {
  2. String hello(String msg);
  3. }
  4. class DemoImpl implements DemoInterface {
  5. @Override
  6. public String hello(String msg) {
  7. System.out.println("msg = " + msg);
  8. return "hello";
  9. }
  10. }
  11. class DemoProxy implements InvocationHandler {
  12. private DemoInterface service;
  13. public DemoProxy(DemoInterface service) {
  14. this.service = service;
  15. }
  16. @Override
  17. public Object invoke(Object obj, Method method, Object[] args) throws Throwable {
  18. System.out.println("调用方法前...");
  19. Object returnValue = method.invoke(service, args);
  20. System.out.println("调用方法后...");
  21. return returnValue;
  22. }
  23. }
  24. public class Solution {
  25. public static void main(String[] args) {
  26. DemoProxy proxy = new DemoProxy(new DemoImpl());
  27. DemoInterface service = Proxy.newInstance(
  28. DemoInterface.class.getClassLoader(),
  29. new Class<?>[]{DemoInterface.class},
  30. proxy
  31. );
  32. System.out.println(service.hello("呀哈喽!"));
  33. }
  34. }

你的框架实现了哪几种序列化方式,可以介绍下吗

实现了 JSON、Kryo和 Protobuf 的序列化。

JSON 是一种轻量级的数据交换语言,该语言以易于让人阅读的文字为基础,用来传输由属性值或者序列性的值组成的数据对象,类似 xml,Json 比 xml更小、更快更容易解析。JSON 由于采用字符方式存储,占用相对于字节方式较大,并且序列化后类的信息会丢失,可能导致反序列化失败。
剩下的都是基于字节的序列化。

Kryo 是一个快速高效的 Java 序列化框架,旨在提供快速、高效和易用的 API。无论文件、数据库或网络数据 Kryo 都可以随时完成序列化。 Kryo 还可以执行自动深拷贝、浅拷贝。这是对象到对象的直接拷贝,而不是对象->字节->对象的拷贝。kryo 速度较快,序列化后体积较小,但是跨语言支持较复杂。

protobuf(Protocol Buffers)是由 Google 发布的数据交换格式,提供跨语言、跨平台的序列化和反序列化实现,底层由 C++ 实现,其他平台使用时必须使用 protocol compiler 进行预编译生成 protoc 二进制文件。性能主要消耗在文件的预编译上。序列化反序列化性能较高,平台无关。

简单介绍一下 Netty

Netty 是一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端Netty 基于 NIO 的,封装了 JDK 的 NIO,让我们使用起来更加方法灵活。
为什么 Netty 性能高

  • IO 线程模型:同步非阻塞,用最少的资源做更多的事。
  • 内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
  • 内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
  • 串行化处理读写:避免使用锁带来的性能开销。
  • 高性能序列化协议:支持 protobuf 等高性能序列化协议。

Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池, boss 线程池和 worker 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 worker 线程池,其中 worker 线程池负责请求的 read 和 write 事件,由对应的Handler 处理。

说下 Netty 零拷贝

Netty 的零拷贝主要包含三个方面:

  • Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
  • Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。
  • Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。


Netty组件

  • Channel:Netty 网络操作抽象类,它除了包括基本的 I/O 操作,如 bind、connect、read、write 等。
  • EventLoop:主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的事情。
  • ChannelFuture:Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener()注册一个 ChannelFutureListener 监听事件,当操作执行成功或者失败时,监听就会自动触发返回结果。
  • ChannelHandler:充当了所有处理入站和出站数据的逻辑容器。ChannelHandler 主要用来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
  • ChannelPipeline:为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline,这个关联是永久性的。

Netty 中责任链

首先说明责任链模式:
适用场景:

  • 对于一个请求来说,如果有个对象都有机会处理它,而且不明确到底是哪个对象会处理请求时,我们可以考虑使用责任链模式实现它,让请求从链的头部往后移动,直到链上的一个节点成功处理了它为止

优点:

  • 发送者不需要知道自己发送的这个请求到底会被哪个对象处理掉,实现了发送者和接受者的解耦
  • 简化了发送者对象的设计
  • 可以动态的添加节点和删除节点

缺点:

  • 所有的请求都从链的头部开始遍历,对性能有损耗
  • 极差的情况,不保证请求一定会被处理

netty 的 pipeline 设计,就采用了责任链设计模式, 底层采用双向链表的数据结构, 将链上的各个处理器串联起来
客户端每一个请求的到来,netty 都认为,pipeline 中的所有的处理器都有机会处理它,因此,对于入栈的请求,全部从头节点开始往后传播,一直传播到尾节点(来到尾节点的 msg 会被释放掉)。
责任终止机制

  • 在pipeline中的任意一个节点,只要我们不手动的往下传播下去,这个事件就会终止传播在当前节点
  • 对于入站数据,默认会传递到尾节点,进行回收,如果我们不进行下一步传播,事件就会终止在当前节点

Netty 是如何保持长连接的(心跳)

首先 TCP 协议的实现中也提供了 keepalive 报文用来探测对端是否可用。TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。
ChannelOption.SO_KEEPALIVE, true 表示打开 TCP 的 keepAlive 设置。
TCP 心跳的问题:
考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态一直向当前服务器发送些必然会失败的请求。
Netty 中提供了 IdleStateHandler 类专门用于处理心跳。
IdleStateHandler 的构造函数如下:

  1. public IdleStateHandler(long readerIdleTime, long writerIdleTime,
  2. long allIdleTime,TimeUnit unit){
  3. }

第一个参数是隔多久检查一下读事件是否发生,如果 channelRead() 方法超过 readerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;
第二个参数是隔多久检查一下写事件是否发生,writerIdleTime 写空闲超时时间设定,如果 write() 方法超过 writerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;
第三个参数是全能型参数,隔多久检查读写事件;
第四个参数表示当前的时间单位。
所以这里可以分别控制读,写,读写超时的时间,单位为秒,如果是0表示不检测,所以如果全是0,则相当于没添加这个 IdleStateHandler,连接是个普通的短连接。

2.Dubbo

  1. 负载均衡 : 同一个服务部署在不同的机器时该调用那一台机器上的服务。
  2. 服务调用链路生成 : 随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,Dubbo 可以为我们解决服务之间互相是如何调用的。
  3. 服务访问压力以及时长统计、资源调度和治理 :基于访问压力实时管理集群容量,提高集群利用率。

📝 RPC框架 - 图3


Dubbo 架构中的核心角色

📝 RPC框架 - 图4

  • Container: 服务运行容器,负责加载、运行服务提供者。必须。
  • Provider: 暴露服务的服务提供方,会向注册中心注册自己提供的服务。必须。
  • Consumer: 调用远程服务的服务消费方,会向注册中心订阅自己所需的服务。必须。
  • Registry: 服务注册与发现的注册中心。注册中心会返回服务提供者地址列表给消费者。非必须。
  • Monitor: 统计服务的调用次数和调用时间的监控中心。服务消费者和提供者会定时发送统计数据到监控中心。 非必须。

注册中心的作用

注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互。

服务提供者宕机后,注册中心会做什么

注册中心会立即推送事件通知消费者。

监控中心的作用

监控中心负责统计各服务调用次数,调用时间等。

注册中心和监控中心都宕机的话,服务都会挂掉吗

不会。两者都宕机也不影响已运行的提供者和消费者,消费者在本地缓存了提供者列表。注册中心和监控中心都是可选的,服务消费者可以直连服务提供者。


Dubbo 的 SPI 机制

1.Java SPI机制

SPI(Service Provider Interface)可以帮助我们动态寻找服务/功能的实现。

Java SPI 就是这样做的,约定在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,然后文件里面记录的是此 jar 包提供的具体实现类的全限定名。
就是约定一个目录,根据接口名去那个目录找到文件,文件解析得到实现类的全限定名,然后循环加载实现类和创建其实例。
image.png
相信大家一眼就能看出来,Java SPI 在查找扩展实现类的时候遍历 SPI 的配置文件并且将实现类全部实例化,假设一个实现类初始化过程比较消耗资源且耗时,但是你的代码里面又用不上它,这就产生了资源的浪费。
所以说 Java SPI 无法按需加载实现类。

2.Dubbo SPI

Dubbo SPI 的具体原理是这样的:我们将接口的实现类放在配置文件中,我们在程序运行过程中读取配置文件,通过反射加载实现类。这样,我们可以在运行的时候,动态替换接口的实现类。和 IoC 的解耦思想是类似的。
image.png[

](https://blog.csdn.net/qq_35190492/article/details/108256452)

负载均衡策略


RandomLoadBalance

根据权重随机选择(对加权随机算法的实现)。这是Dubbo默认采用的一种负载均衡策略。
RandomLoadBalance 具体的实现原理非常简单,假如有两个提供相同服务的服务器 S1,S2,S1的权重为7,S2的权重为3。
我们把这些权重值分布在坐标区间会得到:S1->[0, 7) ,S2->(7, 10]。我们生成[0, 10) 之间的随机数,随机数落到对应的区间,我们就选择对应的服务器来处理请求。

LeastActiveLoadBalance

初始状态下所有服务提供者的活跃数均为 0(每个服务提供者的中特定方法都对应一个活跃数,我在后面的源码中会提到),每收到一个请求后,对应的服务提供者的活跃数 +1,当这个请求处理完之后,活跃数 -1。
因此,Dubbo 就认为谁的活跃数越少,谁的处理速度就越快,性能也越好,这样的话,我就优先把请求给活跃数少的服务提供者处理。
如果有多个服务提供者的活跃数相等怎么办?
很简单,那就再走一遍 RandomLoadBalance 。


ConsistentHashLoadBalance

ConsistentHashLoadBalance 即一致性Hash负载均衡策略。 ConsistentHashLoadBalance 中没有权重的概念,具体是哪个服务提供者处理请求是由你的请求的参数决定的,也就是说相同参数的请求总是发到同一个服务提供者。
📝 RPC框架 - 图7
另外,Dubbo 为了避免数据倾斜问题(节点不够分散,大量请求落到同一节点),还引入了虚拟节点的概念。通过虚拟节点可以让节点更加分散,有效均衡各个节点的请求量。


Dubbo序列化协议

1.JDK序列化

一般我们不会直接使用 JDK 自带的序列化方式。主要原因有两个:

  1. 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
  2. 性能差 :相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。

2.JSON

JSON是一种轻量级的数据交换格式,采用一种“键:值”对的文本格式来存储和表示数据,在系统交换数据过程中常常被使用,是一种理想的数据交换语言。
JSON 序列化由于性能问题,我们一般也不会考虑使用。
FastJson可以实现Java Bean和JSON字符串的互相转换,是序列化经常使用的一种方式。它具有速度快、使用广泛、使用简单等特点。但是如果使用的方式不正确,就可能导致StackOverflowError。
FastJson原理
FastJson的序列化过程,就是把一个内存中的Java Bean转换成JSON字符串,得到字符串之后就可以通过数据库等方式进行持久化了。
其实,对于JSON框架来说,想要把一个Java对象转换成字符串,可以有两种选择:

  • 基于成员变量
  • 基于setter/getter方法

常用的JSON序列化框架中,FastJson和jackson在把对象序列化成json字符串的时候,是通过遍历出该类中的所有getter方法进行的。

  1. JsonBean.getJsonString
  2. -> JSON.toJSONString
  3. -> JSONSerializer.write
  4. -> ASMSerializer_1_JsonBean.write
  5. -> JsonBean.getJsonString
  6. ->...

因为在FastJson将Java对象转换成字符串的时候,出现了死循环,所以导致了StackOverflowError。
调用链中的ASMSerializer_1_JsonBean,其实是FastJson利用ASM为JsonBean生成的一个Serializer,而这个Serializer本质上还是FastJson中内置的JavaBeanSerizlier。

之所以使用ASM技术,主要是FastJson想通过动态生成类来避免重复执行时的反射开销。但是,在FastJson中,两种序列化实现是并存的,并不是所有情况都需要通过ASM生成一个动态类。

防止死循环:
除了修改方法名以外,FastJson还提供了两个注解可以让我们使用,首先介绍JSONField注解,这个注解可以作用在方法上,如果其参数serialize设置成false,那么这个方法就不会被识别为getter方法,就不会参加序列化。

3.Kryo

  1. Kryo序列化的“对象”是数据以及少量元信息,这和JAVA默认的序列化的本质区别,java默认的序列化的目的是语言层次的,将类,对象的所有信息都序列化了,也就是就算是不加载类的定义,也可以根据序列化后的信息动态生成类的所有信息。而Kryo反序列化时,必须能加载类的定义,这样Kryo可以节省大量的字节空间。
  2. 使用变长int,变长long存储int,long类型,大大节省空间。
  3. 元数据(字符串类型)使用缓存机制,重复出现的字符串使用int来存储,节省存储空间。
  4. 字符串类型使用UTF-8