远程服务将计算机程序的工作范围从单机扩展至网络,从本地延伸至远程,是构建分布式系统的首要基础。//远程服务调用(Remote Procedure Call,RPC)
1、进程间通信
远程服务调用 RPC 出现的最初目的,就是为了让计算机能够与调用本地方法一样去调用远程方法。下边是一个本地方法的调用示例:
public static void main(String[] args) {
System.out.println("hello world");
}
当调用 println() 方法输出 hello world 这行时时,计算机需要处理如下的步骤:
- 传递方法参数:将字符串 hello world 的引用地址压栈。
- 确定方法版本:根据println()方法的签名,确定其执行版本。但这并不是一个简单的过程,无论是编译时静态解析,还是运行时动态分派,都必须根据某些语言规范中明确定义的原则,找到明确的被调用者,“明确”是指唯一的一个被调用者,或者有严格优先级的多个被调用者,譬如不同的重载版本。
- 执行被调方法:从栈中弹出 Parameter 的值或引用,并以此为输入,执行被调用者的内部的逻辑。
- 返回执行结果:将调用者的执行结果压栈,并将程序的指令流恢复到 Call Site(调用位置)的下一条指令,继续向下执行。
试想,如果 println()方法不在当前进程的内存地址空间中会发生什么问题呢?
不难想到,这样会至少面临两个直接的障碍。
首先,第一步和第四步所做的传递参数、传回结果都依赖于栈内存,如果Caller(调用者)与Callee(被调用者)分属不同的进程,就不会拥有相同的栈内存,此时将参数在 Caller 进程的内存中压栈,对于 Callee 进程的执行毫无意义。//无法执行方法
其次,第二步的方法版本选择依赖于语言规则,如果 Caller 与 Callee 不是同一种语言实现的程序,方法版本选择就将是一项模糊的不可知行为。//不兼容版本
所以,我们又该如何去解决两个进程之间如何交换数据的问题呢?
以下是解决该问题的几种思路:
(1)管道(Pipe):管道类似于两个进程间的桥梁,可通过管道在进程间传递少量的字符流或字节流。//例如:ps -ef | grep java
(2)信号(Signal):信号用于通知目标进程有某种事件发生。
(3)信号量(Semaphore):信号量用于在两个进程之间同步协作手段,它相当于操作系统提供的一个特殊变量,程序可以在上面进行 wait() 和 notify() 操作。
(4)消息队列(Message Queue):以上三种方式只适合传递少量消息,POSIX 标准中定义了可用于进程间数据量较多的通信的消息队列。进程可以向队列添加消息,被赋予读权限的进程还可以从队列消费消息。消息队列克服了信号承载信息量少、管道只能用于无格式字节流以及缓冲区大小受限等缺点,但实时性相对受限。
(5)共享内存(Shared Memory):允许多个进程访问同一块公共内存空间,这是效率最高的进程间通信形式。
(6)本地套接字接口(IPC Socket):消息队列与共享内存只适合单机多进程间的通信,套接字接口则是更普适的进程间通信机制,可用于不同机器之间的进程通信。套接字(Socket)起初是由 UNIX 系统的 BSD 分支开发出来的,现在已经移植到所有主流的操作系统上。出于效率考虑,当仅限于本机进程间通信时,套接字接口是被优化过的,不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只是简单地将应用层数据从一个进程复制到另一个进程,这种进程间通信方式即本地套接字接口(UNIX Domain Socket),又叫作 IPC Socket。
2、通信成本
这里只考虑使用本地套接字接口(IPC Socket)进行进程间通信这种方案。这是因为最初计算机科学家们的想法,就是将 RPC 作为 IPC 的一种特例来看待的。
基于套接字接口的通信方式(IPC Socket),它不仅适用于本地相同机器的不同进程间通信,由于 Socket 是网络栈的统一接口,它也能支持基于网络的跨机进程间通信。譬如Linux系统的图形化界面、X Window服务器和GUI程序之间的交互就是由这套机制来实现的。这样做的好处是,由于Socket是各个操作系统都提供的标准接口,完全有可能把远程方法调用的通信细节隐藏在操作系统底层,从应用层面上来看可以做到远程调用与本地的进程间通信在编码上完全一致。
问题在于,如果隐藏了远程方法调用的通信细节,就很容易让人忽略了网络的通信问题,造成网络通信无成本的错觉。以下列举了通过网络进行分布式运算的八宗罪:
- 1)The network is reliable.——网络是可靠的。
- 2)Latency is zero.——延迟是不存在的。
- 3)Bandwidth is infinite.——带宽是无限的。
- 4)The network is secure.——网络是安全的。
- 5)Topology doesn’t change.——拓扑结构是一成不变的。
- 6)There is one administrator.——总会有一个管理员。
- 7)Transport cost is zero.——不必考虑传输成本。
- 8)The network is homogeneous.——网络都是同质化的。
所以,如果远程服务调用要透明化,就必须为这些罪过埋单。// RPC的问题
所以,什么是远程服务调用呢?通过以上分析,我们就能够有更好的理解了
远程服务调用是指位于互不重合的内存地址空间中的两个程序,在语言层面上,以同步的方式使用带宽有限的信道来传输程序控制信息。//注意是同步方式
3、三个基本问题
对于所有的RPC协议,都不外乎变着花样使用各种手段来解决以下三个基本问题。
(1)如何表示数据
这里的数据包括传递给方法的参数以及方法执行后的返回值。
其实就是如何对数据进行序列化与反序列化。所以每种 RPC 协议都应该要有对应的序列化协议。比如,Java RMI 的Java对象序列化流协议,gRPC的Protocol Buffers,众多轻量级RPC支持的JSON序列化等。
(2)如何传递数据
准确地说,是指如何通过网络,在两个服务的 Endpoint 之间相互操作、交换数据。
这里“交换数据”通常指的是应用层协议,实际传输一般是基于TCP、UDP等标准的传输层协议来完成的。
两个服务交互不是只扔个序列化数据流来表示参数和结果就行,许多在此之外的信息,譬如异常、超时、安全、认证、授权、事务等,都可能产生双方需要交换信息的需求。在计算机科学中,专门有一个名词“Wire Protocol”来表示这种两个Endpoint之间交换这类数据的行为,常见的Wire Protocol 有://如何交换?需要定义一套规则
- Java RMI 的 Java 远程消息交换协议 //依赖于特定语言
- 如果要求足够简单,双方都是 HTTP Endpoint,直接使用 HTTP 协议也是可以的(如JSON-RPC)
(3)如何表示方法
每种语言的方法签名都可能有差别,所以“如何表示同一个方法”,“如何找到对应的方法”还是需要一个统一的跨语言的标准才行。这个标准可以非常简单,譬如直接给程序的每个方法都规定一个唯一的、在任何机器上都绝不重复的编号,调用时压根不管它是什么方法、签名是如何定义的,直接传这个编号就能找到对应的方法。这种听起既粗鲁又寒碜的办法,还真的就是 DCE/RPC 当初准备的解决方案。//唯一的绝不重复的编码方案UUID就来源于此
后来,DCE 还是弄出了一套与语言无关的接口描述语言(Interface Description Language,IDL),成为此后许多 RPC 参考或依赖的基础(如CORBA的OMG IDL)。如今,用于表示方法的协议还有:
- Android 的 Android 接口定义语言(Android Interface Definition Language,AIDL)
- CORBA 的 OMG 接口定义语言(OMG Interface Definition Language,OMG IDL)
- Web Service 的 Web 服务描述语言(Web Service Description Language,WSDL)
- JSON-RPC 的 JSON Web 服务协议(JSON Web Service Protocol,JSON-WSP)
总之,以上 RPC 中的三个基本问题,全部都可以在本地方法调用过程中找到对应的解决方案。RPC的设计始于本地方法调用,尽管早已不再追求实现与本地方法调用完全一致的目的,但其设计思路仍然带有本地方法调用的深刻烙印。所以抓住两者间的联系来类比,对我们更深刻地理解 RPC 的本质会很有帮助。
4、统一RPC的困扰
需要说明的是,同时支持跨系统、跨语言的统一 RPC 设计和使用起来不是非常复杂就是非常繁琐,所以目前并没有一款普适性的统一 RPC。
面向透明的、简单的 RPC 协议,如 DCE/RPC、DCOM、Java RMI,要么依赖于操作系统,要么依赖于特定语言,总有一些先天约束;那些面向通用的、普适的 RPC 协议,如 CORBA,就无法逃过使用复杂性的困扰,CORBA 烦琐的 OMG IDL、ORB 都是很好的佐证;而那些意图通过技术手段来屏蔽复杂性的 RPC 协议,如 Web Service,又不免受到性能问题的束缚。所以简单、普适、高性能这三点,似乎真的很难同时满足。
5、分裂的RPC
由于一直没有一个同时满足以上三点的“完美RPC协议”出现,所以远程服务器调用这个小小的领域,逐渐进入群雄混战、百家争鸣的战国时代,距离“统一”越来越远,并一直延续至今。
现在,已经相继出现过RMI(Sun/Oracle)、Thrift(Facebook/Apache)、Dubbo(阿里巴巴/Apache)、gRPC(Google)、Motan1/2(新浪)、Finagle(Twitter)、brpc(百度/Apache)、.NET Remoting(微软)、Arvo(Hadoop)、JSON-RPC 2.0(公开规范,JSON-RPC工作组)等难以穷举的协议和框架。
今时今日,任何一款具有生命力的RPC框架,都不再去追求大而全的“完美”,而是以某个具有针对性的特点作为主要的发展方向,举例分析如下:
(1)朝着面向对象发展,不满足于 RPC 将面向过程的编码方式带到分布式,希望在分布式系统中也能够进行跨进程的面向对象编程,代表为 RMI、.NET Remoting,之前的 CORBA 和 DCOM 也可以归入这类。这种方式有一个别名叫作分布式对象(Distributed Object)。
(2)朝着性能发展,代表为 gRPC 和 Thrift。决定RPC性能的主要因素有两个:序列化效率和信息密度。序列化效率很好理解,序列化输出结果的容量越小,速度越快,效率自然越高;信息密度则取决于协议中有效负载(Payload)所占总传输数据的比例大小,使用传输协议的层次越高,信息密度就越低,SOAP 使用 XML 拙劣的性能表现就是前车之鉴。gRPC 和 Thrift 都有自己优秀的专有序列化器,而传输协议方面,gRPC 是基于 HTTP/2 的,支持多路复用和 Header 压缩,Thrift 则直接基于传输层的 TCP 协议来实现,省去了应用层协议的额外开销。
(3)朝着简化发展,代表为 JSON-RPC 牺牲了功能和效率,换来的是协议的简单轻便,接口与格式都更为通用,尤其适合用于Web浏览器这类一般不会有额外协议支持、额外客户端支持的应用场合。
所以,不同的 RPC 框架所提供的特性或多或少是有矛盾的,很难有某一种框架能满足所有需求。
若要朝着面向对象发展,就注定不会太简单,如建 Stub、Skeleton 就很烦了,即使由 IDL 生成也很麻烦;功能多起来,协议就会更复杂,效率一般也会受影响;要简单易用,那很多事情就必须遵循约定而不是自行配置;要重视效率,那就需要采用二进制的序列化器和较底层的传输协议,支持的语言范围容易受限。
也正是每一种RPC框架都有不完美的地方,所以才导致不断有新的 RPC 轮子出现,也决定了
在选择框架时,在获得一些利益的同时,要付出另外一些代价。
到了最近几年,RPC 框架有明显向更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再追求独立地解决 RPC 的全部三个问题(表示数据、传递数据、表示方法),而是将一部分功能设计成扩展点,让用户自己选择。框架聚焦于提供核心的、更高层次的能力,譬如提供负载均衡、服务注册、可观察性等方面的支持。这一类框架的代表有 Facebook 的 Thrift 与阿里的 Dubbo,尤其是断更多年后重启的 Dubbo 表现得更为明显。
Dubbo 默认有自己的传输协议(Dubbo协议),同时也支持其他协议;默认采用 Hessian 2 作为序列化器,如果你有 JSON 的需求,可以替换为 Fastjson,如果你对性能有更高的追求,可以替换为 Kryo、FST、Protocol Buffers 等效率更好的序列化器,如果你不想依赖其他组件库,也可以直接使用 JDK 自带的序列化器。这种设计在一定程度上缓和了 RPC 框架必须取舍、难以完美的缺憾。
关于 Dubbo 的相关内容,我将在其他文章中进行介绍,至此,全文结束。