目录

一、前言

二、服务注册与发现

三、通信协议设计

四、序列化与反序列化

五、服务端接口注册与调用

六、性能优化

七、服务容灾

八、服务配置

九、服务监控

十、demo演示


一、前言

远程过程调用(英语:RemoteProcedureCall,RPC)是一个计算机通信协议,该协议允许程序员像调用本地函数一样调用部署在远程机器上的函数,但无需额外地为这个交互作用编程(无需关注细节)。RPC框架基于RPC协议的思想开发,隐藏了远程调用的细节,对客户端提供一个代理,通过客户端代理实现对远程服务的简易调用。

假设我要在本地机器上调用一个部署在远程机器上的函数,那么在本地机器通过rpc框架调用远程服务编写的代码应该是类似下面这种形式:

也就是说,在客户端,只需要向rpc框架传入服务名就可以获得对应rpc服务的代理,通过这个代理就可以像调用本地函数一样调用远程函数;

在服务端,假设我们已经声明了一个对象并定义该对象内部的各种方法,如果要向外提供rpc服务,那么编写的代码应该是类似下面这种形式:

也就是说,在服务端,我们声明某一个对象,然后定义对象的各个方法,最后调用rpc框架的注册函数,将该对象与某个具体的服务名绑定起来并通过网络对外提供rpc服务,当服务端接收外部的请求时,会从请求中提取出函数名和请求参数,调用对应函数获得返回值后封装到响应包中再返回给客户端;

根据上面描述的过程,一个标准的rpc框架应该提供以下能力:

  1. 服务发现:正如我们平时访问网站是通过网址访问而不是通过ip访问一样,类似的,对于rpc服务,客户端调用远程服务应该传入的是服务名而不是具体的ip地址,因此rpc框架应该提供一种类似dns的功能:

(1)对于客户端:客户端代理向rpc框架传入需要调用的rpc服务名后,rpc框架应该能通过服务名向服务发现中心获得该服务部署机器的ip地址,并通过这个地址发起网络请求调用;

(2)对于服务端:服务端在编写完服务逻辑后,应该访问服务发现中心,在服务发现中心将服务名与服务部署机器的ip地址绑定起来,客户端通过服务发现中心能获取rpc服务的具体ip地址;

(3)第三方服务发现中心:应该是一个分布式的数据库,服务端在该数据库将服务名与网络地址绑定起来,客户端通过服务名请求服务发现中心获取服务具体的网络地址;

2. 序列化与反序列化:rpc框架在拿到客户端传入的函数请求参数后,要将这些参数传递给rpc服务端,服务端解析客户端传递的参数后调用相应的函数并将结果返回给客户端,这个过程是通过网络通信实现的,请求包和响应包在网络通信都是以二进制数据传输的,因此在发送数据时,rpc框架需要将数据序列化为二进制数据,在接收数据时,需要将数据反序列化为程序可识别的数据结构;

3. 网络数据传输:在拿到请求数据或响应数据的二进制包后,rpc需要调用底层的网络函数将数据发送出去,这里我们可以将二进制包直接塞进http协议的body里面然后通过http接口发送出去,也可以直接调用tcp接口和udp接口发送字节流;

简单来说,一个标准的rpc调用过程应该是这样的:

二、服务注册与发现

理论上,所有分布式数据库都可以当作服务发现中心,服务发现中心应该提供以下功能:

  1. 一致键值存储:存储的数据应该应该是key-value格式,其中key值唯一;
  2. 动态管理:在新机器节点启动服务时,服务中心应该在对应服务地址列表中添加新的地址值;当某机器节点停止服务时,服务中心应删除服务地址列表中的相应地址;
  3. 高并发读:发起rpc调用时都会请求服务发现中心,在缓存过期的情况下并发量尤其高,因此需要注册中心有相当高的并发读性能;

常用的服务发现中心有Zookeeper、Eureka、Nacos、Consul和ETCD,它们的特点如下图所示:

其中,Eureka 是典型的 AP,Nacos可以配置为 AP,保障的是数据的可用性;其他的注册中心是CP类型,保障的是数据的一致性;

本文的TinyGRPC框架使用的是zookeeper,数据以节点的形式存在,通过上层节点可以获得下层节点的值,如下所示:

业务节点为根节点,一个业务下面可以有多个服务,一个服务可以部署在多个机器上,每个机器有对应的地址,这些地址信息存储在地址节点中,其中,业务节点和服务节点为持久化节点,永久有效;而地址节点为临时节点,其生命周期和客户端会话绑定,当会话失效时相关的临时节点被移除。

  1. 对于rpc服务端,注册节点时需要传入业务名、服务名和当前节点ip地址,rpc框架在注册节点时,会先检查业务节点和服务节点是否存在,若不存在则创建对应的持久化节点;然后创建临时节点并将节点ip地址保存在临时节点中,当服务停止时注册中心自动删除对应的地址节点;
  2. 对应rpc客户端,获取节点时需传入业务名和服务名,框架会先尝试读取缓存,当缓存过期时才去访问注册中心,注册中心返回地址列表后,通过随机选择方式实现负载均衡;

三、协议设计

请求包和响应包在网络中都以二进制字节流传输,那么为了解析请求和响应,需要设计一个协议,通过该协议rpc框架能对字节流进行切割,来进一步识别出包头和包体。一般而言,包头的长度应该是固定的,这样rpc框架就可以在接受到每一次请求时直接读取固定长度的字节并解析为包头,通过这种方式可以避免网络字节流传输中常见的“粘包”问题。在本文的TinyGRPC框架中,将请求包划分为rpc头、请求头和请求体三部分,其中rpc头固定为8字节,以大端模式传输,rpc头包含的信息如下:

其中,通过MagicNumber可以判断请求是否为合法rpc请求,非法请求直接拒绝;Version标识当前请求使用的rpc版本信息;KeepAlive标识长短连接,服务端对于长短连接有不同的处理方式;CodecType标识了请求头和请求体的序列化方式,获得该信息后通过golang的encoding库可以生成对应的decoder来完成对剩下的请求头和请求体的读取与解析;Timeout标识了针对该请求所允许的最长处理时间;

请求头的设计如下:

注意这里的CustomHeader长度并不是固定的,那么decoder是怎么知道二进制字节流的请求头部分在哪里结束呢?以json序列化方式为例,其头必为"{",结尾必为"}",所以decoder其实是通过检测固定字符对来切割请求头的。

请求体则为请求参数的序列化字节流了,一般而言rpc客户端会将所有请求参数放在同一个结构体中,然后序列化为字节流放在请求头后面一起发送出去。

综上所述,本文的rpc请求包协议设计如下:

对于响应包,TinyGRPC将响应包分为响应头和响应体两部分,其中响应头包含返回的错误码和错误信息,响应体则为接口返回值的序列化字节流,其结构如下:

四、序列化与反序列化

序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。在rpc框架中,序列化最终的目的是为了将请求包和响应包转换为二进制字节流进行网络传输 。Go常用的序列化算法有json/gob/protobuf/msgp等,其中json和gob为go自带的序列化算法,性能较低,而pb和msgp则为较为高效的算法,在压缩性能和速度上有较为明显的优势。考虑到时间问题,本文的RPC框架只提供了gob和json两种序列化方式,为了弥补其性能的不足,在写方面使用了缓存写来提高写入性能,具体代码见:tiny_grpc/codec at main · Buerzhu/tiny_grpc。

五、服务端接口注册与调用

对于rpc客户端而言,其调用的是某个服务的某个接口,对于rpc服务端而言,通常来说服务对应的是某个对象,而接口对于的则是该对象的某个方法。在rpc服务端获取到服务名和接口名后,该如何调用对象的对应方法呢?这就要求我们预先完成接口的注册了。在go自带的net/rpc中,对于一个可对外提供服务的接口,其要求如下:

  1. the method’s type is exported. – 方法所属类型是导出的;
  2. the method is exported. – 方法是导出的;
  3. the method has two arguments, both exported (or builtin) types. – 两个入参,均为导出或内置类型;
  4. the method’s second argument is a pointer. – 第二个入参必须是一个指针;
  5. the method has return type error. – 返回值为 error 类型;

也就是类似下面这样的格式:

在TinyGRPC框架中,对可远程调用的接口进行了拓展,增加了ctx作为第一个入参,req指针作为第二个入参,rsp指针作为第三个入参,格式如下:

增加ctx是考虑到假设有多个rpc节点在相互调用,那么通过ctx可以传递自定义value和超时信息,其实现方法为:(1)rpc服务端获取到请求包时,从请求包获取到超时和自定义头信息,将其填充入ctx然后通过入参传递给接口;(2)接口在调用下游rpc服务时,从ctx取出剩余超时时间和自定义value,然后添加到请求包继续传递到下一个rpc服务。通过这种方式,TinyGRPC实现了全链路超时控制和调用链路的监控。

在实现了对象接口的声明和定义后,对于一个对象的多个接口,需要使用一个全局的map将接口名与具体的实现逻辑映射在一起,这里的key为接口名,value为方法的反射值。在获取到服务名和接口名时,通过map找到对应的接口,然后通过反射调用机制将参数值传递给接口,最后完成函数的调用和rsp的填充。

六、性能优化

在远程调用的过程中,耗时主要为三部分:(1)rpc框架处理请求和封装返回的耗时;(2)网络传输的耗时;(3)远程接口执行逻辑的耗时。一个合格的rpc框架,应该将第一步的耗时压缩得足够短。在本文的TinyGRPC框架中,主要的性能优化有三处:

  1. 服务发现的缓存优化:在rpc客户端传入服务名时,优先请求缓存来获取IP地址列表,缓存过期则通过异步协程来请求服务注册中心并更新缓存;
  2. 连接池优化:创建网络连接耗时较大,通过连接池来降低创建连接的耗时。当客户端使用连接池时,优先从连接池获取网络连接,连接使用完毕后,将连接重新放回连接池;
  3. 协程池优化:类似连接池,需要使用协程时从协程池获取协程,可以减少重复创建协程产生的花销。

下面测试下优化效果,由于第一点的效果比较明显,因此压测测试的是第二点和第三点的优化效果。测试步骤如下:

(1)启动一服务端,每一秒在客户端同时发起5000个请求,连续压测一分钟;

(2)统计一分钟内客户端从发出请求到返回响应所花费的时间,并计算出平均响应时间;

(3)使用火焰图查看各部分的耗时时间;

测试代码如下:

服务端:

客户端:


压测结果如下:

使用连接池和协程池前:

(1)平均耗时为:123ms;

(2)火焰图:如下,可以看到初始化连接和内存回收在客户端调用中还是占用了不少时间的

使用连接池和协程池后:

(1)平均耗时为:54ms,性能提高50%以上

(2)火焰图:如下,可见使用池化技术还是减少了不少建立连接的时间以及创建协程的时间;

七、服务容灾

RPC框架大部分时间应用于微服务,即将一个系统拆分为多个微服务,然后通过rpc框架来彼此调用。根据微服务的思想,必定会出现冗长的调用链路,当这个链路的某一个微服务异常时,将会引起链路反应,在网络调用量比较大的情况下甚至会引发雪崩效应,导致整个链路不可用。

容灾的思想就是:当请求量异常剧增或者某个微服务异常时,通过服务的一些特殊处理逻辑来实现调用链路的整体可靠,尽量避免完全丧失对外提供服务的能力。常见的容灾逻辑主要有以下几种:

  1. 服务限流:限制系统的输入和输出流量以达到保护系统的目的;
  2. 服务超时:在上游服务调用下游服务的时候,设置一个最大响应时间,如果超过这个时间,下游未作出反应,就断开请求,释放资源;
  3. 服务熔断:即断路器,上游服务为了保护系统整体的可用性,可以暂时切断对下游服务的调用;
  4. 服务降级:即后备模式,服务提供一个托底方案,一旦服务无法正常调用,就使用托底方案;
  5. 服务隔离:即舱壁模式。将系统按照一定的原则划分为若干个服务模块,各个模块之间相对独立,无强依赖。常见的隔离方式有:线程池隔离和信号量隔离;

在本文的TinyGRPC框架中,采用的容灾逻辑如下:

  1. 基于ctx实现全链路超时的控制:服务在接收到上游服务的rpc请求头时,会从请求头提取出超时时间,并与服务端最长处理时间相比较,取小者封装到ctx中传递给具体的接口;接口在调用下游服务时,rpc框架会提取出ctx剩余的超时时间,并封装到rpc请求头传递给下游服务;
  2. 基于连接池和协程池实现限流:若设置了限流,当连接池和协程池到达预设定的最大活跃连接数和活跃协程数时,当新的请求来请求连接资源和协程资源时,将直接拒绝该请求;
  3. 基于Hystrix实现熔断和降级:若开启了Hystrix容灾,那么下游服务调用逻辑将在Hystrix框架内运行,当被调用的服务失败率超过预设的阈值时,将直接使用降级逻辑来代替实际的调用逻辑;同时Hystrix还会间隔一端时间去重新调用下游服务来判断下游服务是否可用以及是否需要关闭熔断;

八、服务配置

RPC框架的使用场景复杂多变,不同场景的请求量不同、对性能的敏感性也不同,由此衍生出了不同场景下对框架的不同要求。若每调用一个rpc服务,都要使用代码手动指定框架的配置,那么将增加不少代码开发量。为了应对不同场景对rpc框架的不同要求,本文的TinyGRPC框架支持外部配置,允许使用者通过外部配置文件来修改程序的行为,实现了代码与配置分离; 另外TinyGRPC也同时支持使用者通过代码传入配置,且代码配置优先级高于框架配置。

TinyGRPC的框架配置使用yaml格式,支持配置的内容如下:

代码传入配置使用了配置类常见的设计模式--功能选项模式,该模式提供了将函数的参数设置为可选的功能,每次新增选项时,可以不改变接口保持兼容,并且参数无顺序要求,其具体的实现如下:

九、服务监控

在RPC框架中,对服务的监控是至关重要的,监控是保证服务可靠性的重要手段。对于一个服务而言,我们关心的是内存占用、协程数量、磁盘读写、网络流量这些系统指标,还有根据业务需要的自定义埋点上报指标。

Prometheus+Grafana

在本地安装好Prometheus+Grafana并启动后,若要开启监控,需要在main文件中引入TinyGRPC监控插件,如下所示:

引入插件后Prometheus就会自动开始采集数据,在Grafana添加Prometheus作为数据源,然后按照上面的教程配置监控面板,就可以看到如下所示的监控效果:

协程数监控
内存监控

Grafana从4.0开始新增告警功能,支持Email、webhook等多种方式的告警,可以根据个人需要对特定的指标设置告警,告警设置教程见:Grafana 告警配置并发送邮件

十、demo演示

在TinyGRPC框架的demo目录下,有demo演示文件,在运行该目录下的run.sh前,需保证:

  1. 本地安装了zookeeper并启动;
  2. 在配置文件tiny_grpc.yaml中替换zookeeper的地址为本地运行地址:

运行命令,sh run.sh,若一切正常的话将会打印以下日志:

参考链接: