1 简介
RPC 的主要功能目标是让构建分布式计算(应用)更容易
,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用
。
2 调用分类
RPC 调用分以下两种:
- 同步调用客户方等待调用执行完成并返回结果。
- 异步调用客户方调用后不用等待执行结果返回,但依然可以通过回调通知等方式获取返回结果。 若客户方不关心调用返回结果,则变成单向异步调用,单向调用不用返回结果。
**异步和同步的区分在于是否等待服务端执行完成并返回结果
**。
3 结构拆解
RPC 服务方通过 RpcServer 去导出(export)远程接口方法
,而客户方通过 RpcClient 去引入(import)远程接口方法
。 客户方像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理 RpcProxy
。 代理封装调用信息并将调用转交给 RpcInvoker 去实际执行
。 在客户端的 RpcInvoker 通过连接器 RpcConnector 去维持与服务端的通道 RpcChannel, 并使用 RpcProtocol 执行协议编码(encode)并将编码后的请求消息通过通道发送给服务方。
RPC 服务端接收器 RpcAcceptor 接收客户端的调用请求
,同样使用 RpcProtocol 执行协议解码(decode)。 解码后的调用信息传递给 RpcProcessor 去控制处理调用过程,最后再委托调用给 RpcInvoker 去实际执行并返回调用结果
。
4 组件职责
上面我们进一步拆解了 RPC 实现结构的各个组件组成部分,下面我们详细说明下每个组件的职责划分。
RpcServer负责导出(export)远程接口
RpcClient负责导入(import)远程接口的代理实现
RpcProxy远程接口的代理实现
RpcInvoker客户方实现:负责编码调用信息和发送调用请求到服务方并等待调用结果返回
服务方实现:负责调用服务端接口的具体实现并返回调用结果
RpcProtocol负责协议编/解码
RpcConnector负责维持客户方和服务方的连接通道和发送数据到服务方
RpcAcceptor负责接收客户方请求并返回请求结果
RpcProcessor负责在服务方控制调用过程,包括管理调用线程池、超时时间等
RpcChannel数据传输通道
5 实现分析
在进一步拆解了组件并划分了职责之后,这里以在 java 平台实现该 RPC 框架概念模型为例,详细分析下实现中需要考虑的因素。
5.1 导出远程接口
导出远程接口的意思是指只有导出的接口可以供远程调用,而未导出的接口则不能
。 在 java 中导出接口的代码片段可能如下:
1
2
3
DemoService demo =
new
...;
RpcServer server =
new
...;
server.export(DemoService.``class``, demo, options);
我们可以导出整个接口,也可以更细粒度一点只导出接口中的某些方法,如:
1
2
// 只导出 DemoService 中签名为 hi(String s) 的方法
server.export(DemoService.``class``, demo,
"hi"``,
new
Class<?>[] { String.``class
}, options);
java 中还有一种比较特殊的调用就是多态,也就是一个接口可能有多个实现,那么远程调用时到底调用哪个?
这个本地调用的语义是通过 jvm 提供的引用多态性隐式实现的
,那么对于 RPC 来说跨进程的调用就没法隐式实现了。 如果前面 DemoService 接口有 2 个实现,那么在导出接口时就需要特殊标记不同的实现,如:
1
2
3
4
5
DemoService demo =
new
...;
DemoService demo2 =
new
...;
RpcServer server =
new
...;
server.export(DemoService.``class``, demo, options);
server.export(``"demo2"``, DemoService.``class``, demo2, options);
上面 demo2 是另一个实现,我们标记为 demo2 来导出, 那么远程调用时也需要传递该标记才能调用到正确的实现类,这样就解决了多态调用的语义
。
5.2 导入远程接口与客户端代理
导入相对于导出远程接口,客户端代码为了能够发起调用必须要获得远程接口的方法或过程定义
。目前,大部分跨语言平台 RPC 框架采用根据 IDL 定义通过 code generator 去生成 stub 代码,这种方式下实际导入的过程就是通过代码生成器在编译期完成的
。 我所使用过的一些跨语言平台 RPC 框架如 CORBAR、WebService、ICE、Thrift 均是此类方式。
代码生成的方式对跨语言平台 RPC 框架而言是必然的选择,而对于同一语言平台的 RPC 则可以通过共享接口定义来实现
。 在 java 中导入接口的代码片段可能如下:
1
2
3
RpcClient client =
new
...;
DemoService demo = client.refer(DemoService.``class``);
demo.hi(``"how are you?"``);
在 java 中 import 是关键字,所以代码片段中我们用 refer 来表达导入接口的意思
。 这里的导入方式本质也是一种代码生成技术,只不过是在运行时生成,比静态编译期的代码生成看起来更简洁些。 java 里至少提供了两种技术来提供动态代码生成,一种是 jdk 动态代理,另外一种是字节码生成
。 动态代理相比字节码生成使用起来更方便,但动态代理方式在性能上是要逊色于直接的字节码生成的,而字节码生成在代码可读性上要差很多。 两者权衡起来,个人认为牺牲一些性能来获得代码可读性和可维护性显得更重要。
5.3 协议编解码
客户端代理在发起调用前需要对调用信息进行编码,这就要考虑需要编码些什么信息并以什么格式传输到服务端才能让服务端完成调用
。 出于效率考虑,编码的信息越少越好(传输数据少),编码的规则越简单越好(执行效率高)。 我们先看下需要编码些什么信息:
调用编码接口方法:包括接口名、方法名
方法参数:包括参数类型、参数值
调用属性:包括调用属性信息,例如调用附件隐式参数、调用超时时间等
返回编码返回结果:接口方法中定义的返回值
返回码:异常返回码
返回异常信息:调用异常信息
除了以上这些必须的调用信息,我们可能还需要一些元信息以方便程序编解码以及未来可能的扩展
。 这样我们的编码消息里面就分成了两部分,一部分是元信息、另一部分是调用的必要信息
。 如果设计一种 RPC 协议消息的话,元信息我们把它放在协议消息头中,而必要信息放在协议消息体中
。 下面给出一种概念上的 RPC 协议消息设计格式:
消息头
magic : 协议魔数,为解码设计
header size: 协议头长度,为扩展设计
version : 协议版本,为兼容设计
st : 消息体序列化类型
hb : 心跳消息标记,为长连接传输层心跳设计
ow : 单向消息标记,
rp : 响应消息标记,不置位默认是请求消息
status code: 响应消息状态码
reserved : 为字节对齐保留
message id : 消息 id
body size : 消息体长度
消息体采用序列化编码,常见有以下格式
xml : 如 webservie SOAP
json : 如 JSON-RPC
binary: 如 thrift; hession; kryo 等
格式确定后编解码就简单了,由于头长度一定所以我们比较关心的就是消息体的序列化方式。 序列化我们关心三个方面:
序列化和反序列化的效率,越快越好。
序列化后的字节长度,越小越好。
序列化和反序列化的兼容性,接口参数对象若增加了字段,是否兼容。
5.4 传输服务
协议编码之后,自然就是需要将编码后的 RPC 请求消息传输到服务方,服务方执行后返回结果消息或确认消息给客户方。 RPC 的应用场景实质是一种可靠的请求应答消息流,和 HTTP 类似
。 因此选择长连接方式的 TCP 协议会更高效,与 HTTP 不同的是在协议层面我们定义了每个消息的唯一 id,因此可以更容易的复用连接
。
既然使用长连接,那么第一个问题是到底 client 和 server 之间需要多少根连接? 实际上单连接和多连接在使用上没有区别,对于数据传输量较小的应用类型,单连接基本足够
。 单连接和多连接最大的区别在于,每根连接都有自己私有的发送和接收缓冲区, 因此大数据量传输时分散在不同的连接缓冲区会得到更好的吞吐效率
。 所以,如果你的数据传输量不足以让单连接的缓冲区一直处于饱和状态的话,那么使用多连接并不会产生任何明显的提升,反而会增加连接管理的开销。
连接是由 client 端发起建立并维持。 如果 client 和 server 之间是直连的,那么连接一般不会中断(当然物理链路故障除外)。 如果 client 和 server 连接经过一些负载中转设备,有可能连接一段时间不活跃时会被这些中间设备中断。 为了保持连接有必要定时为每个连接发送心跳数据以维持连接不中断
。 心跳消息是 RPC 框架库使用的内部消息,在前文协议头结构中也有一个专门的心跳位, 就是用来标记心跳消息的,它对业务应用透明。
5.5 执行调用
client stub 所做的事情仅仅是编码消息并传输给服务方,而真正调用过程发生在服务方
。 server stub 从前文的结构拆解中我们细分了 RpcProcessor 和 RpcInvoker 两个组件, 一个负责控制调用过程,一个负责真正调用
。这里我们还是以 java 中实现这两个组件为例来分析下它们到底需要做什么?
java 中实现代码的动态接口调用目前一般通过反射调用。 除了原生的 jdk 自带的反射,一些第三方库也提供了性能更优的反射调用, 因此 RpcInvoker 就是封装了反射调用的实现细节
。
调用过程的控制需要考虑哪些因素,RpcProcessor 需要提供什么样地调用控制服务呢? 下面提出几点以启发思考:
- 效率提升每个请求应该尽快被执行,因此我们不能每请求来再创建线程去执行,需要提供线程池服务。
- 资源隔离当我们导出多个远程接口时,如何避免单一接口调用占据所有线程资源,而引发其他接口执行阻塞。
- 超时控制当某个接口执行缓慢,而 client 端已经超时放弃等待后,server 端的线程继续执行此时显得毫无意义。
5.6 异常处理
无论 RPC 怎样努力把远程调用伪装的像本地调用,但它们依然有很大的不同点,而且有一些异常情况是在本地调用时绝对不会碰到的。在说异常处理之前,我们先比较下本地调用和 RPC调用的一些差异:
- 本地调用一定会执行,而远程调用则不一定,调用消息可能因为网络原因并未发送到服务方。
- 本地调用只会抛出接口声明的异常,而远程调用还会抛出 RPC 框架运行时的其他异常。
- 本地调用和远程调用的性能可能差距很大,这取决于 RPC 固有消耗所占的比重。
正是这些区别决定了使用 RPC 时需要更多考量。 当调用远程接口抛出异常时,异常可能是一个业务异常, 也可能是 RPC 框架抛出的运行时异常(如:网络中断等)
。 业务异常表明服务方已经执行了调用,可能因为某些原因导致未能正常执行, 而 RPC 运行时异常则有可能服务方根本没有执行,对调用方而言的异常处理策略自然需要区分。
由于 RPC 固有的消耗相对本地调用高出几个数量级,本地调用的固有消耗是纳秒级,而 RPC 的固有消耗是在毫秒级
。 那么对于过于轻量的计算任务就并不合适导出远程接口由独立的进程提供服务, 只有花在计算任务上时间远远高于 RPC 的固有消耗才值得导出为远程接口提供服务
。
6 如何调用他人的远程服务
由于各服务部署在不同机器,服务间的调用免不了网络通信过程,服务消费方每调用一个服务都要写一坨网络通信相关的代码,不仅复杂而且极易出错。
如果有一种方式能让我们像调用本地服务一样调用远程服务,而让调用者对网络通信这些细节透明
,那么将大大提高生产力,比如服务消费方在执行helloWorldService.sayHello(“test”)时,实质上调用的是远端的服务。这种方式其实就是RPC(Remote Procedure Call Protocol)
,在各大互联网公司中被广泛使用,如阿里巴巴的hsf、dubbo(开源)、Facebook的thrift(开源)、Google grpc(开源)、Twitter的finagle等。
要让网络通信细节对使用者透明,我们自然需要对通信细节进行封装
,我们先看下一个RPC调用的流程:
1)服务消费方(client)调用以本地调用方式调用服务;
2)client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;
3)client stub找到服务地址,并将消息发送到服务端;
4)server stub收到消息后进行解码;
5)server stub根据解码结果调用本地的服务;
6)本地服务执行并将结果返回给server stub;
7)server stub将返回结果打包成消息并发送至消费方;
8)client stub接收到消息,并进行解码;
9)服务消费方得到最终结果。
RPC的目标就是要2~8这些步骤都封装起来,让用户对这些细节透明。
6.1 怎么做到透明化远程服务调用
怎么封装通信细节才能让用户像以本地调用方式调用远程服务呢?对java来说就是使用代理!java代理有两种方式:1) jdk 动态代理;2)字节码生成
。尽管字节码生成方式实现的代理更为强大和高效,但代码不易维护,大部分公司实现RPC框架时还是选择动态代理方式。
下面简单介绍下动态代理怎么实现我们的需求。我们需要实现RPCProxyClient代理类,代理类的invoke方法中封装了与远端服务通信的细节
,消费方首先从RPCProxyClient获得服务提供方的接口,当执行helloWorldService.sayHello(“test”)方法时就会调用invoke方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public
class
RPCProxyClient
implements
java.lang.reflect.InvocationHandler{
private
Object obj;
public
RPCProxyClient(Object obj){
this``.obj=obj;
}
/**
* 得到被代理对象;
*/
public
static
Object getProxy(Object obj){
return
java.lang.reflect.Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getInterfaces(),
new
RPCProxyClient(obj));
}
/**
* 调用此方法执行
*/
public
Object invoke(Object proxy, Method method, Object[] args)
throws
Throwable {
//结果参数;
Object result =
new
Object();
// ...执行通信相关逻辑
// ...
return
result;
}
}
public
class
Test {
public
static
void
main(String[] args) {
HelloWorldService helloWorldService = (HelloWorldService)RPCProxyClient.getProxy(HelloWorldService.``class``);
helloWorldService.sayHello(``"test"``);
}
}
6.2 怎么对消息进行编码和解码
确定消息数据结构上节讲了invoke里需要封装通信细节,而
通信的第一步就是要确定客户端和服务端相互通信的消息结构
。客户端的请求消息结构一般需要包括以下内容:1)接口名称
在我们的例子里接口名是“HelloWorldService”,如果不传,服务端就不知道调用哪个接口了;
2)方法名
一个接口内可能有很多方法,如果不传方法名服务端也就不知道调用哪个方法;
3)参数类型&参数值
参数类型有很多,比如有bool、int、long、double、string、map、list,甚至如struct(class);以及相应的参数值;
4)超时时间
5)requestID
标识唯一请求id,在下面一节会详细描述requestID的用处。
同理,服务端返回的消息结构一般包括以下内容。
1)返回值
2)状态code
3)requestID
序列化一旦确定了消息的数据结构后,下一步就是要考虑序列化与反序列化了。
什么是序列化?
序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程。什么是反序列化?
将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程。为什么需要序列化?
转换为二进制串后才好进行网络传输嘛!为什么需要反序列化?将二进制转换为对象才好进行后续处理!现如今序列化的方案越来越多,每种序列化方案都有优点和缺点,它们在设计之初有自己独特的应用场景,那到底选择哪种呢?从RPC的角度上看,主要看三点:
1)通用性
,比如是否能支持Map等复杂的数据结构;2)性能
,包括时间复杂度和空间复杂度,由于RPC框架将会被公司几乎所有服务使用,如果序列化上能节约一点时间,对整个公司的收益都将非常可观,同理如果序列化上能节约一点内存,网络带宽也能省下不少;3)可扩展性
,对互联网公司而言,业务变化快,如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,删除老的字段,而不影响老的服务,这将大大提供系统的健壮性。目前国内各大互联网公司广泛使用hessian、protobuf、thrift、avro等成熟的序列化解决方案来搭建RPC框架,这些都是久经考验的解决方案。
6.3 通信
消息数据结构被序列化为二进制串后,下一步就要进行网络通信了。目前有两种IO通信模型:1)BIO;2)NIO
。一般RPC框架需要支持这两种IO模型。
如何实现RPC的IO通信框架?1)使用java nio方式自研
,这种方式较为复杂,而且很有可能出现隐藏bug,见过一些互联网公司使用这种方式;2)基于mina
,mina在早几年比较火热,不过这些年版本更新缓慢;3)基于[Netty](https://www.oschina.net/action/GoToLink?url=http%3A%2F%2Fres.importnew.com%2Fnetty)
,现在很多RPC框架都直接基于netty这一IO通信框架,比如阿里巴巴的HSF、dubbo,Twitter的finagle等。
6.4 消息里为什么要带有requestID
如果使用netty的话,一般会用channel.writeAndFlush()方法来发送消息二进制串,这个方法调用后对于整个远程调用(从发出请求到接收到结果)来说是一个异步的,即对于当前线程来说,将请求发送出来后,线程就可以往后执行了,至于服务端的结果,是服务端处理完成后,再以消息的形式发送给客户端的
。于是这里出现以下两个问题:
1)怎么让当前线程“暂停”,等结果回来后,再向后执行?
2)如果有多个线程同时进行远程方法调用,这时建立在client server之间的socket连接上会有很多双方发送的消息传递,前后顺序也可能是随机的,server处理完结果后,将结果消息发送给client,client收到很多消息,怎么知道哪个消息结果是原先哪个线程调用的?
如下图所示,线程A和线程B同时向client socket发送请求requestA和requestB,socket先后将requestB和requestA发送至server,而server可能将responseA先返回,尽管requestA请求到达时间更晚。我们需要一种机制保证responseA丢给ThreadA,responseB丢给ThreadB
。
怎么解决呢?
1)client线程每次通过socket调用一次远程接口前,生成一个唯一的ID,即requestID(requestID必需保证在一个Socket连接里面是唯一的),一般常常使用AtomicLong从0开始累计数字生成唯一ID
;
2)将处理结果的回调对象callback,存放到全局ConcurrentHashMap里面put(requestID, callback)
;
3)当线程调用channel.writeAndFlush()发送消息后,紧接着执行callback的get()方法试图获取远程返回的结果。在get()内部,则使用synchronized获取回调对象callback的锁,再先检测是否已经获取到结果,如果没有,然后调用callback的wait()方法,释放callback上的锁,让当前线程处于等待状态
。
4)服务端接收到请求并处理后,将response结果(此结果中包含了前面的requestID)发送给客户端,客户端socket连接上专门监听消息的线程收到消息,分析结果,取到requestID,再从前面的ConcurrentHashMap里面get(requestID),从而找到callback对象,再用synchronized获取callback上的锁,将方法调用结果设置到callback对象里,再调用callback.notifyAll()唤醒前面处于等待状态的线程
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public
Object get() {
synchronized
(``this``) {
// 旋锁
while
(!isDone) {
// 是否有结果了
wait();
//没结果是释放锁,让当前线程处于等待状态
}
}
}
private
void
setDone(Response res) {
this``.res = res;
isDone =
true``;
synchronized
(``this``) {
//获取锁,因为前面wait()已经释放了callback的锁了
notifyAll();
// 唤醒处于等待的线程
}
}
7 如何发布自己的服务
如何让别人使用我们的服务呢?有同学说很简单嘛,告诉使用者服务的IP以及端口就可以了啊。确实是这样,这里问题的关键在于是自动告知还是人肉告知。
人肉告知的方式:如果你发现你的服务一台机器不够,要再添加一台,这个时候就要告诉调用者我现在有两个ip了,你们要轮询调用来实现负载均衡;调用者咬咬牙改了,结果某天一台机器挂了,调用者发现服务有一半不可用,他又只能手动修改代码来删除挂掉那台机器的ip。现实生产环境当然不会使用人肉方式。
有没有一种方法能实现自动告知,即机器的增添、剔除对调用方透明,调用者不再需要写死服务提供方地址?当然可以,现如今zookeeper被广泛用于实现服务自动注册与发现功能!
简单来讲,zookeeper可以充当一个服务注册表(Service Registry),让多个服务提供者形成一个集群,让服务消费者通过服务注册表获取具体的服务访问地址(ip+端口)去访问具体的服务提供者
。如下图所示:
具体来说,zookeeper就是个分布式文件系统,每当一个服务提供者部署后都要将自己的服务注册到zookeeper的某一路径上
: /{service}/{version}/{ip:port}, 比如我们的HelloWorldService部署到两台机器,那么zookeeper上就会创建两条目录:分别为/HelloWorldService/1.0.0/100.19.20.01:16888 /HelloWorldService/1.0.0/100.19.20.02:16888。
zookeeper提供了“心跳检测”功能,它会定时向各个服务提供者发送一个请求(实际上建立的是一个 socket 长连接),如果长期没有响应,服务中心就认为该服务提供者已经“挂了”,并将其剔除
,比如100.19.20.02这台机器如果宕机了,那么zookeeper上的路径就会只剩/HelloWorldService/1.0.0/100.19.20.01:16888。
服务消费者会去监听相应路径(/HelloWorldService/1.0.0),一旦路径上的数据有任务变化(增加或减少),zookeeper都会通知服务消费方服务提供者地址列表已经发生改变,从而进行更新
。
更为重要的是zookeeper 与生俱来的容错容灾能力(比如leader选举),可以确保服务注册表的高可用性。