这是我参与「第五届青训营 」笔记创作活动的第13天
RPC 框架的基本概念
RPC(Remote Procedure Call)远程过程调用,简单的理解是一个节点请求另一个节点提供的服务。
本地函数调用和 RPC 调用的区别
本地函数调用:
func main(){
var a = 2
var b = 3
result := calculate(a, b)
fmt.Println(result)
return
}
func calculate(x, y int) {
z := x*y
return z
}
复制代码
- 将 a 和 b 的值压栈
- 通过函数指针找到 calculate 函数,进入函数取出栈中的值 2 和 3,将其赋予 x 和 y。
- 计算 x * y,并将结果存在 z
- 将 z 的值压栈,然后从 calculate 返回
- 从栈中取出 z 返回值,并赋值给 result。
远程函数调用(RPC - Remote Procedure Calls):
在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行,但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数,这时候需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。
RPC 的概念模型
概念模型组成:User、User-Stub、RPC-Runtime、Server-Stub、Server 提出于1984年 Nelson的论文《Implenmenting Remote Procedure Calls》
一次 RPC 的完整过程
IDL(Interface description language)文件
IDL 通过一种中立的方式描述接口,使得在不同平台上运行的·对象和用不同语言编写的程序可以相互通信。
生成代码
通过编译器工具把 IDL 文件转换成语言对应的静态库。
编解码
从内存中表示到字节序列的转换称之为编码,反之为解码,也常叫做序列化和反序列化。
通信协议
规范了数据在网络中的传输内容和格式。除必须的请求/响应数据外,通常还会包含额外的元数据。
网络传输
-
IO 网络模型: 网络 IO 的本质是
socket的读取,socket在linux系统被抽象为流,IO 可以理解为对流的操作。-
阻塞 IO(blocking IO): 需要内核IO操作彻底完成后,才返回到用户空间,执行用户操作
-
非阻塞 IO (non-blocking IO): 不需要内核IO操作彻底完成后,才返回到用户空间。
阻塞/非阻塞指的是用户空间程序的执行状态
-
多路复用 IO (IO multiplexing): 通过select/epoll系统调用,单个应用程序的线程,可以不断轮询成百上千的socket连接,当某个或者某些socket网络连接有IO就绪的状态,就返回对应的可以执行的读写操作。
-
信号驱动 IO(signal driven IO,SIGIO): 当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。
-
异步 IO(Asynchronous IO): 整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓存区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
-
-
传输层协议: 传输层的主要功能是实现分布式进程之间的通信。利用网络层提供的服务,在源主机的应用进程与目的主机的应用进程建立“端—端”连接。 传输层之间传输的报文称为“传输协议数据单元(TPDU)”,TPDU有效载荷称为应用层的数据
- TCP(传输控制协议): TCP是传输控制协议是一种面向连接的、可靠的、基于字节流的传输层通信协议,由 IETF 的RFC 793定义。TCP 是面向连接的、可靠的流协议。流就是指不间断的数据结构,你可以把它想象成排水管中的水流。
- UDP(用户数据报协议): UDP是一个简单的面向数据报的传输层协议。提供的是非面向连接的、不可靠的数据流传输。UDP不提供可靠性,也不提供报文到达确认、排序以及流量控制等功能。
PCR 的好处
- 单一职责,有利于分工协作和运维开发
- 可扩展性强,资源使用率更优
- 故障隔离,服务的整体可靠性更高
RPC 框架的分层设计
编解码层
以 Apache Thrift 为例 数据格式
- 语言特定的格式: 许多编程语言都内建了将内存对象编码为字节序列的支持,例如java.io.Serializable。这种编码形式好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复。
- 文本格式: JSON、XML、CSV等文件格式,具有人类可读性。
- 二进制编码: 具有跨语言和高性能等优点,常见有 Thift 的 BinaryProtocol等。
二进制编码
TLV编码
- Tag:标识字段的类型,占1个字节
- Length:长度
- Value:值,Value也可以是一个TLV结构
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
复制代码
上述结构的TLV编码如下:
TLV编码的结构简单清晰,并且扩展性较好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。
选型
对于不同编解码协议的选择,主要有三点考虑:
-
兼容性
移动互联时代,业务系统需求的更新周期变快,新的需求不断涌现,而老的系统还是需要维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提升系统的灵活度。
-
通用性
-
技术层面
序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就会大大降低。
-
流行程度
序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
-
-
性能
-
空间开销(Verbosity)
序列化需要在原有的数据上加上描述字段,来用于反序列化的解析。如果序列化的过程引入的额外开销过高,可能会导致过大的网络、磁盘方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的额外空间开销衣卫着高昂的成本。
-
时间开销(Complexity)
复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。
-
协议层
概念
协议是双方确定的交流语义,比如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。
消息切分
-
特殊结束符:一个特殊字符作为每个协议单元结束的标示。除此之外必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱。
message body \r\n message body \r\n
-
变长协议:以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度
length message body length message body
协议构造
-
LENGTH:字段 32 bits,数据包大小,不包含自身长度。
-
LEADER MAGIC:字段 16 bits,值为0×1000,用于标识 协议版本信息,协议解析的时候可以快速效验。
-
SEQUENCE NUMBER 字段 32 bits,表示数据包的 seqld,可用于多路复用,最好确保单个连接内递增。
-
HEADER SIZE:字段 16 bits,等于头部长度字节数/4,头部长度计算从第 14 个字节开始计算一直到 PAYLOAD 前。
-
PROTOTCOL ID:字段 uint8 编码
- Binary = 0
- Compact = 2
-
INFO ID:字段 uint8 编码 用于传递一些定制的 meta 的信息
-
PAYLOAD 消息体
网络通信层
Sockets API
网络库
-
提供易用 API
- 封装底层 Socket API
- 连接管理和事件分发
-
功能
- 协议支持:tcp、udp 和 uds 等
- 优雅退出、异常处理等
-
性能
- 应用层 buffer 减少 copy
- 高性能定时器、对象池等
RPC 框架的核心指标
稳定性
-
保障策略
- 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
- 限流:保护被调用方,防止大流量把服务压垮
- 超时控制:避免浪费资源在不可用节点上。
-
请求成功率
- 负载均衡:重试会加大下游的负载。
- 重试:为防止重试风暴,限制单点重试和限制链路重试。
- 长尾请求: 一般是指明显高于均值的那部分占比较小的请求。常见于网络抖动、GC、系统调度
易用性
-
开箱即用
- 合理的默认参数选项、丰富的文档
-
周边工具
- 生成代码工具、脚手架工具
扩展性
- Middleware
- Option
- 编解码层
- 协议层
- 网络传输层
- 代码生成工具插件扩展
观测性
- Log、Metric、Tracing
- 内置观测性服务
高性能
-
场景
- 单机多机
- 单连接多连接
- 单/多client 单/多server
- 不同请求类型:例如pingpong、streaming 等
-
目标
- 高吞吐
- 低延迟
-
手段
- 连接池
- 多路复用
- 高性能编解码协议
- 高性能网络库
Kitex 的企业实践分享
整体架构 - Kitex
-
Kitex Core 核心组件:定义框架的层次结构、接口、还有接口的默认实现。如 client 和 server 是对用户暴露的,client/server option 的配置都是在这两个 package 中提供的,还有 client/server 的初始化。
- client/server 下面的是框架治理层面的功能模块和交互信息,remote是与对端交互的模块,包含编解码是网络通信。
-
Kitex Byted 与公司内部基础设施集成:是对字节内部的扩展,byted 部分是在生成代码中初始化 client 和 cerver 时通过 suite 集成进来的,这样实现的好处是与字节内部特性解耦,方便后续开源拆分。
-
Kitex Tool 代码生成工具:里面包括idl解析、效验、代码生成、插件支持、自更新等。
自研网络库
背景
-
原生库无法感知连接状态
- 在使用连接池时,池中存在失效连接,影响连接池的复用。
-
原生库存在 goroutine 暴涨的风险
- 一个连接一个 goroutine 的模式,由于连接利用率低下,存在大量 goroutine 占用调度开销,影响性能。
Netpoll
- 引入 epoll 主动监听机制,感知连接状态
- 建立 goroutine 池,复用 goroutine
- 引入 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝。
扩展性设计
支持多协议,也支持灵活的自定义协议扩展
- 编解码支持thrift、Protobuf
- 应用层协议支持TTHeader、Http2、也支持裸的thrift协议
- 传输层目前支持TCP
性能优化
网络库优化
-
调度优化
- epoll_wait 在调度上的控制
- gopool 重用 goroutine 降低同时运行协程数
-
LinkBuffer
- 读写并行无锁,支持 nocopy 地流式读写
- 高效扩缩容
- Nocopy Buffer 池化,减少 GC
-
Pool
- 引入内存池和对象池,减少GC 开销
编解码优化
-
Codegen
- 预计算并预分配,减少内存操作次数,包括内存分配和拷贝
- lnline 减少函数调用次数和避免不必要的反射操作等
- 自研了 Go 语言实现的 Thrift IDL 解析和代码生成器,支持完善的 Thrift IDL 语法和语义检查,并支持了插件机制 - Thriftgo
-
JIT
- 使用 JIT 编译技术改善用户体验的同时带来了更强的编解码性能,减轻用户维护生成代码的负担。
- 基于 JIT 编译技术的高性能动态 Thrift 编解码器 - Frugal
合并部署
微服务过微,引入的额外的传输序列化开销越来越大。
将亲和性强的服务实例尽可能调度到同一个物理机,远程 RPC 调用优化本地 IPC 调用。