分布式软件架构——远程服务调用

序言

“架构师”可以是做企业战略设计的架构师,也可以说做业务流程分析的架构师。
架构师视角特指软件系统中技术模型的系统设计者。

在做架构设计的时候,架构师应该思考哪些问题、可以选择哪些主流的解决方案和行业标准做法,以及这些主流方案有什么优缺点、会给架构设计带来什么影响,等等。

RPC(Remote Procedure Call)远程过程调用

其实,RPC这个词儿在计算机科学中已经有超过40年的历史了。RPC的概念与技术早在1981年由Nelson提出。1984年,Birrell和Nelson把其用于支持异构型分布式系统间的通讯。Birrell的RPC 模型引入存根进程( stub) 作为远程的本地代理,调用RPC运行时库来传输网络中的调用。Stub和RPC runtime屏蔽了网络调用所涉及的许多细节,特别是,参数的编码/译码及网络通讯是由stub和RPC runtime完成的,因此这一模式被各类RPC所采用。由于分布式系统的异构性及分布式计算模式与计算任务的多样性,RPC作为网络通讯与委托计算的实现机制,在方法、协议、语义、实现上不断发展,种类繁多,其中SUN公司和开放软件基金会在其分布式产品中所建立和实用的RPC较为典型。现在有Google gRPC、Facebook Thrift等各个厂家的RPC技术。

RPC解决了两个问题

  • 1、分布式系统中,服务之间的调用问题;
  • 2、远程调用时,要能够像本地调用一样方便,让调用者感知不到远程调用的逻辑。

进程间通讯

尽管今天的大多数RPC技术已经不再追求“与本地方法调用一致”这个目标了,但不可否认的是,RPC出现的最初目的,就是为了让计算机能够跟调用本地方法一样,去调用远程方法。

public static void main(String[] args){
	System.out.println("hello world");
}

以上面一段java代码来理解下面几个概念:

  • 调用者(Caller): main()
  • 被调用者(Callee):println()
  • 调用点(Call Site):发生方法调用的指令流位置
  • 调用参数(Parameter):由Caller传递给Callee的数据,即“hello world”
  • 返回值(Retval):由Callee传递给Caller的数据,如果方法正常完成,返回值是void,否则是对应的异常

在完全不考虑编译器优化的前提条件下,程序运行至println()这一行的时候,计算机(物理机或者虚拟机)会做以下这些事情:

  1. 传递方法参数:将字符串hello world的引用压栈。
  2. 确定方法版本:根据println()方法的签名,确定它的执行版本其实并不是一个简单的过程,不管是编译时的静态解析也好,还是运行时的动态分派也好,程序都必须根据某些语言规范中明确定义的原则,找到明确的被调用者Callee。
  3. 执行被调方法:从栈中获得Parameter,以此为输入,执行Callee内部的逻辑。
  4. 返回执行结果:将Callee的执行结果压栈,并将指令流恢复到Call Site处,继续向下执行。

假如当前println()方法不在当前进程的内存地址空间中,会面临两个直接障碍:

  • 1、前面第一步和第四步所做的传递参数、传回结果都依赖于栈内存的帮助,如果Caller与Callee分属不同的进程,就不会拥有相同的栈内存,那么在Caller进程的内存中将参数压栈,对于Callee进程的执行毫无意义。
  • 2、第二步的方法版本选择依赖于语言规则的定义,而如果Caller与Callee不是同一种语言实现的程序,方法版本选择就将是一项模糊的不可知行为。

假设Caller与Callee是使用同一种语言实现的,那两个进程之间如何交换数据的问题即叫做进程间通讯(Inter-Process Communication,IPC),解决的办法有下面几种:

  1. 管道(Pipe)或具名管道(Named Pipe)
    普通管道可用于有亲缘关系进程间的通信(由一个进程启动的另一个进程);
    具名管道除了具有普通管道的功能外,它还允许无亲缘关系进程间通信
    管道典型的应用就是命令行“|”操作符,比如命令ps -ef |grep java,就是管道操作符“|”将ps命令的标准输出通过管道,连接到grep命令的标准输入上。
  2. 信号(Signal)
    信号是用来通知目标进程有某种事件发生的。除了用于进程间通信外,信号还可以被进程发送给进程自身。信号的典型应用是kill命令,比如kill -9 pid,意思就是由Shell进程向指定PID的进程发送SIGKILL信号。
  3. 信号量(Semaphore)
    信号量是用于两个进程之间同步协作的手段,相当于操作系统提供的一个特殊变量。我们可以在信号量上,进程wait()和notify()操作。
  4. 消息队列(Message Queue)
    前面三种方式只适合传递少量信息,而POSIX(Portable Operating System Interface for Computing Systems)标准中,由定义“消息队列”用于进程间通讯的方法。它克服了信号承载信息量少、管道只能用于无格式字节流,以及缓冲区大小受限等缺点,但实时性相对受限
  5. 共享内存(Shared Memory)
    允许多个进程可以访问同一块内存空间,这是效率最高的进程间通信形式。进程的内存地址空间是独立隔离的,但操作系统提供了让进程主动创建、映射、分离、控制某一块内存的接口。由于内存是多进程共享的,所以往往会与其他通信机制,如信号量等结合使用,来达到进程间的同步和互斥。
  6. 本地套接字接口(IPC Socket)
    消息队列和共享内存只适合单机多进程间的通信。而套接字接口,是更为普适的进程间通信机制,可用于不同机器之间的进程通信。
    基于效率考虑,当仅限于本机进程间通讯的时候,套接字不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只是简单地将应用层数据从一个进程拷贝到另一个进程。该通讯方式的专有名称:Unix Domain Socket,也叫IPC Socket。

通信的成本

基于套接字接口的通讯方式(IPC Socket)一旦被滥用就会显著降低分布式系统的性能。1987年,当“透明的RPC调用”一度成为主流范式的时候,Andrew Tanenbaum 教授曾发表了论文《A Critique of The Remote Procedure Call Paradigm》,对这种透明的 RPC 范式提出了一系列质问。

  • 两个进程通信,谁作为服务端,谁作为客户端?
  • 怎样进行异常处理?异常该如何让调用者获知?
  • 服务端出现多线程竞争之后怎么办?
  • 如何提高网络利用的效率,譬如连接是否可被多个请求复用以减少开销?是否支持多播?
  • 参数、返回值如何表示?应该有怎样的字节序?
  • 如何保证网络的可靠性?譬如调用期间某个链接忽然断开了怎么办?
  • 发送的请求服务端收不到回复该怎么办?
  • ……

论文的中心观点是:本地调用与远程调用当做一样处理,这是犯了方向性的错误,将系统间的调用做成透明会增加程序员的工作复杂度。虽然有人支持透明通信,有人反对,但历史证明 Andrew Tanenbaum 的预言是正确的。最终,由 ACM 和 Sun Microsystems 的一些大佬们共同总结了通过网络进行分布式运算的八宗罪(Eight Fallacies of Distributed Computing):

  • The network is reliable —— 网络是可靠的。
  • Latency is zero —— 延迟是不存在的。
  • Bandwidth is infinite —— 带宽是无限的。
  • The network is secure —— 网络是安全的。
  • Topology doesn’t change —— 拓扑结构是一成不变的。
  • There is one administrator —— 总会有一个管理员。
  • Transport cost is zero —— 不必考虑传输成本。
  • The network is homogeneous —— 网络是同质化的。

以上这八条反话被认为是程序员在网路编程中经常被忽略的八大问题,对于实现透明的远程服务调用至关重要。RPC应该被视为高级或语言级特性,而不是像IPC这样的低级或系统级特性,而不是像IPC这样的低级特性。

远程服务调用的概念是RPC的核心,这个概念最早由施乐PARC在1980年代定义,并在第一个商业RPC应用程序Courier中实现,该应用程序使用基于Cedar语言的Lupine RPC框架构建。

Remote procedure call is the synchronous language-level transfer of control between programs in address spaces whose primary communication is a narrow channel.
—— Bruce Jay Nelson,Remote Procedure Call,Xerox PARC,1981

分布式软件架构——远程服务调用_第1张图片
RPC是一种语言级别的通讯协议,它允许运行于一台计算机上的程序以某种管道作为通讯媒介(即某种传输协议的网络),去调用另外一个地址空间(通常为网络中的另外一台计算机)。

RPC框架要解决的三个基本问题

在20世纪80年代中后期,惠普和Apollo提出了网络计算架构(Network Computing Architecture,NCA)的设想,并在DCE项目中将其发展成为在UNIX系统下的远程服务调用框架DCE/RPC。这是历史上第一次对分布式有组织的探索尝试。由于DCE本身是基于UNIX操作系统的,因此DCE/RPC通常也仅适用于UNIX系统程序之间的使用。在1988年,Sun Microsystems起草并向互联网工程任务组(Internet Engineering Task Force,IETF)提交了RFC 1050规范,设计了一套面向广域网或混合网络环境的、基于TCP/IP的、支持C语言的RPC协议,后被称为ONC RPC(Open Network Computing RPC,也被称为Sun RPC)。这两套RPC协议是各种RPC协议和框架的鼻祖,从它们开始,直至接下来这几十年来所有流行过的RPC协议,都不外乎变着花样使用各种手段来解决以下三个基本问题:

如何表示数据

这里数据包括传递给方法的参数和方法执行后的返回值。在进程内的方法调用中,使用程序语言预置的和程序员自定义的数据类型,可以很容易地解决数据表示问题。但是,在远程方法调用中,交互双方可能使用不同的程序语言,甚至在同一种程序语言的不同硬件指令集和操作系统下,同样的数据类型也可能有不同的表现细节,例如数据宽度和字节序的差异。因此,有效的做法是将交互双方所涉及的数据转换为某种事先约定好的中立数据流格式来进行传输,然后将数据流转换回不同语言中对应的数据类型来进行使用。这个过程被称为序列化和反序列化,是RPC中的重要概念。每种RPC协议都应该有对应的序列化协议。譬如:

  • ONC RPC 的External Data Representation (XDR)
  • CORBA 的Common Data Representation(CDR)
  • Java RMI 的Java Object Serialization Stream Protocol
  • gRPC 的Protocol Buffers
  • Web Service 的XML Serialization
  • 众多轻量级 RPC 支持的JSON Serialization
  • … …

如何传递数据

准确的说,是指如何通过网络在两个服务的Endpoint之间相互操作和交换数据,在这里,“交换数据”通常指的是应用层协议,实际传输一般是基于标准的TCP、UDP等标准的传输层协议来完成的。两个服务之间的交互不仅仅是扔个序列化数据流来表示参数和结果,还包括许多其他信息,例如异常、超时、安全、认证、授权、事务等,这些信息都可能导致双方需要交换信息。在计算机科学中,有一个专门的术语“Wire Protocol”用于表示这种两个Endpoint之间交换这类数据的行为。常见的 Wire Protocol 有:

  • Java RMI 的Java Remote Message Protocol(JRMP,也支持RMI-IIOP)
  • CORBA 的Internet Inter ORB Protocol(IIOP,是 GIOP 协议在 IP 协议上的实现版本)
  • DDS 的Real Time Publish Subscribe Protocol(RTPS)
  • Web Service 的Simple Object Access Protocol(SOAP)
  • 如果要求足够简单,双方都是 HTTP Endpoint,直接使用 HTTP 协议也是可以的(如 JSON-RPC)
  • … …

如何确定方法

本地方法调用和远程方法调用中如何表示和找到方法。在本地方法调用中,编译器或解释器会根据语言规范将方法签名转换为进程空间中子过程入口位置的指针,这并不是太大的问题。但是,**在远程方法调用中,不同语言的方法签名可能有所差别,因此需要一个跨语言的统一标准来表示和找到方法。**这个标准可以是给每个方法规定一个唯一的、在任何机器上都绝不重复的编号,调用时直接传递这个编号即可找到对应的方法。这种方法听起来粗鲁而寒碜,但实际上是DCE/RPC当初准备的解决方案之一。虽然DCE最终还是弄出了一套语言无关的接口描述语言(Interface Description Language,IDL),成为此后许多RPC参考或依赖的基础(如CORBA的OMG IDL),但那个唯一的绝不重复的编码方案UUID(Universally Unique Identifier)却也被保留且广为流传开来,今天已广泛应用于程序开发的方方面面。类似地,用于表示方法的协议还有:

  • Android 的Android Interface Definition Language(AIDL)
  • CORBA 的OMG Interface Definition Language(OMG IDL)
  • Web Service 的Web Service Description Language(WSDL)
  • JSON-RPC 的JSON Web Service Protocol(JSON-WSP)
  • … …

以上 RPC 中的三个基本问题,全部都可以在本地方法调用过程中找到相对应的操作。RPC 的想法始于本地方法调用,尽管早已不再追求实现成与本地方法调用完全一致,但其设计思路仍然带有本地方法调用的深刻烙印,抓住两者间的联系来类比,对我们更深刻地理解 RPC 的本质会很有好处。

统一的RPC

DCE/RPC和ONC RPC都是面向C语言设计的RPC协议,它们只支持传递值而不支持传递对象。虽然ONC RPC的XDR序列化器可以用于序列化结构体,但结构体毕竟也不是对象。然而,在上世纪90年代,面向对象编程(OOP)风头正盛,因此在1991年,对象管理组织(OMG)发布了CORBA 1.0(Common Object Request Broker Architecture),这是一种跨进程的、面向异构语言的、支持面向对象的服务调用协议。CORBA的1.0和1.1版本只支持C和C++语言,但到了末代的CORBA 3.0版本,不仅支持了C、C++、Java、Object Pascal、Python、Ruby等多种主流编程语言,还支持了Lisp、Smalltalk、Ada、COBOL等已经半截入土的非主流语言。CORBA是一套由国际标准组织牵头,由多家软件提供商共同参与的分布式规范,当时影响力只有微软私有的DCOM能够与之稍微抗衡,但微软的DCOM与DCE一样,是受限于操作系统的,所以同时支持跨系统、跨语言的CORBA原本是最有机会统一RPC这个领域的有力竞争者。

但无奈CORBA本身设计太过复杂,同时为CORBA制定规范的专家逐渐脱离实际,做出CORBA规范晦涩难懂,各家语言厂商都有自己的解读,结果各门语言最终出来的CORBA实现互不兼容,与CORBA号称支持众多异构语言的描述大相径庭。导致CORBA最终的归宿与DCOM一同被扫进计算机历史的博物馆中

1999 年末,SOAP 1.0 规范的发布,它代表着一种被称为“Web Service”的全新的 RPC 协议的诞生。Web Service 采用了 XML 作为远程过程调用的序列化、接口描述、服务发现等所有编码的载体,这使得 Web Service 具有了很好的互操作性和可扩展性。Web Service 是由微软和 DevelopMentor 公司共同起草的远程服务协议,随后提交给 W3C 投票成为国际标准,所以 Web Service 也被称为 W3C Web Service。

当时 XML 是计算机工业最新的银弹,只要是定义为 XML 的东西几乎就都被认为是好的,风头一时无两,连微软自己都主动宣布放弃 DCOM,迅速转投 Web Service 的怀抱。这表明,采用流行的技术和标准,得到广泛认可和支持,是非常重要的。Web Service 的成功也表明,设计简单、易用、兼容性好的协议和规范非常重要。

交给 W3C 管理后,Web Service 再没有天生属于哪家公司的烙印,商业运作非常成功,大量的厂商都想分一杯羹。但从技术角度来看,它设计得也并不优秀,甚至同样可以说是有显著缺陷的。

对于开发者而言,Web Service 的一大缺点是它那过于严格的数据和接口定义所带来的性能问题。尽管 Web Service 吸取了 CORBA 失败的教训,不需要程序员手工去编写对象的描述和服务代理,但是,XML 作为一门描述性语言本身信息密度就相对低下,这使得一个简单的字段,为了在不同语言中不会产生歧义,要以 XML 严谨描述的话,往往需要比原本存储这个字段值多出十几倍、几十倍乃至上百倍的空间。这个特点一方面导致了使用 Web Service 必须要专门的客户端去调用和解析 SOAP 内容,也需要专门的服务去部署(如 Java 中的 Apache Axis/CXF),更关键的是导致了每一次数据交互都包含大量的冗余信息,性能奇差。

总之,Web Service 的商业成功表明了采用流行的技术和标准,得到广泛认可和支持,是非常重要的。但是,Web Service 的技术缺陷也表明了设计简单、易用、兼容性好的协议和规范非常重要。

分裂的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 功能、特点不尽相同,有的是某种语言私有,有的能支持跨越多门语言,有的运行在应用层 HTTP 协议之上,有的能直接运行于传输层 TCP/UDP 协议之上,但肯定不存在哪一款是“最完美的 RPC”。

今时今日,任何一款具有生命力的 RPC 框架,都不再去追求大而全的“完美”,而是有自己的针对性特点作为主要的发展方向。例如,gRPC 采用了 Protocol Buffers 作为序列化协议,具有高效、跨语言等特点;Dubbo 则注重服务治理和扩展性;Thrift 则支持多种语言和多种传输协议等等。这些框架都在不断地发展和完善,以满足不同领域和场景的需求。

主要的几个发展方向是:

  • 朝着面向对象发展:分布式对象(Distributed Object),代表有RMI、.NET Remoting、CORBA、DCOM;
  • 朝着性能发展:决定RPC性能的主要两个因素是序列化效率和信息密度。代表有gRPC和Thrift,它们都有自己优秀的专有序列化器,而在传输协议方面,gRPC是基于HTTP/2的,支持多路复用和Header压缩,Thrift则直接基于传输层的TCP协议来实现,省去了额外的应用层协议的开销;
  • 朝着简化发展,代表为JSON-RPC,它牺牲了功能和效率,换来的是协议的简单。

总之,RPC 协议的发展历程充满曲折和变化,但现在已经有了许多优秀的框架和协议可供选择。在选择 RPC 框架时,需要根据具体的需求和场景来进行评估和选择,而不是盲目地追求“完美”。

你可能感兴趣的:(分布式,java,rpc)