• RPC协议


    什么是 RPC?

    初步印象

      RPC的语义是远程过程调用,在一般的印象中,就是将一个服务调用封装在一个本地方法中,让调用者像使用本地方法一样调用服务。而具体的实现是通过调用方和服务方各自的stub基于TCP长连接进行数据交互达成。

      上面的解释似云里雾里,仅仅了解到这种程度是远远不够的,还需要更进一步,以相对底层抽象的视角来理解RPC。

    三个特点

      广义上来讲,所有本应用程序外的调用都可以归类为RPC,不管是分布式服务,第三方服务的HTTP接口,还是读写Redis的一次请求。从抽象的角度来讲,它们都一样是RPC,由于不在本地执行,都有三个特点:

      •   需要事先约定调用的语义(接口语法)
      •   需要网络传输
      •   需要约定网络传输中的内容格式

      以一次Redis调用为例,执行redis.set("rpc", 1)这个调用,其中:

      •   set及其参数("rpc", 1),就是对调用语义的约定,由redis的API给出
      •   RedisServer会监听一个服务端口,通过TCP传输内容,用异步事件驱动实现高并发
      •   底层库会约定数据如何进行编解码,如何标识命令和参数,如何表示结果,如何表示数据的结尾等等

      这三个特点都是因为调用不在本地而不得不衍生出来的问题,也因此决定了RPC的形态。所有的RPC解决方案都是在解决这三个问题,不断地在提出更加优良的解决方案,试图达到更好的性能,更低的使用成本。 本文也将围绕这三个特点来展开内容。

      常规的RPC一般都是基于一个大的内部服务,进行分布式拆分,由于其语义上以本地方法的作为入口,那么天然的就更倾向于具备高性能、支持复杂参数和返回值、跨语言等特性。下图是RPC调用的过程示意图:

    协议约定

      Stub会负责封装命令和参数,并以特定的数据格式进行打包。其中命令、参数和返回值的需要客户端和服务端的Stub事先进行协商,双方都需要维护一份完全一样的方法及参数列表。

      更进一步需要知道对方如何进行压缩打包,如何压缩结构体,如何压缩Class等等,并严格按照标准进行解压缩,中途有任何一丝的差错都会的导致调用失败。

      所以一般情况下可能会对数据进行一定的校验,同时要协商方法、参数等错误时如何返回。 这是一个比较繁杂的过程,混合了调用语法内容解压缩两部分内容,可被理解为协议约定问题。

    网络传输

      搞定了协议约定问题后,接下来就是要通过Runtime进行内容传输了,这又是一大难题,一般是需要通过Socket编程来实现,一般使用TCP或UDP来进行传输。

      如果是UDP可以用数据报来区分每一次请求和回复;但如果是字节流的TCP,就需要用特殊的方式来标示请求或回复的末尾,用来区分不同的请求。

      同时当对调用性能有要求时,可能会使用Socket的异步编程模型,消除等待中的消耗,这会引入事件机制,通过状态机来解析处理或回复请求。当出现超时、丢包等情况时还进行做重试、重传、报错等等。

      拆解到协议约定和网络传输时,就会发现实现RPC调用是一件非常复杂的事情,自己实现千难万难,接下来就了解一番已有的,针对协议约定和网络传输的解决方案。

    ONC RPC

      ONC RPC是相对早期的RPC解决方案,通过外部数据表示法来约定数据的压缩方式:

      被传输的所有内容都需要按上面的约定进行压缩,这样接收方就能顺利地按照同样的协议进行解压缩。虽然解决了压缩问题,但无法识别哪些是函数、哪些是类、哪些是参数等,因此需要自定义一份文件,规定什么样的数据是函数、类等。然后客户端和服务器按按此规则划分参数等

      对于命令和参数列表的约定,会创建一份公共的协议文件,里面会定义被调用的方法名,参数列表,对象的列表等等。然后用特定工具将文件进行解析生成Stub程序,客户端和服务端都同时将Stub程序放在代码中。比如对方法名进行编号,将GetUserName(userId)这个方法编号为1,在调用时就将1传输给服务端,服务端通过协议文件就知道调用的是方法,这样节省了大量的空间。

     

      传输则通过对应的类库实现,通过Socket编程实现的非常复杂的解决方案,包含了超时、失败、异常处理、状态转换等等功能。

      这种早期的方案,在每一次代码更新时都需要重新生成Stub程序,调用方和服务方都需要及时更新对应的文件。给某一个方法增加一个默认参数,都需要全部使用者同步升级,从迭代或多版本的场景看来,这是一场噩梦。

    RESTfull HTTP JSON

      RESTfull是一种资源状态转换的架构风格,也可以用来实现RPC, 互联网对HTTP超广泛的支持,使得这相当简单,也是大多数情况下的首选。

      通过HTTP协议来进行内容传输,Header用来约定编码、body大小等,彼此以 来分割,Header和body之间通过两个连续的 来间隔,能很容易地区分不同的请求。

      通过Url和对应参数来标示要调用的方法和参数。在body中用JSON对内容进行编码,极易跨语言,不需要约定特定的复杂编码格式和Stub文件。在版本兼容性上非常友好,扩展也很容易。

      众多的优点使得这种方案广受欢迎。不过也有其无法避开的弱点:

      •   HTTP的header和Json的数据冗余和低压缩率使得传输性能差
      •   JSON难以表达复杂的参数类型,如结构体等

    gRPC HTTP2.0 Protobuf

      gRPC是一款RPC框架,在性能和版本兼容上做了提升和让步:

      •   Protobuf进行数据编码,提高数据压缩率
      •   使用HTTP2.0弥补了HTTP1.1的不足
      •   同样在调用方和服务方使用协议约定文件,提供参数可选,为版本兼容留下缓冲空间

      protobuf是一款用C++开发的跨语言、二进制编码的数据序列化协议,以超高的压缩率著称。它和早期的RPC方案一样,需要双方维护一个协议约束文件,以.proto结尾,使用proto命令对文件进行解析,会生成对应的Stub程序,客户端和服务端都需要保存这份Stub程序用来进行编解码。对于这种协议文件导致的升级困难问题,protobuf 3 中定义的字段默认都是可选的(可以不传),在接口升级时,部分客户端不需要升级自己的Stub程序。

    // ***.proto文件
    syntax = "proto3";
    package id_rpc;
    message BusinessType {// 定义参数
      string name = 1; //参数字段
    }
    
    message UniqueId {// 定义返回值
      uint64 id = 1;
      string business_type = 2;
    }
    
    service UniqueIdService {// 定义服务,可以调用 MakeUniqueId 方法
      rpc MakeUniqueId(BusinessType) returns (UniqueId){}
    }

      对于JSON等文本形式的序列化协议来说,protobuf能有几十倍空间和性能提升, 比如传输123,文本类的需要3个字节(ascii 31 32 33)来传输,而二进制类只需要一个字节(01111011)就可以表示。

      同时protobuf会维护.proto文件,这样在解析文件生成Stub程序时,可以对方法名等进行编号,传输时只传编号,而不用传方法的名字,这又可以节省大量字节,还有其他更多的精巧压缩方法,比如TLV,详情可以参考proto encoding

      解决了数据体积的问题后,gRPC使用HTTP2来改善传输性能。 HTTP2是在HTTP1.1的基础上做了大量的改进,HTTP1.1虽然引入了KeepAlive复用TCP连接,但仍然有很多问题:

      •   使用KeepAlive的请求是串行执行(非pipeline时),pipeline时有队首阻塞问题
      •   每次都需要发送不必要的Header
      •   不能双向通信

      简单补充一下pipeline,HTTP1.1中允许多个请求复用连接,同时可以一口气将请求全部发出去,不用一个返回后再发送第二个,提升并发性。而服务端需要将请求的结果,按照pipeline中发送的顺序进行顺序返回,如果靠前的请求阻塞了,那么靠后请求返回就会被动等待。

      HTTP2解决了这些问题,引入了新的机制:

      •   在两端建立Header索引表,每次只发送索引,减小header体积
      •   建立虚拟通道,将数据拆分成多个流,每个流有自己的ID和优先级,并且流可以双向传输,每个流可以进一步拆成多个帧。可以将多个请求切成不同的流发送,每个流可以独立返回,避开1.1的串行或队首阻塞问题。

      同时,基于HTTP2的数据流机制,gRPC客户端和服务端可以实现批量操作优化,客户端可以攒一些请求,一口气发给服务端,服务端也可以批量返回结果,借此实现流式rpc。

    RabbitMQ

    r  pc作为一种极常见的服务形态,以异步和解耦著称的mq也自然不会放过这个场景,rabbitmq就为rpc调用提供了很好的支持。

      一般和rabbitmq的交互场景是发布或消费消息,是一个单向的过程,而rpc却是一种同步的双向交互过程,在使用上有些差异。要理解rabbitmq如何实现rpc,还是可以从上面三个抽象的特点出发,万变不离其宗。

    如何协商调用语义

      mq中的消息是从exchange分发到queue中,消费端在特定的queue中获取消息,rpc的请求依然要走这条路径: 方法调用->exchange->queue->方法执行

      创建一个direct类型的exchange,让每个rpc方法对应一个queue,这个exchange通过routing_key分发到对应的queue中, 让特定的消费者来实际执行rpc方法。这样rpc方法的语义就通过queue来约定,而方法的参数,可以放入消息中。

    如何将结果传递回客户端

      方法调用->exchange->queue->方法执行, 这条路是单行道,方法执行端执行完rpc方法后不能按照原路将结果返回给客户端。要实现结果回传,就得再开辟一条结果回传端->exchange->queue->结果等待端路径,一条用来发送rpc请求,另一条用来回传rpc结果,方法调用者和方法执行者都会扮演生产者和消费者。

      rabbitmq中有回调队列(Callback queue)来实现调用结果回传,同时有关联ID(Correlation Id)来唯一标识每一份调用结果

      rpc调用方在发送请求时,会在数据中带上回调队列信息(routing_key)和关联ID,rpc执行方在执行完方法后,就将关联ID掺入执行结果中,并将结果通过exchange发往回调队列(通过routing_key)。 rpc调用方在发送请求后,紧接着在设置的回调队列中等结果就行。 整个过程(两条路径)共用同一个exchange。

      调用参数和调用结果的打包可以用JSON,protobuf等等,协商一致即可。完整示例代码

      使用mq实现rpc,有其独有的优势,rpc执行端可以轻松地横向扩展,rpc调用方也不用考虑负载均衡,沿袭了mq解耦的优点。不过对于调用超时,执行端崩溃等等情况得做额外处理。调用方在等待结果时需要设置超时间,高性能的rpc调用还需要调用方能异步高效地通过关联ID将请求结果储存起来,等待调用者获取。Spring框架的实现方案就是用一个HashMap将结果保存起来,等待调用者以关联ID作为key来取结果。

    服务发现

      上面介绍了常见的rpc实现方案,而RPC作为分布式服务的一种形态,本身还有服务发现和负载均衡的问题需要解决(mq除外)。服务注册和发现作为分布式系统的基础设施之一,有很多的解决方案,例如: zookeeper、ETCD、envoy等等。服务启动后向公开的注册中心进行服务注册,服务调用方在调用前,向注册中心查询对应服务的地址,这又是一项工程,不过一般的请求规模也不需要这套东西,更高端的方案,得期待ServiceMesh的进一步发展了。

    总结

      RPC从抽象的角度来看,具有需要约定调用语法需要约定编码格式需要网络传输这三大特点,进一步可以归纳为协议约定问题网络传输问题,本文的主要内容都围绕这两大问题,并介绍常见的解决方案,借此对建立RPC更深的理解。

  • 相关阅读:
    oracle 导入数据时提示只有 DBA 才能导入由其他 DBA 导出的文件
    oracle 常用语句
    android udp 无法收到数据 (模拟器中)
    android DatagramSocket send 发送数据出错
    AtCoder ABC 128E Roadwork
    AtCoder ABC 128D equeue
    AtCoder ABC 127F Absolute Minima
    AtCoder ABC 127E Cell Distance
    CodeForces 1166E The LCMs Must be Large
    CodeForces 1166D Cute Sequences
  • 原文地址:https://www.cnblogs.com/AbnerLc/p/11947078.html
Copyright © 2020-2023  润新知