RPC相关问题

2020/06/10 面试 共 9053 字,约 26 分钟
梦境迷离

什么是 RPC ?

  • RPC (Remote Procedure Call)即远程过程调用,是分布式系统常见的一种通信方法。它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。
  • 除 RPC 之外,常见的多系统数据交互方案还有分布式消息队列、HTTP 请求调用、数据库和分布式缓存等。
  • 其中 RPC 和 HTTP 调用是没有经过中间件的,它们是端到端系统的直接数据交互。与Akka有区别,Akka是点对点,没有严格的客户端和服务端的区别,本质都是Actor对消息进行消费,而对RPC而言,一般都有明显的B/S风格(客户端/服务端)。

简单的说

  • RPC就是从一台机器(客户端)上通过参数传递的方式调用另一台机器(服务器)上的一个函数或方法(可以统称为服务)并得到返回的结果。
  • RPC会隐藏底层的通讯细节(不需要直接处理Socket通讯或Http通讯)。
  • 客户端发起请求,服务器返回响应(类似于Http的工作方式)RPC在使用形式上像调用本地函数(或方法)一样去调用远程的函数(或方法)。

为什么我们要用 RPC ?

RPC 的主要目标是让构建分布式应用更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用。这里的透明机制与Actor模型提供的位置透明性有区别。Actor的位置透明性说明

RPC 需要解决的三个问题

RPC要达到的目标:远程调用时,要能够像本地调用一样方便,让调用者感知不到远程调用的逻辑。

  1. Call ID映射。我们怎么告诉远程机器我们要调用哪个函数呢?在本地调用中,函数体是直接通过函数指针(引用)来指定的,我们调用具体函数,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,是无法调用函数指针的,因为两个进程的地址空间是完全不一样。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个(函数 <–> Call ID)映射表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。
  2. 序列化和反序列化。客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
  3. 网络传输。远程调用往往是基于网络的,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。

实现高可用 RPC 框架需要考虑什么问题 ?

要实现一个RPC不算难,难的是实现一个高性能高可靠的RPC框架

  1. 既然系统采用分布式架构,那一个服务势必会有多个实例,要解决如何获取实例的问题。所以需要一个服务注册中心,比如在Dubbo中,就可以使用Zookeeper(我们使用Consul)作为注册中心,在调用时,从Zookeeper获取服务的实例列表,再从中选择一个进行调用;
  2. 如何选择实例呢?就要考虑负载均衡,例如dubbo提供了4种负载均衡策略;
  3. 如果每次都去注册中心查询列表,效率很低,那么就要加缓存;
  4. 客户端总不能每次调用完都等着服务端返回数据,所以就要支持异步调用;
  5. 服务端的接口修改了,老的接口还有人在用,这就需要版本控制;
  6. 服务端总不能每次接到请求都马上启动一个线程去处理,于是就需要线程池;
  7. 可能还需要有会话和状态保持的功能。

工作中使用什么 RPC ?

gRPC - 是Google开发的高性能、通用的开源RPC框架,其由Google主要面向移动应用开发并基于HTTP/2协议标准而设计,基于ProtoBuf(Protocol Buffers)序列化协议开发,且支持众多开发语言。 本身它不是分布式的,所以要实现上面的框架的功能需要进一步的开发。

RPC 组件有哪些 ?

  1. 客户端(Client) - 服务调用方(服务消费者)
  2. 客户端存根(Client Stub) - 存放服务端地址信息,将客户端的请求参数数据信息打包成网络消息,再通过网络传输发送给服务端
  3. 服务端存根(Server Stub) - 接收客户端发送过来的请求消息并进行解包,然后再调用本地服务进行处理
  4. 服务端(Server) - 服务的真正提供者

具体调用过程

  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步完好地封装起来,也就是把调用、编码/解码的过程给封装起来,让用户感觉上像调用本地服务一样的调用远程服务。

RPC 使用了哪些关键技术 ?

  • 动态代理

生成Client Stub(客户端存根)和Server Stub(服务端存根)的时候需要用到Java动态代理技术,可以使用JDK提供的原生的动态代理机制,也可以使用开源的:CGLib代理,Javassist字节码生成技术。

  • 序列化和反序列化

在网络中,所有的数据都将会被转化为字节进行传送,所以为了能够使参数对象在网络中进行传输,需要对这些参数进行序列化和反序列化操作。

序列化 - 把对象转换为字节序列的过程称为对象的序列化,也就是编码的过程。

反序列化 - 把字节序列恢复为对象的过程称为对象的反序列化,也就是解码的过程。

目前比较高效的开源序列化框架:如Kryo、FastJson和Protobuf等。

  • NIO通信

出于并发性能的考虑,传统的阻塞式IO显然不太合适,因此我们需要异步的IO,即NIO。Java提供了NIO的解决方案,Java 7也提供了更优秀的NIO.2支持。可以选择Netty或者MINA来解决NIO数据传输的问题。

  • 服务注册中心

可选:Redis、Zookeeper、Consul 、Etcd。一般使用ZooKeeper提供服务注册与发现功能,解决单点故障以及分布式部署的问题(注册中心)。

  • 观察者设计模式

SaaS 和私有部署平台的 gRPC 应用有哪些区别 ?

  • 公有云SaaS平台由提供服务的公司统一运维,环境状态或变化相对可控,网络拓扑相对稳定,请求量高,需要使用高性能高可用的架构,重要服务都需要使用集群提供多个节点多个实例,由此平台本身必定会成为分布式应用,进而需要引入服务注册和发现机制(Consul),避免单个服务或节点上的故障影响SaaS的所有用户。

  • 私有部署平台是部署在客户的环境中的,每个客户环境的软件与硬件均可能不同,引入服务发现会加大部署难度。因为需要部署注册中心,而在私有部署环境,多引入一个中间件就意味着多一分不可靠。 同时网络拓扑会变的复杂(如:可能用户的拓扑本来就非常复杂,甚至根本不支持某些中间件)。所以在私有部署时应尽量减少微服务的个数(如:多个服务打包到一个进程以减少进程数量)以降低运维成本(如:客户可能技术薄弱)。 对于小客户使用单机版即可,而数据量足够大的用户应该推荐使用基于nginx upstream的负载均衡,gRpc使用直连的方式(经过负载均衡后得到多个可用的地址的其中一个,直接点对点调用),使用可配置的方式提供服务生产者的机器地址,即有一定的拓展性支持用户新增机器或节点,又不需引入服务注册发现。 所以一般能不用中间件就不用,比如Redis、MQ。比起SaaS平台,私有部署平台的使用者作为单个使用者,一般也用不着使用集群来支撑,而且对于私有平台而言,数据量对于自己来说是可预见可预估的,偶尔做全链路压力测试,修改直连的服务节点地址,即能满足需要。

gRPC 的实际应用是怎样的 ?

取代HTTP,内部微服务间的通信均采用gRPC。避免业务线为restful的URI定义发愁,出现URI冲突,降低数据传输量,提高性能,且能提供类型安全的接口。

下面代码是同时有Scala和Java

观察者模式

在了解gRPC前,我们需要知道观察者模式,一个例子如下:

// 观察者
public abstract class Observer {

    protected Subject subject;

    //收到通知时的动作
    public abstract void update();
}

// 具体的观察者
class ConcreteObserver extends Observer {

    public ConcreteObserver(Subject subject) {
        this.subject = subject;
        this.subject.addObserver(this);
    }

    @Override
    public void update() {
        System.out.println("ConcreteObserver println hello");
    }
}

// 具体的观察者2
class Concrete2Observer extends Observer {

    public Concrete2Observer(Subject subject) {
        this.subject = subject;
        this.subject.addObserver(this);
    }

    @Override
    public void update() {
        System.out.println("Concrete2Observer println world");
    }
}

// 被观察者
// 被观察者(subject)必须实现一些职责,包括能够动态地增加、取消观察者
abstract class Subject {
    // 定义一个观察者数组(顺序 重复性?)
    private Vector<Observer> obsVector = new Vector<Observer>();

    // 增加一个观察者
    public void addObserver(Observer o) {
        this.obsVector.add(o);
    }

    // 删除一个观察者
    public void delObserver(Observer o) {
        this.obsVector.remove(o);
    }

    // 通知所有观察者
    public void notifyObservers() {
        for (Observer o : this.obsVector) {
            o.update();
        }
    }
}

// 具体的被观察者
class ConcreteSubject extends Subject {
}

// 使用
public class ObserverPatternSpec {

    public static void main(String[] args) {
        Subject subject = new ConcreteSubject();
        //这里是通过Observer持有subject实例来对subject进行添加观察者,耦合比较高,但是简单
        new ConcreteObserver(subject);
        new Concrete2Observer(subject);

        //实际应该是subject的状态变更时再通知观察者们,这里省略了状态变更步骤
        subject.notifyObservers();
    }
}

gRPC 中的观察者模式

gRPC的观察者是一个StreamObserver接口,负责从可观察的消息流接收通知(如果用流返回集合)。客户端存根(stub)和服务实现都使用它来发送或接收流消息。

gRPC的StreamObserver只提供三个方法,服务端接收请求时,需要使用这些方法来完成响应。

public interface StreamObserver<V>  {
    //从流中接收值,可以被调用多次(返回集合时需要调用多次),但是当onError或onCompleted被调用后,该方法不可能再被调用。(ClientCalls.java)
    void onNext(V value);
    //从流中接收终止错误,只能被调用一次,如果被调用,它必须是最后一个调用的方法。
    void onError(Throwable t);
    //接收成功完成流的通知。
    void onCompleted();
}

gRPC实现不需要是线程安全的,但应该是线程兼容的(https://www.ibm.com/developerworks/library/j-jtp09263/)。单独的StreamObserver不需要同步在一起;传入和传出方向是独立(指不同请求各自的StreamObserver不需同步)。因为单个StreamObserver不是线程安全的,如果多个线程将同时写入同一个StreamObserver,则应用程序必须同步调用。

对于传出消息(server),gRPC库向应用程序提供StreamObserver

如服务端方法实现

proto文件定义

service ItemModelService {
  rpc Get (GetItemModelRequest) returns (stream ItemModelDto) {
  }
}

proto接口实现

  //gRPC自动生成stub供客户端使用,它提供了一种调用方和服务之间类型安全的绑定关系,它的方法与proto中定义的完全相同,只不过是Java语言表示的。
  //可见gRPC提供了responseObserver供我们写入数据
  override def get(request: GetItemModelRequest, responseObserver: StreamObserver[ItemModelDto]): Unit = {//业务代码}
  //使用StreamObserver后一定要调用onCompleted

对于传入消息(client),应用程序实现StreamObserver并将其传递给gRPC库以进行接收

可以将自动生成的服务端实现的存根(stub)看做观察者模式中的subject,不过和一般观察者模式还是有很大区别的,主要是可拓展性上。 把stub看出subject对象,下面的调用就成了,subject.get(request, observer),只不过在上面一般观察者的示例中,没有接受外部信息,并且是observer持有subject引用以方便新增observer。 而在gRPC中,一般subject对应一个observer,可以看成是B/S架构的,且需要调用的目标服务是确切的,可以将observer作为参数更加方便。

//StreamObservers.unary方法会实例化SimpleObserver
val observer = StreamObservers.unary[ItemModelDto]

//call方法使用存根进行调用,并写入数据到observer,最后使用observer得到数据
//stubManager是一个负责管理gRPC存根的管理器,通过依赖注入获得(非开源代码,不贴了)
//获取stub的逻辑:启动时根据端口和地址创建channel,使用channel获取stub
//call是一个高阶函数,该函数内利用stub来执行call{}函数体中的内容
    stubManager.call { stub: ItemModelServiceStub 
      stub.get(request.build(), observer)
      observer.future.map { result 
        observer.future
      }
    }

上面的SimpleObserver是一个自定义实现,由于在Scala中基本都使用异步的,所以一般对gRPC会进行一次封装,比较简单使用promise即可:

//下面这个为返回单个实体的实现
//若返回多个实体需要使用集合保存每个值
class SimpleObserver[T] extends StreamObserver[T] {
  private[this] var reply: T = _
  private[this] val promise = Promise[T]

  override def onNext(value: T): Unit = reply = value

  override def onError(t: Throwable): Unit = promise.failure(t)

  override def onCompleted(): Unit = promise.success(reply)

  def future: Future[T] = promise.future

}

gRPC生命周期

现在让我们具体看一下当一个gRPC客户端调用了一个gRPC服务器的方法后都发生了什么。以下并非只针对Java实现的gRPC。

一元 RPC

首先来看一个最简单的RPC类型,客户端发送一个请求然后接受一个响应。

  • 一旦客户端调用了存根/客户端对象上的方法,服务器会被通知RPC已经被调用了,同样会接收到调用时客户端的元数据、调用的方法名称以及制定的截止时间(如果适用的话)。
  • 然后,服务器可以立即发送自己的初始元数据(必须在发送任何响应之前发送),也可以等待客户端的请求消息-哪个先发生应用程序指定的。
  • 服务器收到客户的请求消息后,它将完成创建和填充其响应所需的必要工作。然后将响应(如果成功)连同状态详细信息(状态代码和可选状态消息)以及可选尾随元数据一起返回。
  • 如果状态是OK,客户端将获得响应,从而在客户端完成并终结整个调用过程。

服务器流式 RPC

一个服务器流式RPC与简单的一元RPC类似,不同的是服务器在接收到客户端的请求消息后会发回一个响应流。 在发送回所有的响应后,服务器的状态详情(状态码和可选的状态信息)和可选的尾随元数据会被发回以完成服务端的工作。客户端在接收到所有的服务器响应后即完成操作。

返回集合时使用,用的比较多。

客户端流式 RPC

客户端流式RPC也类似于一元PRC,不同之处在于客户端向服务器发送请求流而不是单个请求。服务器通常在收到客户端的所有请求后(但不一定)发送单个响应,以及其状态详细信息和可选的尾随元数据。

用的比较少。

双向流式 RPC

在双向流式RPC中,调用再次由客户端调用方法发起,服务器接收客户端元数据,方法名称和期限。同样,服务器可以选择发回其初始元数据,或等待客户端开始发送请求。

接下来发生的情况取决于应用程序,因为客户端和服务器可以按任何顺序进行读取和写入-流操作完全是独立地运行。 因此,例如,服务器可以等到收到所有客户端的消息后再写响应,或者服务器和客户端可以玩“乒乓”:服务器收到请求,然后发回响应,然后客户端发送基于响应的另一个请求,依此类推。

这种目前用的也比较少。

截止时间/超时时间

gRPC允许客户端指定在RPC被DEADLINE_EXCEEDED错误终结前愿意等待多长时间来让RPC完成工作。在服务器端,服务器可以查看一个特定的RPC是否超时或者还有多长时间剩余来完成RPC。

如何指定期限或超时的方式因语言而异-例如,并非所有语言都有默认期限,某些语言API按照期限(固定的时间点)工作,而某些语言API根据超时来工作(持续时间)。

gRPC AbstractStub的withDeadline方法可以返回带有绝对截止日期的新存根。

RPC终止

在gRPC中,客户端和服务端对调用是否成功做出独立的基于本地的决定,而且两端的结论有可能不匹配。 这意味着,比如说,你可能会有一个在服务端成功完成(“我已经发送完所有响应了”)但是在客户端失败(“响应是在我指定的deadline之后到达的”)的RPC。服务器也有可能在客户端发送所有请求之前决定RPC完成了。

取消RPC

客户端或服务器都可以随时取消RPC。取消操作将立即终止RPC,因此不再进行任何工作。这不是“撤消”:取消之前所做的更改不会回滚。

元数据

元数据是以键值对列表形式提供的关于特定RPC调用的信息(比如说身份验证详情),其中键是字符串,值通常来说是字符串(但是也可以是二进制数据)。元数据对gRPC本身是不透明的-它允许客户端向服务器提供与调用相关的信息,反之亦然。

对元数据的访问取决于语言。

通道

一个gRPC通道提供了一个到指定主机和端口号的gRPC服务器的连接,它在创建客户端存根(或者对某些语言来说就是“客户端”)时被使用。客户端可以指定通道参数来更改gRPC的默认行为,比如说打开/关闭消息压缩。每个通道都有状态,状态包括connected和idle(闲置)

gRPC怎么处理关掉的通道是语言相关的,有些语言还允许查询通道的状态(Java实现就可用使用channel.isShutdown或channel.isTerminated判断通道可用)。

仅供参考。

文档信息

Search

    Table of Contents