前言

  之前用c++造了一个简单的RPC轮子,并且以此写了一篇博客,但是回顾那篇博客发现在讲解代码的时候讲的有点乱,主要是因为RPC框架涉及到的东西和知识点比较多,所以这里专门写一篇博客为之前的那篇文章进行知识铺垫。
  这篇文章会讨论RPC框架涉及的一些基础概念,同时还会对这些基础概念进行延申和拓展,讨论实际应用RPC框架中会遇到的各种问题。

1. 为什么要用PRC?

 RPC 最大的特点就是可以让我们像调用本地函数一样调用远程函数,现在的大多数应用系统发展到一定规模之后,都会向“微服务化”演进,演进后的大型应用系统也的确是由一个个“微服务”组成的,RPC可以说是微服务的基础,不过RPC也不仅仅能用于微服务。
 RPC是解决分布式系统通信问题的重要技术,在搭建一个复杂的分布式系统过程中,如果开发人员想要调用某个远端函数,在编写这个函数逻辑的时候还要编写网络通信有关的一系列逻辑,这样非常不利于开发。所以就需要RPC框架对调用远端函数和接收远端函数的处理结果这整个过程进行封装,让开发人员在调用远端函数时能和调用本地函数一样,具备相同的语义性。

2. RPC远端调用全局概览

2.1 RPC远端调用流程

请添加图片描述

  • 本地发起远端调用(上图中的步骤1)

     本端发起远端调用需要向RPC框架传入请求的服务对象名(可以理解为类对象)、函数方法名(可以理解为类中的成员函数)、函数参数这三样东西。因为我们网络传输使用的是TCP协议,而TCP是无边界的字节流形式,所以我们还需要自己处理拆包粘包问题,即给数据消息自定义传输格式!

  • 序列化和反序列化(上图中的步骤2、4、6、8)

     序列化技术我用的是protobuf,为什么使用protobuf,后面再谈。简单来说,protobuf,能把一个类似于结构体的消息序列化为二进制数据。

      message RpcHeader
      {
      	bytes service_name = 1;
      	bytes method_name = 2;
      	uint32 args_size = 3;
      }
    

     就比如我上面的这个RpcHeader message,咱就当它是一个结构体消息(把bytes当成string,message关键字当成struct来理解)。咱可以利用protobuf提供的函数SerializeToString把这个结构体消息序列化为二进制数据(以字符串形式存储)。当我们对这一段二进制数据调用ParseFromString,又能完好的取出这个结构体消息的每一个字段。(不需要担心三个字段序列化成二进制数据后,每个字段的分界问题,这个protobuf都会给你处理好。)

    我讲一下我项目中是怎么定义数据消息的传输格式的
    在这里插入图片描述
      首先我们有一个protobuf类型的结构体消息RpcHeader,这个RpcHeader有三个字段,分别是服务对象名,服务函数名和函数参数长度。通过SerializeToString能把这个RpcHeader序列化为二进制数据(字符串形式存储)。同时我们的函数参数是可变的,长度不确定的,所以不能和RpcHeader一起封装,否则有多少个函数就会有多少个RpcHeader。我们为每一个不同的函数参数列表定义不同的protobuf结构体消息。然后对这个protobuf结构体消息进行序列化(字符串形式存储),现在我们得到了两个序列化后的二进制字符串,拼接起来就是我们要发送的消息send_str了。
     但是还有个问题,我们怎么知道send_str中哪部分属于RpcHeader,哪部分属于函数参数?所以我们需要再send_str前面加一个固定的4字节uint32整数来表示序列化后的RpcHeader数据的长度。这样我们就能切分开RpcHeader和函数参数的二进制数据了。然后我们对属于RpcHeader的二进制数据反序列化后又能取出函数参数长度字段,进而得知函数参数的长度!

2.2 问题思考

2.2.1 序列化协议选型上有什么需要考虑的?
  • 常见序列化协议对比
     据我个人的了解(我是菜鸡硕士生,别全信我啊!),适合序列化的协议就是Json、Hessian、protobuf。
     Json协议是典型的Key-Value明文协议,用起来相当方便,但是用Json序列化后的空间开销比较大,性能不行。所以Json pass掉。
     Hessian是动态类型、二进制、紧凑的、可跨语言移植的一种序列化框架,序列化后的二进制数据比JSON紧凑高效多了。而且Hessian的兼容性和通用性也比较强,所以Hessian也很适合采用。(个人没用过Hessian,都是网上看的段子。)
     Protobuf序列化体积比Json和Hessian小很多,序列化和反序列化速度也很快,消息格式升级和兼容性也不错。不过这不意味着Protobuf就一定全方位比Hessian强。还是要看具体场景的,对于具有反射和动态能力的语言来说,protobuf用起来就比较费劲。(关于这一点说法,我个人没有研究过,不是很懂Java)

  • 序列化协议选型问题:
     其实序列化协议还挺多的,比如kryo和Message pack,那么在选择序列化协议中需要考虑什么?

    • 序列化和反序列化的性能效率和空间开销
      这一点的确是一个重要因素,序列化与反序列化过程是 RPC 调用的一个必经过程,那么序列化与反序列化的性能和效率就会直接影响到 RPC 框架整体的性能和效率以及网络传输的效率。
    • 序列化协议的通用性和兼容性
       其实这一点我目前还没有涉及过,只是从业内专业人士那里了解来的。比如在业务上,服务提供方将入参加入一个属性之后,服务调用方不能正常调用了,升级RPC版本后发起调用时直接报序列化异常了。
       在序列化协议选择上,序列化协议的通用性和兼容性的优先级应该高于序列化和反序列化的性能效率和空间开销。当然了,你的序列化和反序列化性能效率和空间开销不能太拉跨。序列化协议在版本升级后的兼容性是否良好,是否跨平台和跨语言,是否支持更多的对象类型等,这些是优先考虑的。
    • 序列化协议的安全性
       除了通用性和兼容性,还有就是协议安全性。这个因素的优先级是大于等于通用兼容性的。如果存在漏洞,那危险性可想而知了。
    • 总结:
       从以上三个角度出发,Hessian和Protobuf基本满足上述要求。Hessian在使用上更加方便,对象兼容性好;Protobuf则更加高效、通用性上也更有优势。而二者在安全性上都做的比较好。(为什么比较好,我暂时还不得知,这里继续挖坑,回头填!)
2.2.2 使用序列化需要注意哪些问题?

 一句话总结,远程调用函数的函数入参和返回值对象要尽量简单!细分下去的话,主要考虑下面几点因素。

  • 当函数入参和返回值对象体积比较大时,比如入参对象你给它传了个List或大Map,序列化后字节长度超长。对于网络来说也是个负担,对于CPU来说,序列化这么大一个对象也是很辛苦的事情,超时也很正常。
  • 函数入参和返回值对象有复杂的关系,定义入参和返回值对象的时候不要搞复杂的嵌套、包含、聚合关系,这个对象包含那个对象,那个对象聚合这个对象。对象复杂,CPU也辛苦。
  • 函数入参和返回值对象继承关系复杂,性能开销也大。因为要不停寻找寻找父类,找到父类还要遍历字段。其实最好不要有父子类的情况。
2.2.3 提供RPC服务的服务端应该采用哪种网络IO模型

 在分布式环境下,RPC调用其实是一个高并发场景,就是一大堆客户端会和服务端建立TCP连接,发送服务调用请求。所以服务端的网络模型应该具备高并发能力。
 有哪几种常见的网络IO就不赘述了,最常用的其实就是阻塞IO和IO多路复用了,因为大多Linux内核版本只支持这两种。像异步IO、信号驱动IO,只有高版本Linux内核才支持(实际应用中,Linux版本不是你想升就升的,万一出问题了咋整?)。
 很显然,只剩下IO多路复用模型了,可以用比较少的线程处理多个连接请求。我在个人开发项目中用的Muduo库提供的epoll Multiple Reactor模型搭建的。

2.2.4 用户空间的消息拷贝优化

 之前在2.1节中讲的序列化和反序列化,自定义数据格式问题。这里面就涉及到把两个序列化后的二进制字符串拼接/分割。这个工作其实是在用户空间中完成的,怎么样实现“零拷贝”,这里的零拷贝不是内核拷贝到用户空间的那个零拷贝,就是用户空间内的数据零拷贝。
 这个问题其实我个人还没有深入的研究,暂时搁置一下。挖个坑,回头填!!!

2.2.5 RPC应该用自定义传输协议还是HTTP2协议?

 google的gRPC就是对HTTP2协议,HTTP2的对编码进行各方面优化,效率也高。HTTP和RPC不是一个并行概念,RPC是远端过程调用,其中包含了传输协议和序列化协议,传输协议可以是gRPC所采用的HTTP2,或者是dubbo这种自定义报文的tcp协议。HTTP协议是支持连接池复用的,就是建立一定数量的连接不断开,不会频繁创建和销毁连接。**成熟的RPC库相对HTTP容器,更多的是封装了“服务发现”,“负载均衡”,“熔断降级”这一类面向服务的高级特性,可以说,如果你对一个HTTP2上封装这些功能,那它就可以成为一个RPC框架了。**如果单纯使用HTTP则缺少了这些调用。所以HTTP2协议可以作为RPC的部分模块来使用。
 个人觉得,到底是选择自定义传输协议和HTTP2协议,还是根据业务场景灵活站位吧,由于我水平有限,也没法继续深入下去了。反正不能仅仅使用HTTP协议来实现远端过程调用!!!!

2.2.6 动态代理:为使用者屏蔽RPC处理流程

 其实当我搜动态代理这四个字的时候,出来的都是JAVA相关的东西,我自己又不懂JAVA,不过我自己用C++造了一个简单的RPC轮子,所以也能根据自己的认识来谈一下这个问题!!!
 我个人理解的c++中的动态代理是通过设计模式中的代理模式+多态实现的!(如果观点有误,请一定帮我指出来,谢谢大兄弟们了!!!)
 首先,RPC服务端如果要对外提供可调用方法LoginResonse UserServiceRpc::Login(LoginRequest),该方法接收入参对象LoginRequest并返回对象LoginResponse,我们需要在.proto文件中注册这些信息,如下所示(希望你会protobuf的基本使用),为啥要把对外提供的函数和消息体对象注册在.proto文件,因为我们需要protobuf提供的序列化和反序列化功能,所以提前注册在.proto文件中,然后利用protoc编译这个.proto文件,会得到一组.cc和.h文件,这一组文件就是给客户端和服务端使用的,可以和c++程序进行对接,利用protobuf提供的方法来封装你要传输的消息结构体(三言两句,难以道明,还不懂自己先去学一下protobuf的使用吧):

message LoginRequest{
    bytes name = 1;
    bytes pwd = 2;
}
message LoginResponse{
    ResultCode result = 1;
    bool sucess = 2;
}
message ResultCode{
    int32 errcode = 1; 
    bytes errmsg = 2;
}
service UserServiceRpc
{
    rpc Login(LoginRequest) returns(LoginResponse);
    rpc Register(RegisterRequest) returns(RegisterResponse);
}

 刚才说了,生成的一组.cc和.h文件,这一组文件中有一个抽象虚基类google::protobuf::Service,这个虚基类的表明了我这个类家族要提供什么功能?
 接着这一组文件中又提供了两个类,这两个类都继承了google::protobuf::Service类。一个叫UserServiceRpc类,是给RPC服务端使用的,一个是UserServiceRpc_Stub类,是给客户端使用的。我接下来讲的其实是代理模式的思想,最好提前了解一下代理模式。
 UserServiceRpc类里面实现了RPC服务端接受远程调用的处理逻辑。注意仅仅是实现了逻辑,比如当我接受到RPC请求想调用我的Login函数时,我就调用Login函数,但是Login函数这个实体还没有实现。这个时候用户需要自己定义一个类继承UserServiceRpc类。在UserServiceRpc类中,Login函数是虚类,当我们实现了一个类(就叫它UserService类吧)并继承了UserServiceRpc类之后,重写Login函数。利用C++多态特性,当UserServiceRpc成员函数运行它的处理逻辑时具体调用的实体函数就是用户实现的函数。因为c++虚函数是动态绑定的,所以这个方法应该可以算是动态代理吧????对于用户来说,其实只需要在业务层实现一个UserService类继承UserServiceRpc类,并且实现一下供远端调用的函数逻辑就可以了,这就是利用代理模式+多态实现的屏蔽RPC框架内部处理流程
 UserServiceRpc_Stub类也是差不多的思想,但又略有不同,这个可以自己去看代码理解,或者日后有空我再补上。其实只要对代理模式有理解,而不是停留在八股文上,估计都能很快明白。

2.2.7 客户端调用异步RPC,提高客户端吞吐量

 有没有想过一个问题,假如我一段代码中发起了四个RPC远端调用,假如每一个远端调用都要20ms,那顺序执行下来最快也要80ms吧。在这段时间里,客户端代码基本上都处于cpu挂起状态,cpu利用率比较低下,这就是同步调用的鸡肋之处。要是能实现异步调用,当发起远端调用后就继续主代码的运行逻辑,等远端调用结果回来之后,再去拿结果回来。
 最理想最理想的情况下,就是这四个远端调用之间彼此没有什么依赖,而且再拿到结果之前主代码都可以继续运行,在主代码需要这四个结果做进一步处理之前,这四个结果就已经上交给主代码了。这种完美的情况下,四个远端调用只要20ms。这就是,我们的客户端吞吐量最好的情况下可能提升4倍。
 怎么实现客户端异步调用?由于我个人在项目中并没有实现这块(画个大饼,研三一定找机会实现,最近真的忙),但是我个人建议去参考这个链接,https://melonshell.github.io/2020/01/25/tech4_rpc/。这里出于知识产权尊重问题就不copy了。
 还是稍微提一下大致思路,调用端的异步可以通过调用远端函数的时候注入回调来实现,调用段端发起一次异步请求之后继续处理和RPC调用无关的主代码逻辑,当需要这个RPC请求的结果的时候,假如此时另外一个线程已经从远端服务器上拿到了结果,这个时候可以另外一个线程会执行这个回调,相当于把结果上交给调用这个回调的线程。

2.2.8 服务端异步

 服务端的IO线程收到来自远端发来的数据包(远端调用请求数据),之后进行拆包和消息解码反序列化等,再通过解码出来的服务对象方法调用业务逻辑处理函数,有没有思考过这些操作都是在哪个线程中执行的呢?
 对于二进制数据包的拆解、解码和反序列化过程应该在IO线程里面处理,而业务逻辑处理应该在专门的业务线程池的线程中处理,如果都在IO线程里面处理的话,那其他IO线程就会受到影响,进而影响整个网络的并发性能。
 这时候又来新问题了。那就是业务线程池数量,就算多给业务线程池配几条线程,如果业务逻辑比较耗时,耗时业务也会挤压其他服务。所以最好是为服务端业务处理逻辑也引入异步处理机制,把一个串行的业务逻辑尽量解耦成能异步处理的逻辑,最终结果以回调的方式响应给调用端。(这个实现还是要看场景和业务代码了,当然还有编程者的功底)

3. 服务发现

3.1 服务发现概述

 在一个分布式环境下,服务方法的调用端怎么知道自己调用的服务方法在哪台机器上有提供?怎么获得这个机器的IP地址和端口以联系这个机器并请求服务?所以这里就需要注册中心集群了,注册中心里面会记录服务对象方法以及提供者的IP端口。这个获取服务对象方法提供者地址信息的过程就叫服务发现!

服务发现机制主要提供两个功能!
 1. 服务注册:服务提供方在正式提供服务之前要先把自己的服务对象注册到注册中心上,注册中心会把这个服务提供者的地址信息以及提供的服务对象名+方法名保存下来。

 2. 服务订阅:在服务调用方启动时,会去注册中心找自己需要的服务对象对应的服务提供者的地址信息,然后缓存到本地,为远程调用做储备。

3.2 利用ZooKeeper实现服务发现功能

3.2.1 ZooKeeper是怎么存储数据的?

 稍微画了个图,凑合看一下,这个图只展示出了服务提供方目录。
请添加图片描述

  1. 注册中心的管理者会在ZooKeep下创建一个服务根路径,可以根据接口来命名(我上面的图就随便给了一个斜杠 “/” 作为根路径)。在这个路径下面可以再创建服务提供方目录与服务调用方目录。
  2. 服务提供方注册时,发起注册时,会在服务提供方目录中创建一个临时节点,这个节点存储服务提供方的注册信息,就比如上图中我的UserServiceRpc/Login节点中存的就是提供这个方法的服务器的IP端口号。
  3. 服务调用方发起订阅时,服务调用方目录会创建一个临时节点,节点中存储服务调用方信息。
  4. 当服务提供方目录中节点发生了任何变化时(新增节点,移除节点,节点上数据变动等),ZooKeeper就会通知发起订阅的服务调用方。
3.2.2 Watcher机制

 上一小节有一个东西没讲清楚,就是服务提供方目录中节点发生了任何变化,ZooKeeper怎么通知订阅的服务调用方的,这里就涉及到Watcher机制了。
 首先要知道为什么要有Watcher机制的存在,假如客户端保存了若干服务对象方法对应的服务提供者的地址信息,假如某个服务提供者挂了,在ZooKeeper中表现为服务提供方的某节点消失了。这个时候就需要告知客户端,这个节点消失了,无法继续提供服务了,没有资格继续被客户端缓存在本地了。
请添加图片描述
 Watcher机制简单来讲,就是客户端告知服务端,如果某个节点或者这些子节点发生任何的变化,都必须通知客户端。不过要想服务端告知客户端,客户端必须先往上面注册一个对某个节点的watch事件。服务端发给客户端watch通知,不过服务端发送的watch通知里面不会包含节点数据,就只是告诉节点发生了什么watch事件。另外watch的触发是一次性触发,假如客户端收到了这个watch通知之后,如果后面还想再继续收到相同节点的watch通知,那必须再注册一次对这个节点的watch事件(ZooKeeper3.6.0版本后增加了永久递归watch,自己去了解)。
 客户端向服务端注册了watch事件,同时还需要向客户端中的WatchManager提供一个某节点watch事件对应的回调函数,当服务端向客户端通知watch时,WatchManager就会调用提前保存的回调函数。

3.2.3 ZooKeeper作为服务中心真的好吗?他有什么缺点啊?

 ZooKeeper最大特点就是强一致性,只要ZooKeeper上面有一个节点发生了更新,都会要求其他节点一起更新,保证每个节点的数据都是完全实时同步的,在所有节点上的数据没有完全同步之前不干其他事。
 拿ZooKeeper搞点小项目其实还能应对,但是如果分布式环境中提供服务的和访问服务的机器越来越多,变化越来越频繁时,ZooKeeper为了维持这个强一致性需要付出很多代价,最后ZooKeeper服务注册中心会承受不住压力而崩溃。

3.2.4 除了ZooKeeper以外还有什么更好技术方案实现服务发现中心?

 在超大规模集群服务发现场景下,强一致性是不得不舍弃的,只有以最终一致性作为目标才能支撑起超大规模的场景。

关于最终一致性方案,稍后添加。挖个坑

3.2.5 服务发现的目的在于发现调用方想要知道某个服务方法提供者的IP端口,这不是和DNS机制很相似吗,为什么不用DNS来实现?

 服务发现就是根据服务方法名获取提供该服务的设备IP和端口,这一点功能和DNS机制很像。所以为什么不直接用DNS?把服务方法名当成域名,通过DNS服务器保存IP地址和端口。
未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续 未完待续

4. 健康检测

5. 负载均衡

6. 异常重试机制

7. 熔断限流

8. 启动

9. 关闭

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐