深入浅出RPC 框架 | 青训营

91 阅读10分钟

1. 基本概念

1.1 本地函数调用

func main() {
    var a = 2
    var b = 3
    result := calculate(a, b)
    fmt.Println(result)
}

func calculate(x, y int) {
    z := x * y
    return z
}

执行过程如下:

  1. ab的值压栈
  2. 通过函数指针找到calculate函数,进入函数取出栈中的值23,将其赋予xy
  3. 计算x * y,并将结果存在z
  4. z的值压栈,然后从calculate返回
  5. 从栈中取出z返回值,并赋值给result

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

1.2 远程函数调用

远程函数调用涉及到多台机器,需要解决以下几个问题:

  1. 函数映射
  2. 数据转换成字节流
  3. 网络传输

1.3 RPC概念模型

1984Nelson发表了论文《Implementing Remote Procedure Calls》,其中提出了RPC的过程由5个模型组成:UserUser-StubRPC-RuntimeServer-StubServer

image-20230804154735909.png

1.4 一次RPC完整过程

相比本地函数调用,远程调用的话我们不知道对方有哪些方法,以及参数长什么样,所以需要有一种方式来描述或者说声明我有哪些方法,方法的参数都是什么样子的,这样的话大家就能按照这个来调用,这个描述文件就是 IDL 文件。

  • IDL文件:通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信
  • 生成代码:通过编译工具把IDL文件转成语言对应的静态库
  • 编解码:从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化反序列化
  • 通信协议:规范了数据在网络中的传输内容和格式,除必须的请求、响应数据外,通常还会包含额外的元数据
  • 网络传输:通常基于成熟网络库走TCPUDP传输

image-20230804154928627.png

1.5 RPC优劣势

RPC的好处:

  1. 单一职责,有利于分工协作和运维开发
  2. 可扩展性强,资源使用率更优
  3. 故障隔离,服务的整体可靠性更高

RPC带来的问题:

  1. 服务宕机,对方应该如何处理?
  2. 在调用过程中发生网络异常,如何保证消息的可达性?
  3. 请求量突增导致服务无法及时处理,有哪些应对措施?

2. 分层设计

RPC主要分为3层:编解码层、协议层、网络通信层。

Apache Thrift为例来看下分层设计:

image-20230804155100353.png

2.1 编解码层

生成代码:根据IDL文件,生成需要的代码,可以是不同的语言。

image-20230804155220606.png

数据格式:一般地,数据格式可以分为几类

  • 语言特定的格式:许多编程语言内建了将内存对象编码为字节序列的支持,例如JavaSerializable
  • 文本格式:JSONXMLCSV等,具有可读性
  • 二进制编码:具备跨语言和高性能等优点,常见有ThriftBinaryProtocolProtobuf

二进制编码:

image-20230804155550648.png

选择编码格式需要考虑的点:

  • 兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
  • 通用性:支持跨平台、跨语言
  • 性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长

2.2 协议层

特殊结束符:一个特殊字符作为每个协议单元结束的标识,如\r\n

变长协议:以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度

ThriftTHeader 协议为例:

image-20230804155622557.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 消息内容

协议解析:

image-20230804155842265.png

2.3 网络通信层

Sockets API:

image-20230804155935814.png

在实际开发中,我们往往采用封装好的网络库来操作,对于网络库的选择可以参考以下几点:

  • 提供易用API
    • 封装底层Socket API
    • 连接管理和事件分发
  • 功能
    • 协议支持:tcpudpuds
    • 优雅退出、异常处理等
  • 性能
    • 应用层buffer减少copy
    • 高性能定时器、对象池等

3. 关键指标

3.1 稳定性

既然是稳定性,肯定要有保障策略,有以下几个策略:

  • 熔断:保护调用方,防止调用的服务出现问题而影响到整个链路
  • 限流:保护被调用方,防止大流量把服务压垮
  • 超时控制:避免浪费资源在不可用节点上

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

image-20230804160127477.png

3.1.1 请求成功率

提高请求成功率,要从负载均衡重试出发。

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

在重试时,要防止重试风暴,限制==单点重试==和限制==链路重试==。

image-20230804160308483.png

3.1.2 长尾请求

长尾请求一般是指明显高于均值的那部分占比较小的请求。

业界关于延迟有一个常用的P99标准, P99 单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99 值,那后面这 1%就可以认为是长尾请求。

在较复杂的系统中,长尾延时总是会存在,造成这个的原因非常多,常见的有网络抖动、GC、系统调度。

解决方案,重试请求:

image-20230804160528576.png

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

3.1.3 注册中间件

上面提到的是稳定性的措施和策略,那么如何把这些措施和错略进行串联起来呢?是通过中间件的形式。

image-20230804161111836.png

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

3.2 易用性

开箱即用:合理的默认参数选项、丰富的文档

周边工具:生成代码工具、脚手架工具

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

3.3 扩展性

需要提供尽量多的扩展点,扩展点包括:

  • 中间件
  • Option参数
  • 编解码层
  • 协议层
  • 网络传输层
  • 代码生成工具插件扩展

image-20230804161400766.png

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

3.4 观测性

观测性可以方便追踪、排查问题,包括:LogMetricTracing

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

image-20230804161559005.png

3.5 高性能

做到高性能,我们的目标是:高吞吐、低延迟。

衡量标准覆盖场景:

  • 单机多机
  • 单连接、多连接
  • 单/多client、单/多Server
  • 不同大小的请求包
  • 不同请求类型:例如pingpongstreaming

要达到目标需要的手段:

  • 连接池
  • 多路复用
  • 高性能编解码协议
  • 高性能网络库

多路复用:调用端向服务端的一个节点发送请求,并发场景下,如果是非连接多路复用,每个请求都会持有一个连接,直到请求结束连接才会被关闭或者放入连接池复用,并发量与连接数是对等的关系。而使用连接多路复用,所有请求都可以在一个连接上完成,可以明显看出连接资源利用上的差异。

4. Kitex

4.1 整体架构

image-20230804162035724.png

4.2 自研网络库

原生库go net缺点:

  • 无法感知连接状态:在使用连接池时,池中存在失效连接,影响连接池的复用。
  • 存在 goroutine 暴涨的风险:一个连接一个 goroutine 的模式,由于连接利用率低下,存在大量 goroutine 占用调度开销,影响性能。

自研Netpoll:

  • 解决无法感知连接状态问题:引入 epoll 主动监听机制,感知连接状态
  • 解决 goroutine 暴涨的风险:建立 goroutine 池,复用 goroutine
  • 提升性能:引入 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝

4.3 扩展性设计

支持多协议,也支持灵活的自定义协议扩展。

4.4 性能优化

4.4.1 网络库优化

调度优化:

  • epoll wait 在调度上的控制
  • gopool 重用 goroutine 降低同时运行协程数

LinkBuffer:

  • 读写并行无锁,支持 nocopy 地流式读写
  • 高效扩缩容
  • Nocopy Buffer 池化,减少 GC

Pool:

  • 引入内存池和对象池,减少 GC 开销

4.4.2 编解码优化

Codegen:

  • 预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
  • Inline 减少函数调用次数和避免不必要的反射操作等
  • 自研了 Go 语言实现的 Thrift IDL 解析和代码生成器,支持完善的 Thrift IDL 语法和语义检查,并支持了插件机制 - Thriftgo

JIT:

  • 使用 JIT 编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担
  • 基于 JIT 编译技术的高性能动态 Thrift 编解码器 - Frugal

4.5 合并部署

微服务过微,传输和序列化开销越来越大

将亲和性强的服务实例尽可能调度到同一个物理机,远程 RPC 调用优化为本地 IPC 调用