开发者社区 > 博文 > 深入浅出RPC服务(一)RPC来源-论文解读
分享
  • 打开微信扫码分享

  • 点击前往QQ分享

  • 点击前往微博分享

  • 点击复制链接

深入浅出RPC服务(一)RPC来源-论文解读

  • 冰雪****
  • 2023-07-12
  • IP归属:北京
  • 9760浏览

    导读:✍️

    本系列文章从RPC产生的历史背景开始讲解,涉及RPC核心原理、RPC实现、JSF的实现等,通过图文类比的方式剖析它的内部世界,让大家对RPC的设计思想有一个宏观的认识。
    作者:王禹展
    部门:京东健康-技术产品部-供应链研发部-2B平台研发组

    一、RPC历史背景

    问题思考

    回到上世纪RPC第一次被提及的时候,我们思考一个问题:机器A如何调用机器B上指定的程序?

    1. 首先需要知道机器B的IP地址,但知道了IP地址后,如何知道是那个进程?
    2. 既然要调用肯定会涉及数据的传输,那么数据如何传输,传输协议怎么设计?
    3. 万一服务故障或者通信故障了,怎么处理?
    4. 如何保证通信的安全?

    当时的现状

    当时的背景下使用已有工具构建分布式系统很困难,即使是丰富系统经验的研究人员也觉得不太容易,只能让通信方面的专家来承担相应的工作,这导致很多人不愿意干这件事。通信机制成为制约分布式计算进一步发展的主要因素。

    当时的目标

    主要目标:简化分布式计算,能够通过几乎和本地调用程序一样简单轻松的通信机制,消除构造分布式系统的一切不必要的困难。

    次要目标:通信高效、通信安全。


    二、RPC来源-论文解读

    论文地址: http://birrell.org/andrew/papers/ImplementingRPC.pdf 
    注:论文是上世纪80年代提出的,当时TCP/IP协议还未成熟

    RPC设计初衷

    RPC定义

    RPC(Remote Procedure Call)远程过程调用是一种通过网络,从远程计算机程序上请求服务,而不需要了解底层网络的设计。

    RPC 这个概念术语由BRUCE JAY NELSON(尼尔森)  1984 年通过论文提出,目的是为了解决分布式计算的问题,使得分布式计算变得容易。

    能够通过几乎和本地调用程序一样简单轻松的通信机制,消除构造分布式系统的一切不必要的困难,人们会更容易去构建并实现分布式应用。

    设计初衷


    RPC五大模块及交互关系

    五大模块

    1. user(客户端)
    2. user-stub(客户端存根)
    3. RPCRuntime(RPC通信包)
    4. server-stub(服务端存根)
    5. server(服务端)

    交互流程

    如图Fig.1所示,user、user-stub、RPCRuntime的一个实例运行在用户端机器中。server、server-stub、另一个RPCRuntime实例运行在服务端机器中。

           用户端:当用户希望进行远程调用时,实际上是调用的本地user-stub中相应的代码。user-stub负责将调用的规范和参数打包成一个或多个包,通过RPCRuntime(RPC通信包)传输到被调用机器。

          服务端:服务端接收到这些数据包后,对应的RPCRuntime(RPC通信包)将它们传递给server-stub。然后server-stub将它们解包,并调用对应的本地实现。同时用户端的调用进程挂起,等待服务端返回结果包。当服务端调用完成时,返回到server-stub,并通过服务端的RPCRuntime将结果传回用户端对应的RPCRuntime(RPC通信包)挂起的进程中。然后通过user-stub解包,最后将它们返回给用户。

    如果把用户端和服务端代码放在一台机器上,直接绑定在一起,不使用user-stub和server-stub,程序仍然可以工作。RPCRuntime(RPC通信包)是Cedar系统的一个标准部分,因此不用程序员编写通信相关代码,但是user-stub和server-stub是由一个叫做Lupine的程序自动生成的,也不需要程序员编写对应包处理层面的代码。

                    注意:当今的开源框架如dubbo等RPCRuntime和stub都是框架本身提供的。


    实例或接口绑定

    绑定

    绑定这个概念类似于今天的注册中心,比如:zookeeper、console、nacos、etcd等

    绑定要解决的问题:

    • 绑定机制的客户端如何指定他想要绑定到那台机器?
    • 调用者如何确定被调用者的机器地址,并向被调用者指定要调用的过程?
    • 第一个是如何明确的定义被调用的机器,第二个是明确定义后,如何确定被调用的机器地址、以及该地址上对应的进程

    对接口的命名

    RPC包提供的绑定操作是将接口的导入器绑定到接口的导出器。绑定之后,导入器调用远程导出器对应接口的实现。

    接口的名称由两部分组成:类型和实例。

    定义类型地目的是在抽象级别上,指定调用方希望提供方实现哪个接口。实例用于指定接口对应的实现。

    接口名称的语义不是由RPC包指定的——它们是接口引用者和接口提供者之间的协议。RPC包决定的是服务提供者使用接口名来定位具体的方法。

    查询对应的导出接口

    Grapevine分布式数据库来进行RPC绑定(类似当今的注册中心),Grapevine数据库是分布式的,每一个库有至少3个副本,本身高度可靠。

    实现绑定的方案探索:

          方案一:直接在发布的接口包中写死对应的服务提供者的网络地址,缺点:不太灵活,硬编码。

          方案二:使用某种广播协议定位服务提供者,缺点:会对其它没有此接口的服务造成干扰,因为要处理广播,并且不在同一个局域网的服务无法被通知到。

          方案三:使用Grapevine分布式数据库来完成绑定,服务消费者和服务提供者都和数据库交互,就解决了上述方案一、方案二的问题。

    Grapevine数据库如何存储服务提供者的数据呢?类似下面这种方式:

    • 组类型结构:列表<接口,接口的实例>
    • 个体类型接口:列表<接口的实例,实例所在机器的网络地址>

    简化调用流程

    1. serve端:发布注册服务,将接口信息保存到Grapevine分布式数据库,存储形式为列表<接口,接口的实例>和列表<接口的实例,实例所在机器的网络地址>
    2. user端:传入接口信息,从数据库查询对应接口所在的服务器地址
    3. user端:向请求到的服务器地址发起绑定
    4. server端:查询自己是否有对应的接口实例,如果有绑定成功,否则失败。
    5. user端:发起调用
    6. server端:处理调用并返回结果
    7. user端:收到返回结果

    详细调用流程

    1. 当服务端想要暴露接口给客户端的时候,服务端调用RPCRuntime模块中的Exportinterface并传入接口名称,Exportinterface将接口、实例、机器地址保存到Grapevine数据库中,如果数据库中已经存在了相同的接口信息,则不用更新数据库。另外,服务端的RPCRuntime对导出的接口会生成唯一的”UID标识符“,这个“UID标识符”与“程序调度器”(指向指定的处理器,来自server-stub)、“接口信息”一起被保存在服务端的本地表中(注意:不是Grapevine数据库),
    2. 当客户端希望绑定接口的时候,会调用user-stub, user-stub调用Importinterface并传入接口信息,客户端的RPCRuntime去Grapevine查询要绑定接口的网络地址,拿到网络地址后向服务端发起绑定请求。如果服务端没有对应接口和实例,则绑定失败。如果服务端有对应接口,则返回UID标识符、表索引index等新给客户端,至此绑定成功。
    3. 当客户端的user-stub向服务端发起请求的时候,会带上服务端返回的“UID标识符”、“表索引index”,服务端收到请求后使用“表索引index”去本地表里查询接口相关数据,查询的时候使用“UID标识符”做验证,验证通过后将请求包传递给“程序调度器“(具体处理请求的代码)。

    数据包层面的传输协议设计

    必要设计

    目标:减少调用耗时

    • 方案一:使用当时的Grapevine协议。缺点:性能不太高
    • 方案二:设一个专门用于RPC的传输协议。优点:相对于Grapevine协议,性能能增长10倍以上

    如果传输的数据量特别大,传输耗时很长,使用建立和关闭连接(短链接)可以接受。因为建立和关闭连接的开销远小于传输的开销。相反,对RPC来说连接的客户端特别多,频繁的建立和销毁连接,就不太适合了(建议使用长连接)。

    如果调用过程出现异常,服务端应该立刻终止,并将异常信息返回给调用方。该异常有可能是网络问题,也有可能是服务器崩溃,用户并不知道是哪一种异常。如果服务端代码死循环了,服务端会一直运行,并且不会报告异常,这与调用本地死循环是同样的道理。


    调用时将调用标识符、参数、要调用的接口信息传递给服务提供端。服务端解析收到的数据,调用对应的方法,将结果返回给调用者。

    如果调用端没有收到服务端的确认消息,则调用端重传数据包。服务端和客户端都可能是发送端(服务端在发送结果的时候,角色就是发送端。客户端在请求服务的时候,角色同样是发送端)。

    调用标识符有两个用途:

    1. 对调用端来说可以区分不同的请求
    2. 对被调用端来说,可以用它过滤重复的请求

    一次调用的活动定义:调用机器标识符 + 进程相对标识符 + 序列号。

    序列号只能是单向的,比如递增,但是不要求连续。(类似数学上的单调非连续函数)因为活动和进程相关,被调用的机RPCRuntime维护一个表,表里给出每个活动最后一次的调用序列号。在服务端接受到调用时,查询对应的序列号,看看对应的序列号是否大于表中的序列号,如果小于可以丢弃此请求,说明发生了重复调用,可以丢弃。因为相同的调用每次都会增加序列号的值。(笔者这里理解:‘机器标识符+进程相对标识符’  标识一个请求,后面的‘序列号’标识同一个请求发送了多少次)

    复杂调用过程

    数据包的发送方负责重新发送未被接收端确认的数据。如果调用时间非常长,调用者就会定期发送探测包,被调用方对其进行确认。好处是在服务端奔溃或者通信故障时,调用方能得到通知。发送的频率随着时间的增加,发送频率越来越低,直到每5分钟发送一次探测包。如果探测包一直没有收到响应,调用方就会认为通信存在问题,可以做一些对应的处理。如果发送方发送的数据包太大,会被拆成多个数据包发送,除了最后一个数据包之外,前面的数据包都需要被调用方明确的确认。调用方和被调用方都只是用一个包缓冲区,为了消除重复的包,每一个被发送包都有对应的序列号。

    1. user发起的一个调用由于数据太大被拆成了2个包。假设拆分后的数据包分别为A包和B包,发送A包的时候server端接收到了,并给user返回一个收到A包的确认消息。
    2. 然后user发送B包,server接收到B包,但是某些原因server没有向user发送B包的确认消息。
    3. 由于server端2个包都接受到了,然后将A、B两个包组合还原,解析出参数后执行处理。
    4. 因为server没有向user发送B包的确认消息,此时user端觉得是不是server没有收到,于是重传B个包,此时server收到了重复B包。
    5. server端内部先查询了一下,发现之前已经收到这个包了,于是直接向user端返回一个B包的确认消息(之前这个确认消息没有发送),此时server端已开启的处理流程也在继续。
    6. user端收到B包的确认消息后,进入等待状态(同时也会在等待的过程出发送探测包,以确保两者之间的通信网络正常)。等服务端处理完成后,向user端发送结果包。
    7. user端收到结果包后没有向server返回收到结果的确认消息。于是server端重传结果包,user端返回收到结果包的确认消息,到此调用结束。

    当异常引发时,动态扫描调用堆栈以确定是否有异常。如果有则捕获异常,并发送一个异常包来代替正常的结果包。这个异常包就像正常的结果包一样会被RPCRuntime处理。

    安全

    “RPC包”和“协议包”括提供基于加密的工具安全调用。 这些工具使用Grapevine作为身份验证服务(或密钥分发中心),并使用联邦数据加密标准。 调用者被调用方的身份得到保证,反之亦然。 提供完整的对调用和结果进行端到端加密。 加密技术提供防止窃听和隐藏数据模式,并检测修改、重放或创建调用的尝试。因此,程序员不需要构建详细的通信相关代码。 界面设计完成后,只需要编写用户代码和服务器代码。  Lupine负责生成打包和解打包参数和结果的代码,以及为服务器存根中的调用分派到正确的处理流程。 RPCRuntime负责包级通信。


    参考文献: http://birrell.org/andrew/papers/ImplementingRPC.pdf