RPC设计和使用中的一些杂谈

  RPC当前在大公司的开发中越来越流行了,各大厂的开源RPC框架也呈百花开放的状态。在通过前面的文章做了一个对Google出品之gRPC的使用手册,也算是在使用方面有了一个基本的了解。恰巧最近逛别人博客的时候,看到几篇关于RPC原理和设计方面的文章,觉得十分不错,于是这里就将这些文章进行整理,对RPC设计、使用和实现中的那些乱七八糟的问题进行一个归纳总结。

一、RPC的原理和关键点

1.1 RPC的原理

rpc
  RPC的原理可以说是再简单不过了,一张图足以说明问题,总体来说会经历以下的步骤:
  (1). client代码像普通函数调用一样调用client stub函数,这个调用是本地调用,调用参数会和平常函数调用一样进行返回地址和调用参数压栈操作;
  (2). client stub会把调用参数和其它信息(比如调用方法名、调用属性等,可以称为metadata)进行打包封装成message,然后通过系统调用发送该message,这个打包的过程是个序列化的过程;
  (3). client寻求到服务端的地址,然后通过本地操作系统经由某种协议,将上述message发送给server端;
  (4). server端的操作系统接收message后将其传递给server stub;
  (5). server stub将message解包,得到原始传递过来的各项调用参数,解包的过程是反序列化的过程;
  (6). server stub调用server端的本地函数,然后将得到的结果按照上述类似的步骤反向传递给client作为结果返回。当然整个过程也可能不那么顺利,那么也应该产生合适的状态码、异常信息作为返回。
  所以RPC实际是通过client stub和server stub起到一个代理的作用,将client的请求转发到server端去操作,并将操作得到的结果回传,因为传递是跨进程、跨主机的,所以必须进行序列化和反序列化的过程来保证消息的正确性。

1.2 RPC设计中的要点

  相比于当前流行的HTTP/JSON开发,RPC等同于在实现相同功能的前提下将服务和传输等细节封装好,实现一种业务代码对特定服务开箱即用的效果。针对上面的RPC工作的流程看来,下面的几点是RPC设计实现中的几点要素:
  (1). 调用接口设计
  调用接口是客户端和服务端的一种约定,接触到的RPC框架大多都是服务于C/C++的,这类静态语言需要在编译期间确定调用接口,而且现在许多主流的编程语言都没有内置stub生成的支持,所以通常的解决方式都是用一种IDL(Interface Definition Language)的方式来描述调用接口,然后通过对应的功能辅助生成stub代码,这一点无论是上个世纪的Sun RPC(rpcgen)还是当下的gRPC(protobuf)、Thrift(thrift)都是这么干的。同时,想做到客户端和服务端跨语言的支持,也可以通过IDL然后生成特定语言种类的辅助代码来屏蔽差别性。
idl
  (2). 序列化和网络传输
  序列化的技术当前十分成熟了(json、msgpack、protobuf、xml……),而且大多序列化库能够保证序列化和反序列化后能够生成特定于语言、字节序、架构的数据正确表示,现在序列化库所最求的极致,除了立志于多平台多语言支持外,就是序列化和反序列过程的速度和数据压缩效率,从这一点说Google的protobuf有着很明显的优势,看看其流行程度就知道了。
  还值得一提的是,序列化还可以对数据结构进行序列化,比如C的结构体、指针等信息,感兴趣的可以参看这个小库C serialization library!但是在RPC中要实现调用参数Call-by-ref一定要谨慎,通常不要这么干。
  RPC的好处就是对底层传输协议没有任何细节要求,承载client-server主机的通信协议可以选用诸如UDP、TCP、HTTP、MessageQueue甚至自设计协议,当然这也跟业务需求密切相关的,通用的RPC库通常会提供多个传输协议可供自由选择使用。
  (3). RPC服务发布
  rpcbind:最初的Sun RPC就是这么干的,RPC服务提供者启动后将自己绑定到随机端口上,然后向rpcbind注册告知自己提供的服务和侦听的端口,而当客户端发起RPC调用的时候会首先联系rpcbind服务(周知111端口)以寻求提供服务的主机端口,然后再向这个地址发起真正的RPC请求。不过观当前流行的RPC框架,都是服务端直接绑定到某个约定的端口上面~
  名字服务:这个就厉害了,前面的文章介绍了zookeeper可以做很多事情,其中RPC服务可以方便的采用zookeeper进行发布:RPC的名字可以定义为ZNode,然后在其子节点上创建提供服务的主机信息,就可以实现诸如负载均衡、地址变更、主备冗余切换等功能。
  (4). 错误和异常处理
  RPC过程涉及到的过程和细节众多,客户端、服务端、网络传输都有可能出问题,为此gRPC为可能的情况都定义了对应的出错码,但是除了OK之外感觉其他提示都是不可靠,针对这种问题,会有一种调用语义的概念。
  调用语义:诸如RPC这种跨网络的服务,要达到像本地函数调用那种exactly once semantic是理想化而不可能实现的,通常只能实现at least once和at most once两种调用语义,这之中的权衡一方面需要考虑具体业务的特点和需求,再则需要查看RPC所执行的任务是否是幂等(idempotent)的。

二、RPC和MessageQueue的联系区别

  这两种方式都是企业用来进行业务解耦的重要手段,比如在经历多个项目后,发现有些服务或者功能可以独立出来给大家分享,这种情况就派上了用场了。RPC和MessageQueue虽然功能类似,但是使用手法还是有所区别的:
  同步和异步:RPC模拟远程函数作为一个本地函数调用,所以函数调用上特别适合于Request/Response同步交互方式的使用场景,业务开发使用起来也更为的简单和直观;MessageQueue会将发送的消息进行排队处理,所以天然支持异步的工作模式,反而在需要同步返回结果的情况下MessageAQueue会比较麻烦。
  同步方式使用的最大痛点就是并发量无法做的很高(针对那些使用线程处理请求的模式,协程另说),而这一点MessageQueue可以将请求放入队列中,起到了错峰流控的作用。虽然gRPC、Thrift等开源RPC框架也提供异步使用方式,但是又额外多出来了CompleteQueue、CallBack之类的东西,每个调用还需要使用requestID/tag之类的东西进行标识,增加了RPC框架使用的复杂度,有违RPC简化业务实现的初衷,尤其是那种原本本地调用的业务代码向RPC迁移的时候,异步化过程中大动干戈的修改代码是比较忌讳的。
  大家对这样的使用如此的一致,以至于默认就认为RPC是同步类型的调用,消息队列是异步类型的调用了。
  固化:MessageQueue的一个好处就是可以将收到的消息立即固化到磁盘上,然后再进行消息的处理和应答工作,所以即使这个过程中有意外情况发生,消息也不会丢失,从某中程度上提供了一种更可靠的通信,此外这种固化还起到生产者和消费者长时间间隔完全解耦、接收端不可用的情况下针对发送端的可用性、备份等作用。这点对RPC的方式来说是很难实现的,RPC一般不会暂存请求。
  消费模式:RPC只是Client和Server之间的一对一的关系,而MessageQueue通常采用Sender-Broker-Receiver的方式构成,这三者的联系可以出演化出各种各样的消费模式,尤其对于类似于“一对多”的使用场景只有MessageQueue才能支持。
  静态语言的接口限制:对于C/C++这类强制性静态语言来说,调用接口需要在编译期间确定,基于Protobuf实现的RPC需要修改proto接口定义文件然后重新生成特定语言的辅助代码,对于客户端和服务端这种频繁升级都将是灾难性的。通过MessageQueue的方式就没有这么多的问题,MessageQueue的主体是消息,而Protobuf就是用于定义消息格式的,Google对Protobuf消息的向后兼容性是着重考虑的,所以软件的平滑升级是可行的。
  不过也有大侠指出,一般开发环境都不会这么裸露地直接使用RPC开发业务的,通常会在业务层和RPC层中间做个隔离层,让变化性强的代码不侵入到业务当中,减少RPC频繁变动对业务代码的影响。
  易用性:上面比较起来RPC对比于MessageQueue性能低、资源占用多、灵活性和扩展性不太方便,似乎是一无是处。我感觉RPC的最大优势就是简单易用(所以常常这点会作为RPC框架评价的重要指标),程序开发充斥着无数函数调用,能把远程服务作为像函数调用一样使用真是让程序员再开心不过了。还有,比如RPC->RPC->RPC这样的嵌套调用场景,其中任何一个步骤发生了问题,调用过程都可以失败方式返回,业务上的错误处理和回滚等操作也直接明了一些吧,但是如果在异步模式下的开发,除非记录相关状态,否则这种回溯是很麻烦的!

  当然,本文还是恪守于传统的RPC,所以只需要定位到函数名就可以了;对于支持面向对象的编程来说,还有RMI(Remote Method Invoke)远程方法调用,使用过程中还要找到特定的对象然后再调用该对象上的方法,通常可以通过url、名字服务等方式定位远程对象,不知道现在用的多不多,就没深究下去了。
  RPC的使用的话,PhxSQL项目里面有个PhxRPC,而Raft协议实现中,各个节点的消息传递也是采用的RPC的方式进行的。

本文完!

参考