1 PRC概述
RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调
用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制,让使用者不必显式的区分本地调
用和远程调用。
RPC的优点:
分布式设计、部署灵活、解耦服务、扩展性强
RPC框架优势:
RPC框架一般使用长链接,不必每次通信都要3次握手,减少网络开销。
RPC框架一般都有注册中心,有丰富的监控管理、发布、下线接口、动态扩展等,对调用方来说是
无感知、统一化的操作、协议私密,安全性较高
RPC 协议更简单内容更小,效率更高,服务化架构、服务化治理,RPC框架是一个强力的支撑。
RPC基于TCP实现,也可以基于Http2实现
2 RPC框架
Dubbo:国内最早开源的 RPC 框架,由阿里巴巴公司开发并于 2011 年末对外开源,仅支持 Java
语言。
Motan:新浪微博内部使用的 RPC 框架,于 2016 年对外开源,仅支持 Java 语言。
Tars:腾讯内部使用的 RPC 框架,于 2017 年对外开源,仅支持 C++ 语言。
Spring Cloud:国外 Pivotal 公司 2014 年对外开源的 RPC 框架,提供了丰富的生态组件。
gRPC:Google 于 2015 年对外开源的跨语言 RPC 框架,支持多种语言。
Thrift:最初是由 Facebook 开发的内部系统跨语言的 RPC 框架,2007 年贡献给了 Apache 基
金,成为 Apache 开源项目之一,支持多种语言。
3 应用场景
3.1 分布式操作系统的进程间通讯
进程间通讯是操作系统必须提供的基本设施之一,分布式操作系统必须提供分布于异构的结点机上进
程间的通讯机制,RPC是实现消息传送模式的分布式进程间通讯方式之一。
3.2 构造分布式设计的软件环境
由于分布式软件设计,服务与环境的分布性, 它的各个组成成份之间存在大量的交互和通讯, RPC是
其基本的实现方法之一。Dubbo分布式服务框架基于RPC实现,Hadoop也采用了RPC方式实现客
户端与服务端的交互。
3.3 远程数据库服务
在分布式数据库系统中,数据库一般驻存在服务器上,客户机通过远程数据库服务功能访问数据库
服务器,现有的远程数据库服务是使用RPC模式的。例如,Sybase和Oracle都提供了存储过程机
制,系统与用户定义的存储过程存储在数据库服务器上,用户在客户端使用RPC模式调用存储过
程。
3.4分布式应用程序设计
RPC机制与RPC工具为分布式应用程序设计提供了手段和方便, 用户可以无需知道网络结构和协议细
节而直接使用RPC工具设计分布式应用程序。
3.5分布式程序的调试
RPC可用于分布式程序的调试。使用反向RPC使服务器成为客户并向它的客户进程发出RPC,可以
调试分布式程序。例如,在服务器上运行一个远端调试程序,它不断接收客户端的RPC,当遇到一
个调试程序断点时,它向客户机发回一个RPC,通知断点已经到达,这也是RPC用于进程通讯的例
子。
4 RPC原理
4.1 具体调用过程:
1. 服务调用者(client客户端)通过本地调用的方式调用服务。
2. 客户端存根(client stub)接收到请求后负责将方法、入参等信息序列化(组装)成能够进行网络
传输的消息体。
3. 客户端存根(client stub)找到远程的服务地址,并且将消息通过网络发送给服务端。
4. 服务端存根(server stub)收到消息后进行解码(反序列化操作)。
5. 服务端存根(server stub)根据解码结果调用本地的服务进行相关处理。
6. 本地服务执行具体业务逻辑并将处理结果返回给服务端存根(server stub)。
7. 服务端存根(server stub)将返回结果重新打包成消息(序列化)并通过网络发送至消费方。
8. 客户端存根(client stub)接收到消息,并进行解码(反序列化)。
9. 服务调用方得到最终结果。
4.2 所涉及的技术:
1. 动态代理
生成Client Stub(客户端存根)和Server Stub(服务端存根)的时候需要用到java动态代理技术。
2. 序列化
在网络中,所有的数据都将会被转化为字节进行传送,需要对这些参数进行序列化和反序列化操
作。
目前主流高效的开源序列化框架有Kryo、fastjson、Hessian、Protobuf等。
3. NIO通信
Java 提供了 NIO 的解决方案,Java 7 也提供了更优秀的 NIO.2 支持。可以采用Netty或者mina框
架来解决NIO数据传输的问题。开源的RPC框架Dubbo就是采用NIO通信,集成支持netty、
mina、grizzly。
4. 服务注册中心
通过注册中心,让客户端连接调用服务端所发布的服务。主流的注册中心组件:Redis、Nacos、
Zookeeper、Consul 、Etcd。Dubbo采用的是ZooKeeper提供服务注册与发现功能。
5. 负载均衡
在高并发的场景下,需要多个节点或集群来提升整体吞吐能力。
6. 健康检查
健康检查包括,客户端心跳和服务端主动探测两种方式。
4.2.1 序列化技术
常用的序列化技术
1. JDK原生序列化
(1) 在Java中,序列化必须要实现java.io.Serializable接口。
(2) 通过ObjectOutputStream和ObjectInputStream对象进行序列化及反序列化操作。
(3) 虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点
是两个类的序列化 ID 是否一致
(也就是在代码中定义的序列ID private static final long serialVersionUID)
(4) 序列化并不会保存静态变量。
(5) 要想将父类对象也序列化,就需要让父类也实现Serializable 接口。
(6) Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该
变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如基本类型 int
为 0,封装对象型Integer则为null。
(7) 服务器端给客户端发送序列化对象数据并非加密的,如果对象中有一些敏感数据比如密码
等,那么在对密码字段序列化之前,最好做加密处理, 这样可以一定程度保证序列化对象的数
据安全。
String basePath = "D:/xxx";
FileOutputStream fos = new FileOutputStream(basePath +"xxx.clazz");
User user = new User();
user.setName("jack");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(user);
oos.flush();
oos.close();
FileInputStream fis = new FileInputStream(basePath +
"xxx.clazz");
ObjectInputStream ois = new ObjectInputStream(fis);
User deStudent = (User) ois.readObject();
ois.close();
2. JSON序列化
一般在HTTP协议的RPC框架通信中,会选择JSON方式。
优势:JSON具有较好的扩展性、可读性和通用性。
缺陷:JSON序列化占用空间开销较大,没有JAVA的强类型区分,需要通过反射解决,解析效率
和压缩率都较差。
如果对并发和性能要求较高,或者是传输数据量较大的场景,不建议采用JSON序列化方式。
3. Hessian2序列化
Hessian 是一个动态类型,二进制序列化,并且支持跨语言特性的序列化框架。
Hessian 性能上要比 JDK、JSON 序列化高效很多,并且生成的字节数也更小。有非常好的兼
容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。
User user = new User();
user.setName("jack");
//tradeUser对象序列化处理
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output output = new Hessian2Output(bos);
output.writeObject(user);
output.flushBuffer();
byte[] data = bos.toByteArray();
bos.close();
//tradeUser对象反序列化处理
ByteArrayInputStream bis = new ByteArrayInputStream(data);
Hessian2Input input = new Hessian2Input(bis);
User deTradeUser = (User) input.readObject();
input.close();
Hessian自身也存在一些缺陷需注意:
对Linked系列对象不支持,比如LinkedHashMap、LinkedHashSet 等,但可以通过
CollectionSerializer类修复。
Locale 类不支持,可以通过扩展 ContextSerializerFactory 类修复。
Byte/Short 在反序列化的时候会转成 Integer。
4.2.2 动态代理
RPC的调用对用户来讲是透明的,那内部是如何实现呢?内部核心技术采用的就是动态代理,RPC
会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接
口生成的代理类。
常用的动态代理
JDK动态代理:
Cglib 动态代理
Javassist 动态代理
Byte Buddy 字节码增强库
4.2.3 服务注册发现
服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心内,注册中心将这个服务
节点的 IP 和接口等连接信息保存下来。为了检测服务的服务端的有效状态,一般会建立双向心跳
机制。
服务订阅:在服务调用方启动的时候,客户端去注册中心查找并订阅服务提供方的 IP,然后缓存到
本地,并用于后续的远程调用。如果注册中心信息发生变化, 一般会采用推送的方式做更新。
主流服务注册工具有Nacos、Consul、Zookeeper等,
基于 ZooKeeper 的服务发现:
ZooKeeper 集群作为注册中心集群,服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册
信息即可,利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能。
A. 先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例
如:/micro/service/com.xxx.xxService),在这个路径再创建服务提供方与调用方目录
(server、client),分别用来存储服务提供方和调用方的节点信息。
B. 服务端发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储注册信 息,比如
IP,端口,服务名称等等。
C. 客户端发起订阅时,会在服务调用方目录中创建一个临时节点,节点中存储调用方的信息,同时
watch 服务提供方的目录(/service/com.xxx.xxService/server)中所有的服务节点数据。当服
务端产生变化时,比如下线或宕机等,ZooKeeper 就会通知给订阅的客户端。
ZooKeeper方案的特点:
ZooKeeper 的一大特点就是强一致性,ZooKeeper 集群的每个节点的数据每次发生更新操作,都
会通知其它 ZooKeeper 节点同时执行更新。它要求保证每个节点的数据能够实时的完全一致,这
样也就会导致ZooKeeper 集群性能上的下降,ZK是采用CP模式(保证强一致性),如果要注重性
能, 可以考虑采用AP模式(保证最终一致)的注册中心组件, 比如Nacos等。
4.2.4 网络IO模型
有哪些网络IO模型:
同步阻塞 IO(BIO)、同步非阻塞 IO(NIO)、IO 多路复用、信号驱动IO、异步非阻塞 IO(AIO)
常用的是同步阻塞 IO 和 IO 多路复用模型。
阻塞IO:
通常由一个独立的 Acceptor 线程负责监听客户端的连接。一般通过在while(true) 循环中服务
端会调用 accept() 方法等待接收客户端的连接的方式监听请求,请求一旦接收到一个连接请
求,就可以建立通信套接字,在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请
求,直到客户端的操作执行完成。
系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO
操作的线程会一直都处于阻塞状态,如果是基于 Java 多线程开发,那么每一个 IO 操作都要占用线
程,直至 IO 操作结束。
IO多路复用:
概念: 服务端采用单线程过select/epoll机制,获取fd列表, 遍历fd中的所有事件, 可以关注多个
文件描述符,使其能够支持更多的并发连接。
IO多路复用的实现主要有select,poll和epoll模式。
RPC 框架采用哪种网络 IO 模型?
1) IO 多路复用应用特点:
IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求,但
使用难度比较高。
2) 阻塞 IO应用特点:
与 IO 多路复用相比,阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较
低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不
需要发起 大量的select 调用,开销上要比 IO 多路复用低。
3) RPC框架应用:
RPC 调用在大多数的情况下,是一个高并发调用的场景, 在 RPC 框架的实现中,一般会选择 IO
多路复用的方式。在开发语言的网络通信框架的选型上,我们最优的选择是基于 Reactor 模式实现
的框架,如 Java 语言,首选的框架便是 Netty 框架(目前 Netty 是应用最为广泛的框架),并且
在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系
统内核不支持)。
4.2.5 零拷贝
系统内核处理 IO 操作分为两个阶段:等待数据和拷贝数据。
等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中。
拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。
应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内
核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。这里我们可以看
到,一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过
来,数据同样会拷贝两次才能让应用程序读取到数据。
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,
都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者
读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
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通信;我们要合理使用 ByteBuf 子类,做到完全零拷贝,提升 RPC 框
架的整体性能。
4.2.6 时间轮
为什么需要时间轮?
在Dubbo中,为增强系统的容错能力,会有相应的监听判断处理机制。比如RPC调用的超时机制的
实现,消费者判断RPC调用是否超时,如果超时会将超时结果返回给应用层。在Dubbo最开始的实
现中,是将所有的返回结果(DefaultFuture)都放入一个集合中,并且通过一个定时任务,每隔
一定时间间隔就扫描所有的future,逐个判断是否超时。
这样的实现方式虽然比较简单,但是存在一个问题就是会有很多无意义的遍历操作开销。比如一个
RPC调用的超时时间是10秒,而设置的超时判定的定时任务是2秒执行一次,那么可能会有4次左右
无意义的循环检测判断操作。
为了解决上述场景中的类似问题,Dubbo借鉴Netty,引入了时间轮算法,减少无意义的轮询判断
操作。
时间轮原理
对于以上问题, 目的是要减少额外的扫描操作就可以了。比如说一个定时任务是在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个槽位
Dubbo中的时间轮原理是如何实现?
主要是通过Timer,Timeout,TimerTask几个接口定义了一个定时器的模型,再通过
HashedWheelTimer这个类实现了一个时间轮定时器(默认的时间槽的数量是512,可以自定义这
个值)。它对外提供了简单易用的接口,只需要调用newTimeout接口,就可以实现对只需执行一
次任务的调度。通过该定时器,Dubbo在响应的场景中实现了高效的任务调度。
5. RPC的高级机制
5.1 异步处理机制
如果采用同步调用, CPU 大部分的时间都在等待而没有去计算,从而导致 CPU 的利用率不够。
调用端如何实现异步?
常用的方式就是Future 方式,它是返回 Future 对象,通过GET方式获取结果;或者采用入参为
Callback 对象的回调方式,处理结果。
服务端如何实现异步?
为了提升性能,连接请求与业务处理不会放在一个线程处理, 这个就是服务端的异步化。服务端业
务处理逻辑加入异步处理机制。
RPC框架的异步实现
RPC 框架的异步策略主要是调用端异步与服务端异步。调用端的异步就是通过 Future 方式实现异
步,调用端发起一次异步请求并且从请求上下文中拿到一个 Future,之后通过 Future 的 get 方法
获取结果,如果业务逻辑中同时调用多个其它的服务,则可以通过 Future 的方式减少业务逻辑的
耗时,提升吞吐量。
服务端异步则需要一种回调方式,让业务逻辑可以异步处理,之后调用 RPC 框架提供的回调接
口,将最终结果异步通知给调用端。这样就实现了RPC调用的全异步。
Dubbo源码:
异步调用: AsyncToSyncInvoker.invoke方法
获取结果:ChannelWrappedInvoker.doInvoke方法
5.2 路由与负载均衡
RPC路由策略:
从服务提供方节点集合里面选择一个合适的节点(负载均衡),把符合我们要求的节点筛选出来。
这个就是路由策略:
接收请求-->请求校验-->路由策略-->负载均衡-->
RPC框架中的负载均衡
RPC 的负载均衡是由 RPC 框架自身提供实现,自主选择一个最佳的服务节点,发起 RPC 调用请
求。
RPC 负载均衡策略一般包括轮询、随机、权重、最少连接等。Dubbo默认就是使用随机负载均衡
策略
5.3 熔断限流
为什么要进行限流?
在实际生产环境中,每个服务节点都可能由于访问量过大而引起一系列问题,就需要业务提供方能
够进行自我保护,从而保证在高访问量、高并发的场景下,系统依然能够稳定,高效运行。
服务端的自我保护实现
在Dubbo框架中, 可以通过Sentinel来实现更为完善的熔断限流功能,服务端是具体如何实现限流逻辑
的?
方法有很多种, 最简单的是计数器,还有平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。
Sentinel采用的是滑动窗口来实现的限流。
调用方的自我保护
一个服务 A 调用服务 B 时,服务 B 的业务逻辑又调用了服务 C,这时服务 C 响应超时,服务 B 就
可能会因为堆积大量请求而导致服务宕机,由此产生服务雪崩的问题。
熔断处理流程:
熔断机制:
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。
Sentinel 熔断降级组件它可以支持以下降级策略:
平均响应时间 ( DEGRADE_GRADE_RT ):当 1s 内持续进入 N 个请求,对应时刻的平均响应时间
(秒级)均超过阈值( count ,以 ms 为单位),那么在接下的时间窗口( DegradeRule 中
的 timeWindow ,以 s 为单位)之内,对这个方法的调用都会自动地熔断(抛出
DegradeException )。注意 Sentinel 默认统计的 RT 上限是 4900 ms,超出此阈值的都会
算作 4900 ms,若需要变更此上限可以通过启动配置项 -
Dcsp.sentinel.statistic.max.rt=xxx 来配置。
异常比例 ( DEGRADE_GRADE_EXCEPTION_RATIO ):当资源的每秒请求量 >= N(可配置),并
且每秒异常总数占通过量的比值超过阈值( DegradeRule 中的 count )之后,资源进入降
级状态,即在接下的时间窗口( DegradeRule 中的 timeWindow ,以 s 为单位)之内,对这
个方法的调用都会自动地返回。异常比率的阈值范围是 [0.0, 1.0] ,代表 0% - 100%。
异常数 ( DEGRADE_GRADE_EXCEPTION_COUNT ):当资源近 1 分钟的异常数目超过阈值之后会
进行熔断。注意由于统计时间窗口是分钟级别的,若 timeWindow 小于 60s,则结束熔断状
态后仍可能再进入熔断状态。