RPC 发展史
RPC(Remote Procedure Call)即远程过程调用,随着微服务的兴起,每个服务都拥有自己的数据库,负责各自的模块,例如 keystone(认证服务)负责用户信息、权限认证的内容,workOrder(工单服务)负责工单的流程流转。但是在 workOrder 中,可能也需要查询一些用户的信息,但用户信息表并不在该服务的数据库中,因此就需要调用 keystone 服务来获取用户信息,调用其他服务获取信息就是一次远程过程调用。
说个题外话,也不是完全都要这么实现。我见的对实时性要求不高的服务,可能会在服务中建一个叫 UserCache 的表,定时从真正的用户表同步数据,虽然这也需要服务调用,但针对本服务获取用户信息来讲,就可以查询 Cache 表而不用进行服务间调用。另外,也可以直连对方的数据库,使用jdbcTemplate 等进行数据库直接查询等,不过这里只讨论 RPC 就是了。
RPC 中的三个问题
我们知道 Socket 网络通信,实际上就可以在不同的服务间进行请求,但是如果调用一下对方的服务,都需要手动来创建 socket,也太复杂难懂了,而且还会有以下的问题。
RPC 既然叫远程过程调用,那肯定跟本地调用是不一样的,不一样在什么地方呢?思考一个减法的场景:
如果是本地调用,你直接调用这个方法就行了:
public Long sub(Long a, Long b) {
return a - b;
}
但是在 RPC 中是不可以的,首先,你需要让对方知道,你想要调用对方的 减法 方法,接着还需要传入两个数字,还需要标识哪个是被减数,哪个是减数,就像下面这样,甚至更极端一点,每个数字是大端存储还是小端存储 (大端存储与小端存储) 都要进行规定
sub 10 3
10 3 sub
10 sub 3
这就是第一个问题,协议约定问题:如何确定传输的语法,如何传递需要的参数
封装好了查询参数,怎么知道你想要调用的服务的地址呢(ip:port),假设服务端实现了多个远程调用,每个可能实现在不同的进程中,监听的端口也不一样,而且由于服务端都是自己实现的,不可能使用一个大家都公认的端口,而且有可能多个进程部署在一台机器上,大家需要抢占端口,为了防止冲突,往往使用随机端口,那客户端如何找到这些监听的端口呢?
这是第二个问题,服务发现问题:如何找到要进行调用的服务端信息
有了服务端地址,也封装好了请求参数,终于可以发送请求了,那么如果此时网络比较拥堵,请求包丢失了怎么办?应用需不需要进行重试,对性能的影响大不大?
这是第三个问题,传输问题:如何处理丢包、超时等问题
综上,RPC 框架需要解决如下三个问题:
- 协议约定
- 服务发现
- 传输
RPC 规范
RPC 有一篇很经典的论文:Implementing Remote Procedure Calls,定义了 RPC 的调用标准。后面所有 RPC 框架,都是按照这个标准模式来的。
协议约定
当客户端的应用想发起一个远程调用时,它实际是通过本地调用本地调用方的 Stub。它负责将调用的接口、方法和参数,通过约定的协议规范进行编码,并通过本地的 RPCRuntime 进行传输,将调用网络包发送到服务器。
服务器端的 RPCRuntime 收到请求后,交给提供方 Stub 进行解码,然后调用服务端的方法,服务端执行方法,返回结果,提供方 Stub 将返回结果编码后,发送给客户端,客户端的 RPCRuntime 收到结果,发给调用方 Stub 解码得到结果,返回给客户端。
也就是双方都有 stub 的存在,用来进行编码解码,类似于序列化和反序列化
服务发现
常用的方案为提供服务注册中心:
如 SpringCloud 集成 Eureka
如 Dubbo 集成 Zookeeper
传输
早期有的实现是由公司网络人员封装出一套类库,供大家开发使用,现在一般集成市面上比较优秀的网络框架实现,如 netty。
Sun RPC(ONC RPC)
最早的 RPC 的一种实现方式称为 Sun RPC 或 ONC RPC。Sun 公司是第一个提供商业化 RPC 库和 RPC 编译器的公司。这个 RPC 框架是在 NFS 协议中使用的。
协议约定
NFS(Network File System)就是网络文件系统。要使 NFS 成功运行,要启动两个服务端,一个是 mountd,用来挂载文件路径;一个是 nfsd,用来读写文件。NFS 可以在本地 mount 一个远程的目录到本地的一个目录,从而本地的用户在这个目录里面写入、读出任何文件的时候,其实操作的是远程另一台机器上的文件。
倒数第二部分的 XDR(External Data Representation,外部数据表示法)是一个标准的数据压缩格式,可以表示基本的数据类型,也可以表示结构体,主要用于编码和解码参数,客户端和服务端共享。
在客户端和服务端实现 RPC 的时候,首先要定义一个双方都认可的程序、版本、方法、参数等,双方约定为一个协议定义文件,接着通过 ONC 提供的工具生成双方的 Stub 程序。
传输
同时,大牛们写了 ONC RPC 的类库来解决传输中可能出现的错误、丢包等问题,通过状态机实现 Socket 的异步模型,流程非常复杂,参考下图:
服务发现
在 ONC RPC 中,服务发现是通过 portmapper 实现的。
portmapper 会启动在一个众所周知的端口上,RPC 程序由于是用户自己写的,会监听在一个随机端口上,但是 RPC 程序启动的时候,会向 portmapper 注册。客户端要访问 RPC 服务端这个程序的时候,首先查询 portmapper,获取 RPC 服务端程序的随机端口,然后向这个随机端口建立连接,开始 RPC 调用。从图中可以看出,mount 命令的 RPC 调用,就是这样实现的。
早期 ONC RPC 框架,以及 NFS 的实现,给出了解决 RPC 三大问题的示范性实现,也即协议约定要公用协议描述文件,并通过这个文件生成 Stub 程序
;RPC 的传输一般需要一个状态机
;需要另外一个进程专门做服务发现
。
【1】协议修改不灵活,尤其是版本变更(部分用户希望新增字段)
【2】双方的压缩格式(例如字段顺序)需要完全一致
SOAP
如果使用二进制编码传输协议,其实是很晦涩的,而使用文本传输协议,就变得通俗易懂多了,xml 就是一种常见的文本传输协议
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.5</version>
</dependency>
相比于 ONC RPC 来讲,如果提供出去的服务中有使用者希望进行字段新增,直接修改就行:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.5</version>
<test>666</test>
</dependency>
而对于那些不需要该字段的,忽略不解析即可,而且即使字段直接交换顺序也不影响最后的解析结果
协议约定和传输
基于 XML 的最著名的通信协议就是SOAP了,全称简单对象访问协议(Simple Object Access Protocol)。它使用 XML 编写简单的请求和回复消息,通常
用 HTTP 协议进行传输。
一般来讲,还需要对服务进行描述,因为调用的人不认识你,所以没办法找到你,问你的服务应该如何调用。
当然你可以写文档,然后放在官方网站上,但是你的文档不一定更新得那么及时,而且你也写的文档也不一定那么严谨,所以常常会有调试不成功的情况。因而,我们需要一种相对比较严谨的Web 服务描述语言,WSDL(Web Service Description Languages)。它也是一个 XML 文件。
具体可参考 XML Web 服务技术解析:WSDL 与 SOAP 原理、应用案例一览,总之就是对服务的信息进行了详细的描述,包括请求-响应类、数据名、数据类型等。
服务发现问题
有一个UDDI(Universal Description, Discovery, and Integration),也即统一描述、发现和集成协议。它其实是一个注册中心,服务提供方可以将上面的 WSDL 描述文件,发布到这个注册中心,注册完毕后,服务使用方可以查找到服务的描述,封装为本地的客户端进行调用。
总结
- 原来的二进制 RPC 有很多缺点,格式要求严格,修改过于复杂,不面向对象,于是产生了基于文本的调用方式——基于 XML 的 SOAP。
- SOAP 有三大要素:协议约定用 WSDL、传输协议用 HTTP、服务发现用 UDDL。
Restful
虽然 SOAP 使用 HTTP 协议,但一般只会使用 POST 请求方式,原因是它传输的内容包含 XML 数据,容易超出 URL限制,而且 Get 请求一般用于请求资源,不满足 SOAP 请求/响应模型的设计。
POST /create HTTP/1.1
Host: 127.0.0.1
Content-Type: application/xml; charset=utf-8
Content-Length: nnn
<?xml version="1.0"?>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.5</version>
</dependency>
协议约定和传输
于是出现了更为简单的文本化方式:Json + 请求方式
POST /create HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json; charset=utf-8
Content-Length: nnn
{
"dependency": {
"groupId": "com.alibaba",
"artifactId": "druid-spring-boot-starter",
"version": "1.2.5"
}
}
协议约定间使用 json,传输使用 HTTP
服务发现
Spring 系列里非常经典的微服务方案 Spring Cloud。在 Spring Cloud 中有一个组件叫 Eureka,就是用来做注册中心的。
服务分服务提供方(server),它向 Eureka 做服务注册、续约和下线等操作,注册的主要数据包括服务名、机器 IP、端口号、域名等等。
另外一方是服务消费方(client),向 Eureka 获取服务提供方的注册信息。为了实现负载均衡和容错,服务提供方可以注册多个。
当消费方要调用提供方服务的时候,会从注册中心读出多个服务来,进行负载均衡后选择一个利用 RestTemplate 工具,将请求对象转换为 JSON,并发起 Rest 调用,RestTemplate 的调用也是分 POST、PUT、GET、 DELETE 的,当结果返回的时候,根据返回的 JSON 解析成对象。
通过这样的封装,调用起来已经很方便了。顺便一提:也可以使用 Feign 通过声明式的接口进行更加简单的调用。
不过,虽然这样的方式更加的通俗易懂,也有缺点,文本类的序列化相比二进制来讲,一般情况下都是要占用更多的空间,也即需要传输更大的数据量。
二进制类 RPC 协议
出于对远程调用性能的考虑,很多公司选型的时候,还是希望采用更加省空间和带宽的二进制的方案。一个著名的例子就是 Dubbo 服务化框架二进制的 RPC 方式。
协议约定
Dubbo 中默认的 RPC 协议是 Hessian2。为了保证传输的效率,Hessian2 将远程调用序列化为二进制进行传输,并且可以进行一定的压缩。
相比于之前的二进制,Hessian2 有很大提升:
- 原来要定义一个协议文件,然后通过这个文件生成客户端和服务端的 Stub,才能进行相互调用,这样使得修改就会不方便。Hessian2 不需要定义这个协议文件,而是自描述的。关于调用哪个函数,参数是什么,另一方不需要拿到协议文件,而是拿到二进制,靠它本身根据 Hessian2 的规则,就能解析出来。
- 序列化效率提高。具体参看 Hessian2 序列化, 与 Protobuf 相比如何?
传输
Dubbo 会在客户端的本地启动一个 Proxy,其实就是客户端的 Stub,对于远程的调用都通过这个 Stub 进行封装。接下来,Dubbo 会从注册中心获取服务端的列表,根据路由规则和负载均衡规则,在多个服务端中选择一个最合适的服务端进行调用。
在 Dubbo 里面,使用了 Netty 的网络传输框架
。
调用服务端的时候,首先要进行编码和序列化,形成 Dubbo 头和序列化的方法和参数。将编码好的数据,交给网络客户端进行发送,网络服务端收到消息后,进行解码。然后将任务分发给某个线程进行处理,在线程中会调用服务端的代码逻辑,然后返回结果。
服务发现
Dubbo 搭配服务注册中心实现服务发现,一般配合 Zookeeper 使用。
基于此,简单对比下 SpringCloud 和 Dubbo:
- SpringCloud 有一套完整的微服务处理方案,包括服务注册与发现、网关、负载均衡、熔断降级、等
- Dubbo 只实现了 RPC 相关的一些服务治理内容
跨语言类 RPC 协议
通过上面所提到的 RPC 协议,我们对理想的 RPC 框架的定义也愈加明确了:
- 首先,传输性能很重要。因为服务之间的调用如此频繁了,还是二进制的越快越好。
- 其次,跨语言很重要。因为服务多了,什么语言写成的都有,而且不同的场景适宜用不同的语言,不能一个语言走到底。
- 最好既严谨又灵活,添加个字段不用重新编译和发布程序。
- 最好既有服务发现,也有服务治理,就像 Dubbo 和 Spring Cloud 一样。
那有没有一个比较理想的 RPC 框架呢,GRPC!
协议约定
在性能方面,GRPC 使用 protobuf 来作为序列化方式,压缩效率极高,有很多设计精巧的序列化方法。可以类比 redis 的数据结构设计,非常精妙。
在灵活性方面,Protocol Buffers 考虑了兼容性。在协议文件中,每一个字段都有修饰符:
- required:这个值不能为空,一定要有这么一个字段出现;
- optional:可选字段,可以设置,也可以不设置,如果不设置,则使用默认值;
- repeated:可以重复 0 到多次。
这样的设计方式给了客户端和服务端很大的灵活性。
传输
如果是 Java 技术栈,GRPC 的客户端和服务器之间通过 Netty Channel 作为数据通道,每个请求都被封装成 HTTP 2.0 的 Stream。
同时在 Go、Python、C++ 中,也都有性能优异的实现。
服务发现
GRPC 本身没有提供服务发现的机制,需要借助其他的组件,发现要访问的服务端,在多个服务端之间进行容错和负载均衡。
有一种对于 GRPC 支持比较好的负载均衡器 Envoy。其实 Envoy 不仅仅是负载均衡器,它还是一个高性能的 C++ 写的 Proxy 转发器,可以配置非常灵活的转发规则。
如果你比较了解 k8s,应该会知道 istio,istio 的核心支持就是 Envoy。
总结
- GRPC 是一种二进制,性能好,跨语言,还灵活,同时可以进行服务治理的多快好省的 RPC 框架,唯一不足就是还是要写协议文件。
- GRPC 序列化使用 Protocol Buffers,网络传输使用 HTTP 2.0,服务治理可以使用基于 Envoy 的 Service Mesh。