新来的美女同事问我如何实现一个RPC框架,我这样回答她......

什么是RPC

RPC是远程过程调用(Remote Procedure Call)的缩写,RPC调用需要对应的RPC协议,它允许像调用本地方法一样调用远程服务。由于双方不在一个内存空间,不能直接调用,需要通过网络来传输方法调用的语义和参数

本地和远程

本地和远程是相对的,传统的单体应用,所有的方法调用都是基于进程内的,比如下面的方法

在这里插入图片描述

main方法调用add方法就是本地调用,这两个方法的字节码被加载进同一个JVM中,可以直接进行调用

而在分布式系统中,可能有很多微服务,他们之间要相互调用就不能直接像本地调用那么方便了,因为两者不在同一个进程内,他们使用的JVM也可能不是同一个,这个时候就需要使用到RPC了,如下图所示
在这里插入图片描述

如上图所示,Consumer端main方法在调用add方法时,实际上是调用的一个远程服务。Provider端提供add方法的具体实现,并且暴露出add方法,供Consumer端调用

那么,我们想一个问题,两个服务不在同一个进程内或者不在同一台服务器上,他们之间要实现方法调用,必然会走网络传输。那么就会涉及到Consumer端是如何将调用的数据传输给Provider的?Provider端是如何接收数据,执行方法,返回结果的?

下面我们来看一下自己实现一个简单的RPC需要实现哪些功能

自己实现RPC调用需要做什么

最简单的RPC调用过程如下所示

在这里插入图片描述

如上图所示,整个RPC调用过程如下:

  1. RPC Client发起远程调用,由Client端的远程代理模块解析请求
  2. RPC Client将数据包中的对象序列化成字节流,传递给网络模块
  3. 网络模块将数据包发送给RPC Server
  4. RPC Server端的网络模块接收请求,将数据包中的字节流反序列化成数据对象,传递给Server端的代理
  5. Server端的代理模块接收到请求和参数,进行本地方法调用
  6. 结果返回流程同前面1-5的调用:Server端将请求结果封包、序列化、网络传输,Client端接收、反序列化拿到结果

在知道整个RPC的调用过程后,我们来尝试自己手写一个简易版的RPC调用

自己动手实现一个简单的RPC调用

自己动手写一个简单的RPC调用,包括两部分:RPC-Client、RPC-Server

在下面的例子中,我将实现一个最简单的RPC:客户端调用一个addUser(user)方法,将user对象传输到远程Server服务,远程服务返回一个ResultInfo对象给客户端

实现RPC最主要的两个部分就是:序列化/反序列化和网络传输,在下面的例子中我使用Netty实现网络传输,使用Google的ProtoBuf实现序列化和反序列化

1、RPC-Server端

TcpServer
在这里插入图片描述

ServerHandler
在这里插入图片描述

TcpServerMain
在这里插入图片描述

2、RPC-Client端

TcpClient
在这里插入图片描述

ClientHandler
在这里插入图片描述

TcpClientMain
在这里插入图片描述

UserInfo、ResultInfo都是ProtoBuf生成的Java类,对应的.proto文件如下:

User.proto
在这里插入图片描述

Result.proto
在这里插入图片描述

对于ProtoBuf不熟悉的同学,需要去了解一下

运行程序后,客户端传递一个User对象给服务端,服务端接收User对象,回传一个Result对象给客户端

这里面涉及到了一个RequestId,为啥需要一个requestId?
因为Netty采用的是异步的网络IO模型,当客户端发起请求后,不会马上返回结果,采用事后回调的方式来通知客户端。那么通知客户端时,服务端是如何知道当前结果是对应的哪一次请求的,假如有很多网络请求的时候,就需要用一个requestId来将请求与响应配对,基本上RPC框架都是使用这样一个思路来实现的

以上是一个简单的RPC远程调用的实现,实际的RPC框架远远不止如此,那么一个RPC产品除了最基本的RPC调用功能,还需要有哪些功能呢?下面我们通过服务提供者和服务消费者分别介绍

RPC服务消费者核心功能设计实现

RPC服务消费者主要包括以下功能

  • 连接管理
  • 负载均衡
  • 请求路由
  • 超时处理

连接管理

在这里插入图片描述

如上图所示,调用过程如下:

  • Consumer发起调用,会将请求发送给远程方法的本地代理
  • 本地代理接收到请求之后,会进行序列化,将数据包转换成网上可传输的二进制流
  • 从连接管理模块拿到对应的连接,进行远程调用
  • 拿到结果后,进行反序列化返回给业务系统

1、连接建立时机

我们可以看到连接管理主要是用来维持与Provider的长连接,当需要进行RPC调用的时候,直接通过连接管理模块与Provider建立的长连接进行网络通讯

那么这里会有一个问题,连接管理模块何时与Provider建立长连接,是系统初始化时就建立,还是发起调用的时候再去建立?
在这里插入图片描述

如上图所示,在微服务架构中一般会有一个GateWay网关服务,所有的请求会经过网关再转发到对应的微服务中,那么GateWay与下游的各个服务的连接是网关启动时就建立,还是等有流量进来了再建立链接,我们来分析一下

假如是网关服务启动时就与下游各个服务建立连接,需要等所有链接建立好了之后,网关才能对外提供服务,这里会有两个问题

  • 网关的启动时间会变长,需要与很多服务建立连接
  • 假如有服务很少人使用,我们一开始就把连接建立好会造成资源浪费

那么网关这种场景可能更适合先启动服务,再通过少量的连接,把各个服务之间的连接建立起来,这种懒加载的方式对业务比较友好

还有一种场景就是服务之间的调用,可能对调用时长有严格的要求,如果是流量打过来再建立连接,可能会导致部分请求超时,这个时候我们在初始化的时候就预建立一部分连接会比较好

所以我们在设计RPC框架时要考虑各种情况,一般会支持初始化建立连接和懒加载方式,根据不同的场景来选择

2、连接数维护

一般在初始化连接时,会维护一定数量的连接用于RPC调用。这样的好处是当有连接进来时,可以直接使用建立好的连接,不用每次都去创建连接,影响调用的效率

3、心跳/重连
需要定时用心跳去检测连接是否可用,当检测到连接不可用时需要进行重连,避免在RPC调用时拿到一条不可用的连接,导致调用失败。

负载均衡

在这里插入图片描述

如上图所示,负载均衡模块是在连接管理之前,主要作用是为了使流量均衡的打到Provider服务节点

以上图为例,有4个Provider,当Consumer在发起调用时需要通过负载均衡模块来确定本次调用哪一个provider,然后再从连接管理模块拿到对应provider的连接进行调用

一般负载均衡支持如下几种策略:

  • 轮询
  • 随机
  • 取模
  • 带权重
  • 一致性Hash
轮询

轮询是将请求依次发送经每个服务,按顺序一个一个往下发,比较适合服务配置一样的服务集群

随机

将请求随机的发送给一台服务器处理,跟轮询一样,也适合服务配置一样的服务集群

取模

按字段取模,让同一个状态的请求发送到同一个服务节点上,一般用于有状态的服务

带权重

可以根据每台服务器的处理能力不同分配不同比例的流量,这个时候就可以使用带权重的负载均衡算法,让能力强的多处理一些请求,能力弱的少处理一些请求

一致性Hash

一致性Hash类似取模,根据IP地址进行Hash计算,相同的IP会落到同一个节点上,可以用来实现会话黏滞

路由

在这里插入图片描述

如上图所示,在负载均衡之前有一个路由模块,通过一系列规则过滤出可以选择的服务提供方节点列表,在应用隔离,读写分离,灰度发布中都发挥作用

我们举一个Dubbo路由的规则
在这里插入图片描述

上图中我们有四个服务节点,现在有一个节点是灰度环境节点,主要用于灰度环境的测试,那么我们就会有两类流量进来:1、正常业务流量;2、灰度环境测试流量。我们期望把灰度流量指向Provider1,正常流量不到Provider1

上图中有两个Filter,Filter1的规则是:所有流量都不到Provider1,通过这条规则就把Provider1排除在外,所有流量都打到2、3、4节点上。

Filter2的规则是:灰度流量到Provider1,因为有前面一条规则把流量都打到2,3,4节点了,再加上这一条规则,就实现了灰度流量到Provider1,正常流量都到2、3、4节点了

超时处理

在这里插入图片描述

当调用Provider超时后,需要将超时结果返回给业务系统,这里结果与请求的配对也是使用的RequestId来串连起来的

RPC服务提供者核心功能设计实现

  • 队列/线程池
  • 超时丢弃
  • 优雅关闭
  • 过载保护

队列/线程池

在这里插入图片描述

如上图所示,Provider在接收到Consumer请求后,会有如下的处理步骤

  • 请求首先发送给IO线程,此处的IO线程只负责接收请求而不处理实际的业务逻辑
  • 接收到请求后,进行数据的反序列化,把数据还原出来
  • 把请求放到请求队列中,线程池中会有一定的工作线程负责从队列中取出请求,并进行逻辑处理
  • 处理完成后把结果序列化后,给到IO线程
  • IO线程把结果返回给Consumer端

上图中可以看到请求队列有多个,一般会将不同类型的请求放到各自的队列中,每个队列分配独立的线程池,相互之间不影响,起到资源隔离的作用

超时丢弃

在这里插入图片描述

如上图所示的请求队列中,红色代表耗时请求,处理时间会超过超时时间,这个时候如果工作线程按顺序去处理,必然会导致后面正常的请求堆积,慢慢的后面的正常请求也因为排队全部超时,整个模块全部变成超时状态,在Consumer看来所有的请求全部超时了,Provider是不可用的

所以超时丢弃是Provider的一个很重要的功能,丢弃少量超时的请求,保证大部分请求正常可用是架构设计中需要权衡的

优雅关闭

在这里插入图片描述

我们服务升级或者版本迭代避免不了的需要发布上线或者服务重启,那么在整个重启过程中如果有请求正在处理,势必会丢掉部分请求,这个对业务是不友好的,那我们能不能实现优雅的关闭,实现不丢失请求呢?

我们想想这个问题,请求在源源不断的进来,我如何处理才能让Provider在不丢失请求的情况下,优雅的关闭服务

假如我能在关闭服务前留一段时间给正在处理的请求,让它处理完了再关闭服务,是不是就可以实现优雅的关闭呢?

那么还有一个问题,请求源源不断的进来,我在关闭服务前通知Consumer,我要重启服务了,不要再给我发送请求了

解决了这两个问题,基本上就可以实现优雅的关闭了

所以总结一下就是:在服务重启之前通知Consumer,不要再发请求过来了,相当于切断了流量,然后再预留一段时间给Provider将排队的请求处理完之后,再断开与客户端的连接

过载保护

在这里插入图片描述

如上图所示,当工作线程处理速度慢于Consumer请求的速度,就会造成请求队列积压,当越来越多的请求积压,就会导致队列越排越长,最终会导致所有的请求都无法正常处理

所以我们要进行过载保护,当堆积的请求达到一定数量后要拒绝外部请求,等当前排队的请求处理一部分,再放请求进来。这样虽然损失了一部分请求,却保证了大部分请求可以正常处理,而且服务也不会被压垮

总结

  • RPC是远程过程调用(Remote Procedure Call)的缩写,RPC调用需要对应的RPC协议,它允许像调用本地方法一样调用远程服务

  • 整个RPC调用过程如下:

    • RPC Client发起远程调用,由Client端的远程代理模块解析请求
    • RPC Client将数据包中的对象序列化成字节流,传递给网络模块
    • 网络模块将数据包发送给RPC Server
    • RPC Server端的网络模块接收请求,将数据包中的字节流反序列化成数据对象,传递给Server端的代理
    • Server端的代理模块接收到请求和参数,进行本地方法调用
    • 结果返回流程同前面1-5的调用:Server端将请求结果封包、序列化、网络传输,Client端接收、反序列化拿到结果
  • RPC服务消费者主要包括以下功能

    • 连接管理
    • 负载均衡
    • 请求路由
    • 超时处理
  • RPC服务提供者核心功能设计实现

    • 队列/线程池
    • 超时丢弃
    • 优雅关闭
    • 过载保护
posted @ 2021-08-07 16:43  果子爸聊技术  阅读(8)  评论(0编辑  收藏  举报