基本概念
RPC调用与本地函数调用的区别与问题
func add(x, y int) int {
s := x + y
return s
}
func main() {
a, b := 1, 2
result := add(a, b)
fmt.Println(result)
return
}
以上面的代码为例,本地函数add调用的过程如下:
- 调用者将
ab的值压入栈中 - 跳转到
add函数所在地址,将ab的值出栈,赋值给xy - 计算
x + y的值,存储在z中 - 将
z的值压栈,从add返回 - 从栈中取出
z的值,赋值给result
远程函数调用过程与本地函数调用过程类似,但是被执行函数的指令是在远程服务器上。这就需要解决一些问题:
- 函数映射。需要知道要调用的是哪个函数。
- 数据转换为字节流。需要将参数传递给远程函数。
- 网络传输。高效、稳定地传输数据。
单次RPC调用的完整过程
RPC 的过程由 5 个模型组成:
- User
- User—Stub
- RPC—Runtime,一个实例存在于调用者机器上,另一个存在于被调用者机器上
- Server—Stub
- Server
当User进行远程调用时,实际上会进行一个普通的本地调用,调用User-Stub中的对应过程。User-Stub负责将目标过程的规范和参数放入一个或多个数据包中,并请求RPC-Runtime可靠地将这些数据包传输到被调用者机器。在被调用者机器上的RPC-Runtime接收这些数据包,将其传递给Server-Stub。Server-Stub解包数据包,再次进行普通的本地调用,调用服务器中的适当过程。与此同时,调用者机器上的调用过程被挂起,等待结果数据包的到来。
当服务器中的调用完成时,它返回给Server-Stub,结果被传递回调用者机器上的挂起进程。在那里,结果被解包,然后由User-Stub传递给User。RPC-Runtime负责重传、确认、数据包路由和加密。
实践中,需要涉及以下概念:
-
IDL文件IDL 通过一种中立的方式来描述接囗,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。
-
生成代码
通过编译器工具把 IDL 文件转换成语言对应的静态库。
-
编解码
从内存表示到字节序列的转换称为编码,反之为解码。
-
通信协议
规范了数据在网络中的传输内容和格式。賒必须的请求/响应数据外,通常还会包含额外的元数据。
-
网络传输
通常基于TCP/UDP协议
RPC的优点:
- 单一职责,有利于分工协作和运维开发
- 可扩展性强,资源利用率高
- 故障隔离,服务整体可用性强
RPC带来的问题
服务宕机、网络异常、请求暴增等问题。
由RPC框架来解决。
RPC 框架分层设计
编解码层
这一层将依赖 IDL 文件生成不同语言的代码,作为约束。生成代码和编解码层相互依赖,框架的编解码应当具备扩展任意编解码协议的能力。
不同数据格式的优缺点与选型考虑
-
语言特定格式
编程语言内建的将内存对象编码为字节序列的方法。如 Java 的
java.io.Serializable -
文本格式
JSON、XML、CSV等人类可读的文本格式
-
二进制编码
具备跨语言和高性能等优点,常见有 Thrift 的 BinaryProtocol,Protobuf 等
TLV 编码就是一种二进制编码。三个字母分别代表:
- Tag:标签,可以理解为类型
- Length:长度
- Value:值,也可以是 TLV 结构
选型:
-
兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
-
通用性:跨平台、跨语言
-
性能:从空间和时间考虑,即考虑编码后数据大小和编码所需时间
协议层
概念
-
特殊结束符
一个特殊字符作为每个协议单元结束的标识。
-
变长协议
由定长加不定长部分组成,定长部分描述不定长部分的长度。
Thrift协议的THeader
0 1 2 3 4 5 6 7 8 9 a b c d e f 0 1 2 3 4 5 6 7 8 9 a b c d e f
+----------------------------------------------------------------+
| 0| LENGTH |
+----------------------------------------------------------------+
| 0| HEADER MAGIC | FLAGS |
+----------------------------------------------------------------+
| SEQUENCE NUMBER |
+----------------------------------------------------------------+
| 0| Header Size(/32) | ...
+---------------------------------
Header is of variable size:
(and starts at offset 14)
+----------------------------------------------------------------+
| PROTOCOL ID (varint) | NUM TRANSFORMS (varint) |
+----------------------------------------------------------------+
| TRANSFORM 0 ID (varint) | TRANSFORM 0 DATA ...
+----------------------------------------------------------------+
| ... ... |
+----------------------------------------------------------------+
| INFO 0 ID (varint) | INFO 0 DATA ...
+----------------------------------------------------------------+
| ... ... |
+----------------------------------------------------------------+
| |
| PAYLOAD |
| |
+----------------------------------------------------------------+
LENGTH:占据32位,表示数据包大小(不包括长度字段本身)。HEADER MAGIC:占据16位,用来识别版本信息,协议解析时快速校验。FLAGS:占据16位,用于指示一些标志信息。SEQUENCE NUMBER:占据32位,表示序列号。可用于多路复用,单连接内递增。Header Size:占据16位,表示头部的大小,不包括HEADER MAGICFLAGSSEQUENCE NUMBER以及其本身,以4字节为单位计算。头部的大小是可变的,并且从第14个字节开始,到 PAYLOAD 前结束。PROTOCOL ID:协议ID。有 Binary 和 Compact 两种。NUM TRANSFORMS:变换(transform)数量。TRANSFORM ID:对数据进行处理或压缩的标识。ZLIB_TRANSFORM(0x01):使用 zlib 进行数据压缩和解压缩,不需要在Header中指定数据的大小。HMAC_TRANSFORM(0x02):表示使用 HMAC 进行数据的校验。这需要在Header中追加一个字节来指定校验数据的大小,并且校验数据会附加在数据包的末尾。SNAPPY_TRANSFORM(0x03):使用 snappy 进行数据压缩和解压缩,同样不需要在Header中指定数据的大小。
INFO ID:也是变长整数类型。这些信息可以包含时间戳、调试追踪等元数据。INFO_KEYVALUE(0x01):这个Info ID 表示Header中包含了一些键值对信息。这些信息可以是以变长字符串(varstring)表示的键和值,其中键和值的长度都是以 varint16 表示的,而且在任何情况下都不应该被修改。
PAYLOAD:有效载荷
变换ID使用变长整数编码。每个变换的数据根据代码中的变换ID进行定义,头部中没有给出大小信息。如果客户端指定了一个服务器不认识的变换ID,服务器必须返回错误,因为服务器不知道如何进行数据变换。
相反地,信息(info)头部中的数据可以被忽略。这些信息可能包括时间戳、调试追踪等。使用头部大小,你可以跳过这些数据,并且在不知道信息ID的情况下安全地读取有效载荷。
信息应该按照从最旧支持到最新支持的顺序排列,这样如果我们读取了一个不支持的信息ID,那么剩下的信息ID也都不会被支持,我们可以安全地跳过直接读取有效载荷。
信息ID和变换ID应该共享相同的ID空间。
头部会被填充到下一个4字节边界,使用0x00字节进行填充。
最大帧大小是0x3FFFFFFF,略小于HTTP_MAGIC。这使我们可以区分不同(较旧的)传输方式。
协议解析过程
- 读取
MagicNumber,获取协议类型 - 读取
PayloadCodec,获取解码方式 - 解码
Payload
网络通信层
Socket API
阻塞IO、非阻塞IO和IO多路复用的比较与解释
-
阻塞 IO 下,耗费一个线程去阻塞在
read(fd)去等待用足够多的数据可读并返回。 -
非阻塞 IO 下,不停对所有 fds 轮询
read(fd),如果读取到 n <= 0 则下一个循环继续轮询。
第一种方式浪费线程(会占用内存和上下文切换开销), 第二种方式浪费 CPU 做大量无效工作。 而基于 IO 多路复用系统调用实现的 Poll 的意义在于将可读/可写状态通知和实际文件操作分开,并支持多个文件描述符通过一个系统调用监听以提升性能。
网络库的核心功能与作用
网络库的核心功能就是去同时监听大量的文件描述符的状态变化(通过操作系统调用),并对于不同状态变更,高效,安全地进行对应的文件操作。
- 提供易用的API
- 封装底层Socket API
- 连接管理和事件分发
- 功能
- 协议支持:TCP、UDP和UDS等
- 优雅退出、异常处理等
- 性能
- 应用层减少拷贝
- 高性能定时器、对象池等
RPC 框架核心指标
稳定性
保障策略
- 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
- 限流:保护被调用方,防止大流量把服务压垮
- 超时控制:避免浪费资源在不可用节点上
从某种程度上讲超时、限流和熔断也是一种服务降级的手段。
请求成功率
- 负载均衡
尽量使得请求平均分配给同一个服务不同的服务器上,保证某一个节点压力不会太大。
- 重试
请求调用失败时多试几次。重试能够提高服务稳定性,但是重试会加大直接下游的负载,还会存在链路放大的效应。
长尾请求
长尾请求一般是指明显高于均值的那部分占比较小的请求。关于请求的延迟有一个常用的 P99 标准,也就是 99% 的请求延迟要满足在一定耗时以内,1% 的请求会大于这个耗时,而这 1% 就可以认为是长尾请求。
例如有 100 个请求,其中 99 个请求的响应时间都是 1 秒,有一个请求的响应时间是 60 秒,那么这个响应时间为 60 秒的请求就是长尾请求。
如下图,一般情况下,重试是在获取超时响应后再重新发送第二次请求,总耗时是t1 + t2。
假设这样的场景,服务 A 发出请求 Req1 后可能等待 2 秒,但大多数(99%)情况下,请求在几百毫秒内就响应了。那么这次请求很有可能是个长尾请求或者最终超时,我们应该考虑提前重试。
- Backup Requests
我们可以使用 Backup Requests 的方法。如上图,先设定一个时间阈值 t3。这个值一般是 99% 请求延时的最大值。若 Req1 发出后未在 t3 时间内响应,我们直接提前重试,发送请求 Req2,当任意一个请求响应后,直接结束其他请求。这样总体的请求时延就有可能控制在 t4 之内。
实际:注册中间件
真正实践中,我们注册中间件来实现以上功能。
WithTimeout(...)
WithRateLimiter(...)
WithRoadbalancer(...)
WithRetry(...)
WithBackupRequest(...)
WithCircuitBreaker(...)
易用性
- 开箱即用
- 合理的默认参数选项、丰富的文档
- 周边工具
- 生成代码工具、脚手架工具
扩展性
- Middleware:中间件会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等
- Option:作为初始化参数
- 核心层是支持扩展的:编解码、协议、网络传输层
- 代码生成工具也支持插件扩展
观测性
- 三件套:Log(日志)、Metric(监控) 和 Tracing(跟踪)
- 内置观测性服务,用于观察框架内部状态
- 当前环境变量
- 配置参数
- 缓存信息
- 内置 pprof 服务用于排查问题
高性能
场景:
- 单机/多机
- 单连接/多连接
- (单/多)(客户端/服务器)
- 不同大小请求
- 不同类型请求
目标:
- 高吞吐
- 低延迟
手段:
- 连接池和多路复用:复用连接,减少频繁建联带来的开销
- 高性能编解码协议:Thrift、Protobuf、Flatbuffer 和 Cap'n Proto 等
- 高性能网络库:Netpoll 和 Netty 等
字节内部 Kitex 实践分享
Kitex
- Kitex Core:核心组件
- Kitex Tool:代码生成工具
Netpoll
背景:
-
原生库无法感知连接状态
在使用连接池时,池中存在失效连接,影响连接池的复用。
-
原生库存在 goroutine 暴涨的风险
一个连接一个 goroutine 的模式,由于连接利用率低下,存在大量 goroutine 占用调度开销,影响性能。
解决方案:
-
解决无法感知连接状态问题
引入 epoll 主动监听机制,感知连接状态
-
解决 goroutine 暴涨的风险
建立 goroutine 池,复用 goroutine
-
提升性能
引入 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝
扩展性
支持多协议,也支持灵活的自定义协议扩展。
| 交互方式 | Ping-Pong / Streaming / Oneway |
| 编解码 | Thrift / Protobuf |
| 应用层协议 | TTHeader / HTTP2 / - |
| 传输层协议 | TCP / UDP / RDMA |
性能优化,参考 字节跳动 Go RPC 框架 KiteX 性能优化实践
- 网络优化
- 调度优化
- epoll-wait 在调度上的控制
- gopool 重用 goroutine 降低同时运行协程数
- LinkBuffer 减少内存拷贝,从而减少 GC
- 读写并行无锁,支持 nocopy 地流式读写
- 高效扩缩容
- Nocopy Buffer 池化,减少 GC
- 引入内存池和对象池
- 引入内存池和对象池,减少 GC 开销
- 调度优化
- 编解码优化
- Codegen:预计算提前分配内存,inline,SIMD等
- 预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
- inline 减少函数调用次数和避免不必要的反射操作等
- 自研了 Go 语言实现的 Thrift IDL 解析和代码生成器,支持完善的 Thrift IDL 语法和语义检查,并支持了插件机制 —— Thriftgo
- JIT:无生产代码,将编译过程移到了程序的加载(或首次解析)阶段,可以一次性编译生成对应的 codec 并高效执行
- 使用 JIT 编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担
- 基于 JIT 编译技术的高性能动态 Thrift 编解码器 —— Frugal
- Codegen:预计算提前分配内存,inline,SIMD等
合并部署
- 微服务过微,引入的额外的传输和序列化开销越来越大
- 将强依赖的服务实例尽可能调度到同一个物理机,RPC优化为本地IPC调用
- 中心化的部署调度和流量控制
- 基于共享内存的通信协议
- 定制化的服务发现和连接池实现
- 定制化的服务启动和监听逻辑
课后
- 不同RPC框架的优劣比较
- 基于核心指标,对Kitex的功能进行欠缺或加强的讨论
- 理解Service Mesh的新趋势及其与RPC框架的关系
- 探索业界对RPC框架的新趋势和概念
- 比较字节内部Netpoll与其他高性能网络库的优劣
- 分析Flatbuffer、Cap'n Proto等高性能编解码协议的原因