深入浅出 RPC框架

RPC 框架分层设计

01 基本概念

1.1 本地函数调用

以上步骤只是为了说明原理。事实上编译器经常会做优化,对于参数和返回值少的情况会直接将其存放在寄存器,而不需要压栈弹栈的过程,甚至都不需要调用call,而直接做inline操作

1.2 远程函数调用(RPC—Remote Procedure Calls )

1、函数映射

我们怎么告诉支付服务我们要调用付款这个函数,而不是退款或者充值呢?

在本地调用中,函数体是直接通过函数指针来指定的,我们调用哪个方法,编译器就自动帮我们调用它相应的函数指针

但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。

所以函数都有自己的一个ID,在做 RPC的时候要附上这个 ID,还得有个 ID 和函数的对照关系表,通过 ID找到对应的函数并执行。

2、客户端怎么把参数值传给远程的函数呢?

在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。

但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。

这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式

3、 远程调用往往用在网络上,如何保证在网络上高效稳定地传输数据?

1.3 RPC 概念模型

1.4 一次 RPC 完整过程

相比本地函数调用,远程调用的话我们不知道对方有哪些方法,以及参数长什么样,

所以需要有一种方式来 描述 或者 声明 我有哪些方法,方法的参数都是什么样子的,

这样的话大家就能按照这个来调用,这个描述文件就是 IDL 文件。

刚才我们提到服务双方是通过约定的规范进行远程调用,双方都依赖同一份IDL文件,需要通过工具来生成对应的生成文件,具体调用的时候用户代码需要 依赖 生成代码,所以可以把用户代码和生成代码看做一个整体

编码只是解决了跨语言的数据交换格式,但是如何通讯呢?需要制定通讯协议,

以及数据如何传输?我的网络模型如何呢?那就是这里的 transfer 要做的事情。

1.5 RPC好处

单一职责,开发(采用不同的语言)、部署以及运维(上线独立)都是独立

可扩展性强,例如压力过大的时候可以独立扩充资源,

底层基础服务可以复用,节省资源 某个模块发生故障,不会影响整体的可靠性

1.6RPC带来的问题

1.7 总结

1.本地函数调用和RPC 调用的区别:

RPC需要解决:函数映射、数据转成字节流、网络传输

2.RPC 的概念模型:User、User-Stub、RPC-Runtime、Server-Stub、Server

3. 一次 PRC 的完整过程,并讲解了RPC的基本概念定义

4.RPC 带来好处的同时也带来了不少新的问题,将由 RPC 框架来解决

02分层设计

2.1 分层设计— 以Apache Thrift为例

Code 是用户自己编写的逻辑代码 不在框架范畴;

2.2 编解码层

生成代码也是编解码层的一部分

2.3 编解码层—生成代码

2.4 编解码层—数据格式

语言特定编码格式:

这种编码形式 好处 是非常方便,可以用很少的额外代码实现内存对象的保存与恢复,这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。

如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。安全和兼容性也是问题

文本格式:

文本格式具有人类可读性,数字的编码多有歧义之处,比如XML和CSV不能区分数字和字符串,JSON虽然区分字符串和数字,但是不区分整数和浮点数,而且不能指定精度,处理大量数据时,这个问题更严重了;

没有强制模型约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便。 由于JSON在一些语言中的序列化和反序列化需要采用反射机制,所以性能比较差;

二进制编码:

实现可以有很多种,TLV 编码 和 Varint 编码

2.5 编解码层—二进制编码

这里我们可以看到他的第一个byte是类型,主要用来表示是string还是int还是list等等。

这里不写key的字符串了,比如上面的userName,favoriteNumber等等,

取而代之的是一个field tag的东西,这个会设置成1,2,3和上面的schema中key字符串前面的数字,也就是用这里来取代了具体的key值,从而减小的总体的大小,

这里打包后压缩到 59个字节 TLV编码结构简单清晰,并且扩展性较好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。

2.6 编解码层— 选型

兼容性:

移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。

通用性有两个层面的意义:

第一、技术层面,序列化协议是否支持跨平台、跨语言。

		如果不支持,在技术层面上的通用性就大大降低了。 

第二、流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。

性能:

第一、空间开销(Verbosity),

序列化需要在原有的数据上加上描述字段,为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。

对于海量分布式存储系统,数据量往往以TB为单位,巨大的的额外空间开销意味着高昂的成本。

第二、时间开销(Complexity),复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。

2.7 协议层 TTransport

2.8 协议层— 概念

协议是双方确定的交流语义,比如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。

这个协议很简单,首先发送一个4字节的消息总长度,然后再发送1字节的字符集charset长度,接下来就是消息的payload,字符集名称和字符串正文。

特殊结束符:过于简单,对于一个协议单元必须要全部读入才能够进行处理,除此之外必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱

HTTP 协议头就是以回车(CR)加换行(LF)符号序列结尾。

变长协议:一般都是自定义协议,有 header 和 payload 组成,会以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度,使用比较广泛

2.9 协议构造

LENGTH 字段 32bits,包括数据包剩余部分的字节大小,不包含 LENGTH 自身长度

HEADER MAGIC 字段16bits,值为:0x1000,用于标识 协议版本信息,协议解析的时候可以快速校验 FLAGS 字段 16bits,为预留字段,暂未使用,默认值为 0x0000

SEQUENCE NUMBER 字段 32bits,表示数据包的 seqId,可用于多路复用,最好确保单个连接内递增

HEADER SIZE 字段 16bits,等于头部长度 字节数/4,头部长度计算从第14个字节开始计算,一直到 PAYLOAD 前(备注:header 的最大长度为 64K)

PROTOCOL ID 字段 uint8 编解码方式,

取值有:~ ProtocolIDBinary = 0 ProtocolIDCompact = 2 这两种

NUM TRANSFORMS 字段 uint8 编码,表示 TRANSFORM 个数

TRANSFORM ID 字段 uint8 编码,具体取值参考下文,表示压缩方式 zlib or snappy

INFO ID 字段 uint8 编码,具体取值参考下文,用于传递一些定制的 meta 元数据 信息

PAYLOAD 消息内容

2.10协议解析

2.11 网络通信层 Network IO

2.12 Sockets API

套接字编程中的客户端必须知道两个信息:服务器的 IP 地址,以及端口号

socket函数创建一个套接字,bind 将一个套接字绑定到一个地址上。

listen 监听进来的连接,放到队列里,backlog的含义有点复杂,这里先简单的描述:指定挂起的连接队列的长度,当客户端连接的时候,服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,backlog则指定这个队列的长度,accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。如果队列未满,客户端调用connect马上成功,如果满了可能会阻塞等待队列未满(实际上在Linux中测试并不是这样的结果,这个后面再专门来研究)。

Linux的backlog默认是128,通常情况下,我们也指定为128即可。

connect 客户端向服务器发起连接,accept 接收一个连接请求,如果没有连接则会一直阻塞直到有连接进来。得到客户端的fd之后,就可以调用read, write函数和客户端通讯,读写方式和其他I/O类似

read 从fd读数据,socket默认是阻塞模式的,如果对方没有写数据,read会一直阻塞着:

write 写fd写数据,socket默认是阻塞模式的,如果对方没有写数据,write会一直阻塞着:

socket 关闭套接字,当另一端socket关闭后,这一端读写的情况: 尝试去读会得到一个EOF,并返回0。 尝试去写会触发一个SIGPIPE信号,并返回-1和errno=EPIPE,SIGPIPE的默认行为是终止程序,所以通常我们应该忽略这个信号,避免程序终止。

如果这一端不去读写,我们可能没有办法知道对端的socket关闭了。

2.13 网络库

02 小结

03 关键指标

3.1稳定性-保障策略

熔断:

一个服务 A 调用服务 B 时,服务 B 的业务逻辑又调用了服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 继续频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机,由此就导致了服务雪崩的问题

限流: 当调用端发送请求过来时,服务端在执行业务逻辑之前先执行检查限流逻辑,如果发现访问量过大并且超出了限流条件,就让服务端直接降级处理或者返回给调用方一个限流异常

超时: 当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,避免浪费资源 从某种程度上讲超时、限流和熔断也是一种服务降级的手段

3.2 稳定性 - 请求成功率

注意,因为重试有放大故障的风险,首先,重试会加大直接下游的负载。

如上图,假设 A 服务调用 B 服务,重试次数设置为 r(包括首次请求),当 B 高负载时很可能调用不成功,这时 A 调用失败重试 B ,B 服务的被调用量快速增大,最坏情况下可能放大到 r 倍,不仅不能请求成功,还可能导致 B 的负载继续升高,甚至直接打挂。 防止重试风暴,限制单点重试和限制链路重试

3.3 稳定性 - 长尾请求

长尾请求一般是指明显高于均值的那部分占比较小的请求。 业界关于延迟有一个常用的P99标准, P99 单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99 值,那后面这 1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动,GC,系统调度。 我们预先设定一个阈值 t3(比超时时间小,通常建议是 RPC 请求延时的 pct99 ),当 Req1 发出去后超过 t3 时间都没有返回,那我们直接发起重试请求 Req2 ,这样相当于同时有两个请求运行。然后等待请求返回,只要 Resp1 或者 Resp2 任意一个返回成功的结果,就可以立即结束这次请求,这样整体的耗时就是 t4 ,它表示从第一个请求发出到第一个成功结果返回之间的时间,相比于等待超时后再发出请求,这种机制能大大减少整体延时。

3.2 稳定性 - 请求成功率

04 企业实践

非常感谢您阅读到这里,创作不易!如果这篇文章对您有帮助,希望能留下您的点赞👍 关注💖 收藏 💕评论💬感谢支持!!!

听说 三连能够给人 带来好运!更有可能年入百w,进入大厂,上岸

posted @ 2023-08-25 10:18  软工菜鸡  阅读(57)  评论(0编辑  收藏  举报  来源