RPC介绍及框架设计 | 青训营笔记

57 阅读11分钟

基本概念

image.png

分层设计

image.png RPC框架主要分为以下三层:

  • 框架的编解码层

    • 通过代码生成工具把IDL文件转换成不同语言对应的lib代码,里面封装了编解码逻辑
    • 框架的编解码层
  • 框架的协议层

  • 框架的网络通信层

编解码层

生成代码

数据格式

  • 语言特定编码格式:这种编码形式好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复,这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。安全和兼容性也是问题
  • 文本格式:文本格式具有人类可读性,数字的编码多有歧义之处,比如XML和CSV不能区分数字和字符串,JSON虽然区分字符串和数字,但是不区分整数和浮点数,而且不能指定精度,处理大量数据时,这个问题更严重了;没有强制模型约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便。 由于JSON在一些语言中的序列化和反序列化需要采用反射机制,所以在性能比较差;
  • 二进制编码:实现可以有很多种,TLV 编码 和 Varint 编码

二进制编码

这里我们可以看到他的第一个byte是类型,主要用来表示是string还是int还是list等等。这里不写key的字符串了,比如上面的userName,favoriteNumber等等,取而代之的是一个field tag的东西,这个会设置成1,2,3和上面的schema中key字符串前面的数字,也就是用这里来取代了具体的key值,从而减小的总体的大小,这里打包后压缩到 59个字节

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

选型

协议层

概念

协议构造

image.png

  • 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 消息内容

协议解析

网络通信层

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关闭了。

网络库

  • 提供易用API

    • 封装底层Socket API
    • 链接管理和事件分发
  • 功能

    • 协议支持:tcp、udp和uds等
    • 优雅退出、异常处理等
  • 性能

    • 应用层buffer减少copy
    • 高性能定时器、对象池等

关键指标

  • 稳定性
  • 易用性
  • 扩展性
  • 观测性
  • 高性能

稳定性

保障策略

  • 熔断: 一个服务 A 调用服务 B 时,服务 B 的业务逻辑又调用了服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 继续频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机,由此就导致了服务雪崩的问题
  • 限流: 当调用端发送请求过来时,服务端在执行业务逻辑之前先执行检查限流逻辑,如果发现访问量过大并且超出了限流条件,就让服务端直接降级处理或者返回给调用方一个限流异常
  • 超时: 当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,避免浪费资源

从某种程度上讲超时、限流和熔断也是一种服务降级的手段

请求成功率

注意,因为重试有放大故障的风险,首先,重试会加大直接下游的负载。如下图,假设 A 服务调用 B 服务,重试次数设置为 r(包括首次请求),当 B 高负载时很可能调用不成功,这时 A 调用失败重试 B ,B 服务的被调用量快速增大,最坏情况下可能放大到 r 倍,不仅不能请求成功,还可能导致 B 的负载继续升高,甚至直接打挂。

防止重试风暴,限制单点重试和限制链路重试

长尾请求

长尾请求一般是指明显高于均值的那部分占比较小的请求。 业界关于延迟有一个常用的P99标准, P99 单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99 值,那后面这 1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动,GC,系统调度。

我们预先设定一个阈值 t3(比超时时间小,通常建议是 RPC 请求延时的 pct99 ),当 Req1 发出去后超过 t3 时间都没有返回,那我们直接发起重试请求 Req2 ,这样相当于同时有两个请求运行。然后等待请求返回,只要 Resp1 或者 Resp2 任意一个返回成功的结果,就可以立即结束这次请求,这样整体的耗时就是 t4 ,它表示从第一个请求发出到第一个成功结果返回之间的时间,相比于等待超时后再发出请求,这种机制能大大减少整体延时。

注册中间件

Kitex Client 和 Server 的创建接口均采用Option模式,提供了极大的灵活性,很方便就能注入这些稳定性策略。

易用性

Kitex使用Suite来打包自定义的功能,提供一键配置基础依赖的体验

扩展性

一次请求发起首先会经过治理层面,治理相关的逻辑被封装在middleware中,这些middleware会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等,mw执行后就会进入到remote 模块,完成与远端的通信。

观测性

除了传统的 Log、Metric、Tracing 三件套之外,对于框架来说可能还不够,还有些框架自身状态需要暴露出来,例如当前的环境变量、配置、Client/Server初始化参数、缓存信息等。

高性能

这里分两个维度,高性能意味着高吞吐和低延迟,两者都很重要,甚至大部分场景下低延迟更重要。

多路复用可以大大减少了连接带来的资源消耗,并且提升了服务端性能,我们的测试中服务端吞吐可提升30%。

右边的图帮助大家理解连接多路复用 调用端向服务端的一个节点发送请求,并发场景下,如果是非连接多路复用,每个请求都会持有一个连接,直到请求结束连接才会被关闭或者放入连接池复用,并发量与连接数是对等的关系。

而使用连接多路复用,所有请求都可以在一个连接上完成,大家可以明显看到连接资源利用上的差异

企业实践——Kitex

整体架构

  • core是它的的主干逻辑,定义了框架的层次结构、接口,还有接口的默认实现,如中间蓝色部分所示,最上面client和server是对用户暴露的,client/server option的配置都是在这两个package中提供的,还有client/server的初始化,在第二节介绍kitex_gen生成代码时,大家应该注意到里面有client.go和server.go,虽然我们在初始化client时调用的是kitex_gen中的方法,其实大家看下kitex_gen下service package代码就知道,里面是对这里的 client/server的封装。
  • client/server下面的是框架治理层面的功能模块和交互元信息,remote是与对端交互的模块,包括编解码和网络通信。
  • 右边绿色的byted是对字节内部的扩展,集成了内部的二方库还有与字节相关的非通用的实现,在第二节高级特性中关于如何扩展kitex里有介绍过,byted部分是在生成代码中初始化client和server时通过suite集成进来的,这样实现的好处是与字节的内部特性解耦,方便后续开源拆分。
  • 左边的tool则是与生成代码相关的实现,我们的生成代码工具就是编译这个包得到的,里面包括idl解析、校验、代码生成、插件支持、自更新等,未来生成代码逻辑还会做一些拆分,便于给用户提供更友好的扩展

自研网络库-netpoll

  1. Go Net 使用 Epoll ET ,Netpoll 使用 LT。
  2. Netpoll 在大包场景下会占用更多的内存。
  3. Go Net 只有一个 Epoll 事件循环(因为 ET 模式被唤醒的少,且事件循环内无需负责读写,所以干的活少),而 Netpoll 允许有多个事件循环(循环内需要负责读写,干的活多,读写越重,越需要开更多 Loops)。
  4. Go Net 一个连接一个 Goroutine,Netpoll 连接数和 Goroutine 数量没有关系,和请求数有一定关系,但是有 Gopool 重用。
  5. Go Net 不支持 Zero Copy,甚至于如果用户想要实现 BufferdConnection 这类缓存读取,还会产生二次拷贝。Netpoll 支持管理一个 Buffer 池直接交给用户,且上层用户可以不使用 Read(p []byte) 接口而使用特定零拷贝读取接口对 Buffer 进行管理,实现零拷贝能力的传递。