深入浅出RPC框架
框架
gRPC
ByteMesh 相传是下一代微服务框架
01. 基本概念
1.1 本地函数调用
从这个简单的例子开始
- 将a和b的值压栈
- 通过函数指针找到 calculate 函数,进入函数取出栈中的值2和3,将其赋予 x 和 y
- 计算 x * y ,并将结果存在 z
- 将z的值压栈,然后从 calculate返回
- 从栈中取出z返回值,并赋值给result
1.2 远程函数调用 (RPC - Remote Procedure Calls)
RPC需要解决的问题
-
函数映射
怎么调用付款函数,本地调用是函数指针。每个函数都有自己uid
-
数据转换成字节流
把参数告诉远程调用,从字节流中取
-
网络传输
1.3 RPC 概念模型
1984年 Nelson发表了论文《Implementing Remote Procedure Calls》,其中提出了RPC的过程由5个模型组成:
User、User-Stub、RPC-Runtime、Server-Stub、Server
1.4 一次RPC的完整过程
相比本地函数调用,远程调用的话我们不知道对方有哪些方法,以及参数长什么样,所以需要有一种方式来描述或者说声明我有嗯些方法,方法的参数都是什么样子的,这样的活大家就能按照这个来调用,这个描述文件就是IDL文件。
刚才我们提到服务双方是通过约定的规范进行远程调用,双方都依赖同一份IDL文件,需要通过工具来生成对应的生成文件,具体调用的时候用户代码需要依赖生成代码,所以可以把用户代码和生成代码看做一个整体。
编码只是解决了跨语言的数据交换格式,但是如何通讯呢?需要制定通讯协议,以及数据如何传输?我的网络模型如何呢?那就是这里的transfer要做的事情。
1.5 RPC的好处
-
单一职责,有利于分工协作和运维开发
可以独立出来每个团队。
-
可扩展性强,资源使用率更优
对于压力大的场景,可以提前进行动态扩容。
-
故障隔离,服务的整体可靠性更高
某一个服务发生故障时,不会引起整个服务的故障
抖音架构
1.6 RPC带来的问题
-
服务宕机,对方应该如何处理?
当被调用方出现故障时 该怎么办
-
在调用过程中发生网络异常,如何保证消息的可达性?
RPC调用往往是垮网络的,怎么去保证消息的可达性
-
请求量突增导致服务无法及时处理,有哪些应对措施?
对于压力非常大的时候,节点无法处理了
01. 小结
- 本地函数调用和RPC调用的区别:函数映射、数据转成字节流、网络传输
- RPC的概念模型:User、User-Stub、RPC-Runtime、Server-Stub、Server
- 一次PRC的完整过程,并讲解了RPC的基本概念定义
- RPC带来好处的同时也带来了不少新的问题,将由RPC框架来解决
02. 分层设计
编解码层 | 协议层 | 网络通信层
自顶向下的介绍各个分层
2.2 编解码层
2.3 编解码层 - 生成代码
2.4 编解码层 - 数据格式
==√语言特定的格式== 许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java 有java.io.Serializable
非常方便,可以用很少的内存代码实现对象的存储和恢复。但与语言绑定死了,不具有兼容性
==√文本格式== JSON、XML、CSV 等文本格式,具有人类可读性
JSON没法区分整数与浮点数,没有强行的模型约束
==√二进制编码== 具备跨语言和高性能等优点,常见有Thrift的 BinaryProtocol,Protobuf 等
跨语言、高性能
2.5 编解码层 - 二进制编码
TLV编码
- Tag:标签,可以理解为类型
- Length:长度
- Value:值,Value也可以是个TLV结构(嵌套)
这里我们可以看到他的第一个byte是类型,主要用来表示是sting还是int还是list等等。
这里不写key的字符串了,比如上面的usenName , favoriteNumber等,取而代之的是一个feld tag的东西,
这个会设置成1,2,3和上面的schema中key字符串前面的数字,也就是用这里来取代了具体的key值,从而减小的总体的大小,这里打包后压缩到59个字节
2.6 编解码层 - 选型
对编码格式有了一些了解,如果要选一种,该怎么选型呢。
==√兼容性== 支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度 ==√通用性== 支持跨平台、跨语言 (流行程度-学习成本) ==√性能== 从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长
2.7 协议层
2.8 协议层 - 概念
==√特殊结束符== 一个特殊字符作为每个协议单元结束的标示
==√变长协议== 以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度
协议是双方确定的交流语义,比如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。
这个协议很简单,首先发送一个4字节的消息总长底,然后再发送1字节的字符集charset长度,接下来就是消息的payload,字符集名称和字符串正文。 特殊结束符:过于简单,对于一个协议单元必须要全部读入才能够进行处理,除此之外必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱。
HTTP协议头就是以回车(CR)加换行(LF)符号序列结尾。
变长协议:一般都是自定义协议,有header和payload组成,会以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度,使用比较广泛。
2.9 协议层 - 协议构造
有点小复杂,慢慢看
2.10 协议层 - 协议解析
在框架内,先从内存中读写MagicNumber,就知道是什么类型的协议,在读取编解码方式,然后解得,Payload
2.11 网络通信层
2.12 网络通信层 - Socket API
socket api 介于应用层和传输层之间
socket函数创建一个套接字, bind将一个套接字绑定到一个地址上。listen监听进来的连接,放入一个backlog队列里面,这里先简单的描述:指定挂担的连接队列引的长度,当客户端连接的时候,
服务器可能正在处理其他逻缅而未调用accep接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,baclog则指定这个队列的长度,accept函数从队列中取出连接制求并接收它,然后这个连接就从挂起队列移除。
如果队列末满,客户端调用comnet马上成功,如果满了可能会阻塞等待队列末满(实际上在linux中测试并不是这样的结果,这个后面再专门来研究)。
Linux的backlog默认是128,通常情况下,我们也指定为128即可。
connect客户端向服务器发超连接,accept接收一个连接清求,如果没有连接则会一直阳塞直到有连接进来。得到客户端的fd之后,就可以调用read、wite函数和客户端通汛。读写方式和其他I/O类似
read 从fd读数据,socket默认是阻塞模式的,如果对方没有写数据,read会一直阻塞着: write写fd写数据,socket默认是阻塞模式的,如果对方没有写数据,write会一直阻塞着:
socket关闭套接字,当另一端socket关闭后,这一端读写的情况: 尝试去读会得到—个EOF,并返回0。 尝试去写会触发一个SIGPIPE信号,并返回-1和errno=EPIPE,SIGPIPE的默认行为是终止程序,所以通常我们应该忽略这个信号,避免程序终止。如果这—端不去读写,我们可能没有办法知道对端的socket关闭了。
2.13 网络通信层 - 网络库
==√提供易用API== 封装底层 Socket API连接管理和事件分发
==√功能== 协议支持:tcp、udp和uds 等优雅退出、异常处理等
==√性能== 应用层buffer 减少copy高性能定时器、对象池等
02. 小结
-
RPC框架主要核心有三层:编解码层、协议层和网络通信层
-
二进制编解码的实现原理和选型要点
-
协议的一般构造,以及框架协议解析的基本流程
-
Socket API的调用流程,以及选型网络库时要考察的核心指标
03. 关键指标
稳定性
保障策略
√熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
√限流:保护被调用方,防止大流量把服务压垮
√超时控制:避免浪费资源在不可用节点上
下游主动停掉一些不太重要的业务。
从某种程度上讲超时、限流和熔断也是—种服务降级的手段
请求成功率
负载均衡
重试
防止重试风暴: 限制单点重试和限制链路重试
长尾请求
长尾请求一般是指明显高于均值的那部分占比较小的请求,业界关于延迟有一个常用的P99标准,P99单个请求响应耗时从小到大排列,顺序处于99%位置的值助为P99值,那后面这1%就可以认为是长尾请求。
在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动,GC,系统调度。
Resp1 在t3(p99)的时间内都没有返回,所以另发一个重试请求 Req2
如此减少长尾请求的延时。
注册中间件
Kitex Client和Server的创建接口均采用Option模式,提供了极大的灵活性,很方便就能注入这些稳定性策略
易用性
开箱即用 合理的==默认参数选项==、丰富的文档 》 开箱即用
周边工具 生成代码工具、脚手架工具
3.6扩展性
中间件处理
一次请求发起首先会经过治理层面,治理相关的逻辑被封装在middleware中,这些middleware会被构造成一个有序调用链逐个执行,比如服务发现、路由、负截的摄、超时控制等, mw执行后就会进入到remote模块,完成与远端的通信
- Middleware
- Option
- 编解码层
- 协议层
- 网络传输层
- 代码生成工具插件扩展
3.7 观测性
Log、Metric、Tracing
日志 监控 链路跟踪
内置观测服务
3.8 高性能
目标: 高性能、低延迟
场景:
- 单机多机
- 单连接多连接
- 单/多 client/server
- 不同大小的请求包
- 不同请求类型:pingpong streaming
手段:
- 连接池
- 多路复用
- 高性能编解码协议
- 高性能网络库
03. 小结
- 框架通过中间件来注入各种服务治理策略,保障服务的稳定性
- 通过提供合理的默认配置和方便的命令行工具可以提升框架的易用性
- 框架应当提供丰富的扩展点,例如核心的传输层和协议层
- 观测性除了传统的Log、Metric和Tracing之外,内置状态暴露服务也很有必要
- 性能可以从多个层面去优化,例如选择高性能的编解码协议和网络库
04. 企业实践
整体架构 | 自研网络库 | 扩展性设计 | 性能优化 | 合并部署
4.1 整体架构 - Kitex
Kitex Core 核心组件 Kitex Byted 与公司内部基础设施集成 Kitex Tool 代码生成工具
4.2 自研网络库 - 背景
√原生库无法感知连接状态 在使用连接池时,池中存在失效连接,影响连接池的复用。
√原生库存在goroutine暴涨的风险 一个连接一个goroutine的模式,由于连接利用率低下,存在大量 goroutine占用调度开销,影响性能。
4.3 自研网络库 - Netpoll
√解决无法感知连接状态问题 引入epoll主动监听机制,感知连接状态
√解决goroutine暴涨的风险 建立goroutine池,复用goroutine
√提升性能 引入 Nocopy Buffer,向上层提供NoCopy 的调用接口,编解码层面零拷贝
4.4 扩展性设计
支持多协议,也支持灵活的自定义协议扩展
如图2 所示,框架内部不强依赖任何协议和网络模块,可以基于接口扩展,在传输层上则可以集成其他库进行扩展。 目前集成的有自研的Netpoll,基于netpoll实现的http2库,用于mesh场晶通过共享内存高效通信的shm-ipc,以后也可以增加对RDMA支持的扩展
4.5 性能优化 - 网络库优化
√调度优化 epoll_wait在调度上的控制 gopool重用goroutine降低同时运行协程数
√LinkBuffer 读写并行无锁,支持nocopy地流式读写 高效扩缩容 Nocopy Buffer池化,减少GC
√Pool池化 引入内存池和对象池,减少GC开销
4.6 性能优化 - 编解码优化
Codegen 预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝 lnline 减少函数调用次数和避免不必要的反射操作等
自研了Go语言实现的Thrift IDL解析和代码生成器,支持完善的Thrift IDL语法和语义检查,并支持了插件机制- Thriftgo
JIT
使用川T编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担基于川IT编译技术的高性能动态 Thrift 编解码器– Fruaal
4.7 合并部署
微服务过微,传输和序列化开销越来越大 将亲和性强的服务实例尽可能调度到同一个物理机,远程RPC调用优化为本地IPC调用
- √中心化的部署调度和流量控制
- √基于共享内存的通信协议
- √定制化的服务发现和连接池实现
- √定制化的服务启动和监听逻辑
04. 小结
- 介绍了Kitex的整体架构
- 介绍了自研网络库Netpoll的背景和优势
- 从扩展性和性能优化两个方面分享了相关实践
- 介绍了内部正在尝试落地的新的微服务形态:合并部署
课程总结
- 从本地函数调用引出RPC的基本概念
- 重点讲解了RPC框架的核心的三层,编解码层、协议层和网络传输层
- 围绕RPC框架的核心指标,例如稳定性、可扩展性和高性能等,展开讲解相关的知识
- 分享了字节跳动高性能RPC框架 Kitex的相关实践