一、简介
在上一篇博客中我们介绍到了深入RPC原理的序列化技术,接下来我们来继续介绍深入RPC原理对应的其他技术。
二、深入RPC原理
2.1动态代理
2.1.1内部接口如何调用实现
RPC的调用对用户来讲是透明的,那内部是如何实现呢?内部核心技术采用的就是动态代理,RPC会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程实际绑定的是这个接口生成的代理类。在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入其它调用处理逻辑,比如连接负载均衡,日志记录等。
2.1.2JDK动态代理实现
实现代码:
public class JDKDynamicProxy {
/**
* 定义用户的接口
*/
public interface User {
String job();
}
/**
* 实际的调用对象
*/
public static class Teacher {
public String invoke(){
return "i'm a Teacher";
}
}
/**
* 创建JDK动态代理类
*/
public static class JDKProxy implements InvocationHandler {
private Object target;
JDKProxy(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] paramValues) {
return ((Teacher)target).invoke();
}
}
public static void main(String[] args){
// 构建代理器
JDKProxy proxy = new JDKProxy(new Teacher());
ClassLoader classLoader = JDKDynamicProxy.class.getClassLoader();
// 生成代理类
User user = (User) Proxy.newProxyInstance(classLoader, new Class[]{User.class}, proxy);
// 接口调用
System.out.println(user.job());
}
}
2.2服务注册中心
2.2.1 服务注册发现的作用
在高可用的生产环境中,一般都是以集群方式提供服务,集群里面的IP可能随时变化,也可能会随着维护扩充或减少节点,客户端需要及时感知服务端的变化,获取集群最新服务节点的连接信息。
2.2.2 服务注册发现功能
服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心内,注册中心将这个服务节点的IP和接口等连接信息保存下来,为了检测服务的服务端的有效状态,一般会建立双向心跳机制。
服务订阅:在服务调用方启动的时候,客户端去注册中心查找并订阅服务提供方的IP,然后缓存到本地,并用于后续的远程调用。如果注册中心信息发生变化,一般采用推送的方式做更新。
2.2.3 服务注册发现的具体流程
主流服务注册工具有Nacos、Consul、Zookeeper等。
基于Zookeeper的服务发现:Zookeeper集群作为注册中心集群,服务注册的时候只需要服务节点像Zookeeper节点写入注册信息即可,利用Zookeeper的Watcher机制完成服务订阅与服务下发功能。
- A. 先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例 如:/micro/service/com.itcast.xxService),在这个路径再创建服务提供方与调用方目(server、client),分别用来存储服务提供方和调用方的节点信息。
- B. 服务端发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储注册信 息,比如 IP,端口,服务名称等等。
- C. 客户端发起订阅时,会在服务调用方目录中创建一个临时节点,节点中存储调用方的信息,同时 watch 服务提供方的目录(/service/com.demo.xxService/server)中所有的服务节点数据。当服 务端产生变化时,比如下线或宕机等,ZooKeeper 就会通知给订阅的客户端。
Zookeeper方案的特点:Zookeeper的一大特点就是强一致性,Zookeeper集群的每个节点的数据每次发生更新操作,都会通知其他Zookeeper节点同时执行更新,它要求每个节点的数据能够实时的完全一致,这样也就会导致Zookeeper集群性能上的下降,ZK采用的CP模式(保持强一致性),如果要注重性能,可以考虑采用AP模式(保证最终一致性)的注册中心组建,比如Nacos等。
2.3网络IO模型
网络IO模型分为5种:
- 同步阻塞IO(BIO)
- 同步非阻塞IO(NIO)
- IO多路复用
- 信号驱动IO
- 异步非阻塞IO(AIO)
通常的是同步阻塞IO和IO多路复用模型。
2.3.1 什么是阻塞IO模型
通常由一个Acceptor线程负责监听客户端的连接。一般通过while(true)循环中服务端会调用accept()方法等待接收客户端的连接的方式监听请求,请求一旦接收到连接请求,就可以建立通信套接字,在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,知道客户端的操作执行完成。
系统内核IP操作分为两个阶段:等待数据和拷贝数据,而在这两个阶段中,应用进程IO操作的线程会一直都处于阻塞状态,如果基于Java多线程开发,那么每一个IO操作都要占用线程,直至IO操作完成。
2.3.2 IO多路复用
概念:服务端采用单线程过select/epoll机制,获取fd列表,遍历fd的所有时间,可以关注多个文件描述符,使其能够支持更多的并发连接。
IO多路复用的实现主要有select、poll和epoll模式。
文件描述符:在Linux系统中一切皆可以看成是文件,文件又可以分为:普通文件、目录文件、链接文件和设备文件。
文件描述符(file descriptor)是内核为了高效管理已经被打开的文件所创建的索引,用来指向被打开的文件。文件描述符的值是一个非负整数。
下图说明(左边是进程、中间是内核、右边是文件系统):
- A的文件描述符1和30都指向了同一个打开的文件句柄, 代表进程多次执行打开操作。
-
A 的文件描述符 2 和 B 的文件描述符 2 都指向文件句柄( #73 ),代表 A 和程 B 可能是父子进程或者 A 和进程 B 打开了同一个文件(低概率)。
-
(时间紧张可不讲) A 的描述符 0 和 B 的描述符 3 分别指向不同的打开文件句柄,但这些句柄均指 向i-node 表的相同条目( #1936 ),这种情况是因为每个进程各自对同一个文件发起了打开请求。
-
程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文 件,它的文件描述符会是3。
三者的区别:
select/poll处理流程:
epoll的处理流程:
2.4 零拷贝
系统内核处理 IO 操作分为两个阶段:等待数据和拷贝数据。
-
等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中。
-
拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。具体流程:
应用进程的每一次操作,都会把数据写到用户空间的缓冲区中,再由CPU将数据拷贝到系统内核的缓冲区中 ,之后再由DMA将这份数据拷贝到网卡中,最后由网卡发送出去,这里我们可以看到,一次写操作数据拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到程序。
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作都可以通过一种方式,让应用进程向用户控件写入或者读取数据,就如同直接向内核控件写入或者读取数据一样,在通过DMA将内核总的数据拷贝到网卡,或将网卡的数据拷贝到内核中。
2.4.1 RPC框架中的零拷贝应用
Netty框架是否也有零拷贝机制,其实Netty零拷贝机制有些不一样,他完全站在用户空间之上,也就是基于JVM之上,Netty当中的零拷贝是如何实现零拷贝的。
RPC 并不会把请求参数作为一个整体数据包发送到对端机器上,中间可能会拆分,也可能会合并其 他请求,所以消息都需要有边界。接收到消息之后,需要对数据包进行处理,根据边界对数据包进 行分割和合并,最终获得完整的消息。
Netty零拷贝主要体现在三个方面:
-
Netty 的接收和发送 ByteBuffer 是采用 DIRECT BUFFERS ,使用 堆外的直接内存 (内存对象分 配在JVM 中堆以外的内存)进行 Socket 读写,不需 要进行字节缓冲区的二次拷贝 。如果采用传 统堆内存(HEAP BUFFERS )进行 Socket 读写, JVM 会将 堆内存 Buffer 拷贝一份到直接内存 中 ,然后写入 Socket 中。
-
Netty 提供了 组合 Buffer 对象 ,也就是 CompositeByteBuf 类,可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。
-
Netty的文件传输采用了 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法,它可以直接将文件缓冲区的数据发送到目标 Channel ,避免了传统通过循环 write 方式导致的内存拷贝问题。
零拷贝带来的作用就是避免没必要的 CPU 拷贝,减少了 CPU 在用户空间与内核空间之间的上下文 切换,从而提升了网络通信效率与应用程序的整体性能。
Netty的零拷贝与操作系统的零拷贝是有一些区别的,Netty的零拷贝实际上是对用户控件中数据操作的优化,这对处理TCP传输中的拆包粘包问题有着重要意义,通过CompositeByteBuf可以有效解决这些问题。
在RPC框架的开发和应用过程中,我们要深入了解网络通信相关的原理知识,尽量做到零拷贝,比如采用Netty框架作为RPC通信;我们要合理使用ByteBuff子类,做到完全零拷贝,提升RPC框架的整体性能。
2.5时间轮
2.5.1为什么需要时间轮
在Dubbo中,为增强系统的容错能力,会有相应的监听判断处理机制,比如RPC调用的超时机制的实现,消费者判断RPC调用是否超时,如果超时结果返回给应用层。在Dubbo最开始的实现中,是将所有的返回结果(DefaultFuture)都放入一个集合中,并且通过一个定时任务,每隔 一定时间间隔就扫描所有的future,逐个判断是否超时。
这样子的实现方式虽然简单,但是存在一个问题就是会有很多无意义的遍历操作,比如一个RPC超时时间是10s,而设置超时判定的定时任务是2s执行一次,那么可能会有4次左右的无意义的循环遍历操作。
为了解决上述场景的类似问题,Dubbo借鉴Netty,引入了时间轮算法,减少无意义的轮训判断操作。
2.5.2 时间轮原理
对于以上问题, 目的是要减少额外的扫描操作就可以了。比如说一个定时任务是在5 秒之后执行,那么在 4.9 秒之后才扫描这个定时任务,这样就可以极大减少 CPU开销。这时我们就可以利用时钟轮的机制了。
时钟轮的实质上是参考了生活中的时钟跳动的原理,那么具体是如何实现呢?
在时钟轮机制中,有时间槽和时钟轮的概念,时间槽就相当于时钟的刻度;而时钟轮就相当于指针跳动的一个周期,我们可以将每个任务放到对应的时间槽位上。
如果时钟轮有 10 个槽位,而时钟轮一轮的周期是 10 秒,那么我们每个槽位的单位时间就是 1 秒,而下一层时间轮的周期就是 100 秒,每个槽位的单位时间也就是 10 秒,这就好比秒针与分 针, 在秒针周期下, 刻度单位为秒, 在分针周期下, 刻度为分。
假设现在我们有 3 个任务,分别是任务 A(0.9秒之后执行)、任务 B(2.1秒后执行)与任务 C(12.1秒之后执行),我们将这 3 个任务添加到时钟轮中,任务 A 被放到第 0 槽位,任务 B 被放 到第 2槽位,任务 C 被放到下一层时间轮的第2个槽位,如下图所示:
通过这个场景我们可以了解到,时钟轮的扫描周期仍是最小单位1秒,但是放置其中的任务并没有 反复扫描,每个任务会按要求只扫描执行一次, 这样就能够很好的解决CPU 浪费的问题。 叠加时钟轮, 无限增长, 效率会不断下降,该如何解决?设定三个时钟轮, 小时轮, 分钟轮, 秒 级轮。
2.5.2 Dubbo的时间轮原理是如何实现
主要是通过Timer,Timeout,TimerTask几个接口定义了一个定时器的模型,再通过 HashedWheelTimer这个类实现了一个时间轮定时器(默认的时间槽的数量是512,可以自定义这 个值)。它对外提供了简单易用的接口,只需要调用newTimeout接口,就可以实现对只需执行一 次任务的调度。通过该定时器,Dubbo在响应的场景中实现了高效的任务调度。
时间轮核心类HashedWheelTimer结构:
2.5.3 时间轮在RPC的应用
- 调用超时与重试机制:上面所讲的客户端超时的处理,就可以应用到时间轮,我们每发一次请求,都创建一个处理请求超时的定时任务放到时间轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽中的任务,这样子会节省大量的CPU。
- 定时心跳检测机制:RPC 框架调用端定时向服务端发送的心跳检测,来维护连接状态,我们可以将心跳的逻辑封装为一个心跳任务,放到时钟轮里。心跳是要定时重复执行的,而时钟轮中的任务执行一遍就被移除了,对于这种需要重复执行的定时任务我们该如何处理呢?我们在定时任务逻辑结束的最后,再加上一段逻辑, 重设这个任务的执行时间,把它重新丢回到时钟轮里。这样就可以实现循环执行。