深入浅出 RPC 框架 | 青训营笔记
这是我参与「第三届青训营 -后端场」笔记创作活动的的第13篇笔记
课程概述
- RPC 相关的基本概念
- RPC 框架的分层设计
- 衡量 RPC 框架的一些核心指标
- 字节内部 RPC 框架 Kitex 实践分享
01 基本概念
1.1 本地函数调用
func main(){
var a = 2
var b = 3
result := calculate(a,b)
fmt.Println(result)
return
}
func calulate(x,y int){
z := x*y
return z
}
- 将a和b的值压栈
- 通过函数指针找到calculate函数,进入函数取出栈中的值2和3,将其赋予X和y
- 计算x*y并将结果存在z
- 将z的值压栈,然后从calculate返回
- 从栈中取出z返回值,并赋值给result
1.2 远程函数调用(RPC-Remote Procedure Calls)
-
相比本地函数调用,RPC调用需要解决的问题
- 函数映射
- 数据转换成字节流
- 网络传输
1.3 RPC 概念模型

1.4 一次 RPC 的完整过程

-
IDL (Interface description language)文件
IDL通过一种中立的方式来描述接口,使得在不同平台.上运行的对象和用不同语言编写的程序可以相互通信
-
生成代码
通过编译器工具把IDL文件转换成语言对应的静态库
-
编解码
从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化和反序列化
-
通信协议
规范了数据在网络中的传输内容和格式。除必须的请求/响应数据外,通常还会包含额外的元数据
-
网络传输
通常基于成熟的网络库走TCP/UDP传输
1.5 RPC的好处
- 单一职责,有利于分工协作和运维开发
- 可扩展性强,资源使用率更优
- 故障隔离,服务的整体可靠性更高
1.6 RPC 带来的问题将由 RPC 框架来解决
- 服务宕机如何感知?
- 遇到网络异常应该如何应对?
- 请求量暴增怎么处理?
1.7 小结
- 本地函数调用和RPC调用的区别:函数映射、数据转成字节流、网络传输
- RPC的概念模型: User、 User- Stub、RPC- -Runtime、Server- Stub、Server
- 一次PRC的完整过程,并讲解了RPC的基本概念定义
- RPC带来好处的同时也带来了不少新的问题,将由RPC框架来解决
02 RPC 框架分层设计
2.1 分层设计 - 以Apache Thrift 为例
2.2 编解码层
-
数据格式
- 语言特定格式:许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java有
java.io.Serializable - 文本格式:例如 JSON、XML、CSV 等
- 二进制编码:具备跨语言和高性能的优点, 常见有 Thrift 的
BinaryProtocol,Protobuf,实现可以有多种形式,例如TLV编码 和Varint编码
- 语言特定格式:许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java有
-
TLV编码
-
Tag:标签,可以理解为类型
-
Length:长度
-
Value:值,Value也可以是一个TLV结构
-
编码前:

-
编码后:

-
-
编码选型考察点
-
兼容性
- 支持自动添加新的字段,而不影响老的服务,浙江提高系统的灵活度
-
通用型
- 支持跨平台、跨语言
-
- 从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长
-
- 生成代码和编解码层相互依赖,框架的编解码应当具备扩展任意编解码协议的能力
2.3 协议层
-
概念
-
特殊结束符
- 一个特殊字符作为每个协议单元结束的标识

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

-
-
以 Thrift 的 THeader 协议为例
- 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 编码,取值有:
Binary和Compact两种 - NUM TRANSFORMS 字段 uint8 编码,表示 TRANSFORM 个数
- TRANSFORM ID 字段 uint8 编码,表示压缩方式
zlib or snappy - INFO ID 字段 uint8 编码,具体取值参考下文,用于传递一些定制的 meta 信息
- PAYLOAD 消息内容
- 协议解析
2.4 网络通信层
- 阻塞 IO 下,耗费一个线程去阻塞在 read(fd) 去等待用足够多的数据可读并返回。
- 非阻塞 IO 下,不停对所有 fds 轮询 read(fd) ,如果读取到 n <= 0 则下一个循环继续轮询。
第一种方式浪费线程(会占用内存和上下文切换开销),第二种方式浪费 CPU 做大量无效工作。而基于 IO 多路复用系统调用实现的 Poll 的意义在于将可读/可写状态通知和实际文件操作分开,并支持多个文件描述符通过一个系统调用监听以提升性能。
网络库的核心功能就是去同时监听大量的文件描述符的状态变化(通过操作系统调用),并对于不同状态变更,高效,安全地进行对应的文件操作。
2.4.1 Sockets API

2.4.2 网络库
-
提供易用 API
- 封装底层 Socket API
- 连接管理和事件分发
-
功能
- 协议支持:tcp、udp 和 uds 等
- 优雅退出、异常处理等
-
性能
- 应用层 buffer 减少 copy
- 高性能定时器、对象池等
2.4.3 小结
- RPC框架主要核心有三层:编解码层、协议层和网络通信层
- 二进制编解码的实现原理和选型要点
- 协议的一般构造,以及框架协议解析的基本流程
- 网络库的基本架构,以及选型时要考察的核心指标
03 RPC 框架核心指标
1. 稳定性
-
保障策略
- 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
- 限流:保护被调用方,防止大流量把服务压垮
- 超时控制:避免浪费资源在不可用节点上
从某种程度上讲超时、限流和熔断也是一种服务降级的手段 。
-
请求成功率
- 负载均衡

-
重试

-
长尾请求
- BackupRequest
-
注册中间件

2. 易用性
-
开箱即用
- 合理的默认参数选项、丰富的文档
-
周边工具
- 生成代码工具、脚手架工具
-
简单易用的命令行工具
- 生成服务代码脚手架
- 支持
protobuf和thrift - 内置功能丰富的选项
- 支持自定义的生成代码插件
3. 扩展性
- Middleware:middleware 会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等
- Option:作为初始化参数
- 核心层是支持扩展的:编解码、协议、网络传输层
- 代码生成工具也支持插件扩展
4. 观测性
- 三件套:Log、Metric 和 Tracing
-
内置观测性服务,用于观察框架内部状态
- 当前环境变量
- 配置参数
- 缓存信息
- 内置
pprof服务用于排查问题
5. 高性能
-
场景
- 单机多机
- 单连接多连接
- 单/多client 单/多server
- 不同大小的请求包
- 不同的请求类型:例如
pingpong、streaming等
-
目标
- 高吞吐
- 低延迟
-
手段
- 连接池和多路复用:复用连接,减少频繁建联带来的开销
- 高性能编解码协议:
Thrift、Protobuf、Flatbuffer和Cap'n Proto等
- 高性能网络库:
Netpoll和Netty等
6. 小结
- 框架通过中间件来注入各种服务治理策略,保障服务的稳定性
- 通过提供合理的默认配置和方便的命令行工具可以提升框架的易用性
- 框架应当提供丰富的扩展点,例如核心的传输层和协议层
- 观测性除了传统的Log、Metric 和Tracing之外,内置状态暴露服务也很有必要
- 性能可以从多个层面去优化,例如选择高性能的编解码协议和网络库
04 字节内部 Kitex 实践分享
4.1 整体架构

-
Kitex Core
- 核心组件
-
Kite Byted
- 与公司内部基础设施集成
-
Kitex Tool
- 代码生成工具
4.2 背景
自研网络库 Netpoll,背景:
-
原生库无法感知连接状态
在使用连接池时,池中存在失效连接,影响连接池的使用。
-
原生库存在
goroutine暴涨的风险一个连接一个
goroutine的模式,由于连接利用率低下,存在大量的goroutine占用调度开销,影响性能。
4.3 Netpoll
-
解决无法感知连接状态问题
- 引入
epoll注定监听机制,感知连接状态
- 引入
-
解决
goroutine暴涨的风险- 建立
goroutine池,复用goroutine
- 建立
-
提升性能
- 引入
Nocopy Buffer,向上层提供NoCopy的调用接口,编解码层面零拷贝
- 引入
4.4 扩展性设计
- 扩展性:支持多协议,也支持灵活的自定义协议扩展
4.5 性能优化
-
性能优化,参考 字节跳动 Go RPC 框架 KiteX 性能优化实践
-
网络库优化
-
调度优化
epoll_wait在调度上的控制gopool重用goroutine降低同时运行协程数
-
LinkBuffer减少内存拷贝,从而减少 GC- 读写并行无锁,支持
nocopy地流式读写 - 高效扩缩容
Nocopy Buffer池化,减少GC
- 读写并行无锁,支持
-
Pool
- 引入内存池和对象池
-
-
编解码优化
-
Codegen:
- 预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
- Inline减少函数调用次数和避免不必要的反射操作等
- 自研了Go语言实现的Thrift IDL解析和代码生成器,支持完善的Thrift IDL语法和语义检查,并支持了插件机制 -
Thriftgo
-
JIT:
- 使用JIT编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担
- 基于JIT编译技术的高性能动态Thrift编解码器 -
Frugal - 无生产代码,将编译过程移到了程序的加载(或首次解析)阶段,可以一次性编译生成对应的 codec 并高效执行
-
-
4.6 合并部署
-
微服务过微,引入的额外的传输和序列化开销越来越大
-
将亲和性强的服务实例尽可能调度到同一个物理机,远程RPC调用优化为本地IPC调用
- 中心化的部署调度和流量控制
- 基于共享内存的通信协议
- 定制化的服务发现和连接池实现
- 定制化的服务启动和监听逻辑
4.7 小结
- 介绍了
Kitex的整体架构 - 介绍了自研网络库
Netpoll的背景和优势 - 从扩展性和性能优化两个方面分享了相关实践
- 介绍了内部正在尝试落地的新的微服务形态:合并部署
05 课程总结
- 从本地函数调用引出RPC的基本概念
- 重点讲解了RPC框架的核心的三层,编解码层、协议层和网络传输层
- 围绕RPC框架的核心指标,例如稳定性、可扩展性和高性能等,展开讲解相关的知识
- 分享了字节跳动高性能RPC框架
Kitex的相关实践