深入浅出RPC框架
远程函数调用(RPC - Remote Procedure Calls)
RPC需要解决的问题:
1、函数映射
2、数据转换成字节流
3、网络传输
RPC概念模型
一次RPC的完整过程
IDL(Interface description language)文件
IDL通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以互相通信
生成代码
通过编译器工具把IDL文件转换成语言对应的静态库
编解码
从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化和反序列化
通信协议
规范了数据在网络中的传输内容和格式。除必须的请求/响应数据外,通常还会包含额外的元数据
网络传输
通常基于成熟的网络库走TCP/UDP传输
RPC的好处
1、单一职责,有利于分工协作和运维开发
2、可扩展性强,资源使用率更优
3、故障隔离,服务的整体可靠性更高
RPC带来的问题
1、服务宕机,对方应该如何处理?
2、在调用过程中发生网络异常,如何保证消息的可达性?
3、请求量激增导致服务无法及时处理,有哪些应对措施?
编解码层
数据格式
- 语言特定的格式:许多变成语言都内建了将内存对象编码为字节序列的支持,例如Java有java.io.Serializable
- 文本格式:JSON、XML、CSV等文本格式,具有人类可读性
- 二进制编码:具备跨语言和高性能等优点,常见有Thrift的BinaryProtocol, Protobuf等
二进制编码
TLV编码:
- Tag:标签,可以理解为类型
- Length:长度
- Value:值,Value也可以是个TLV结构!
TLV编码结构简单清晰,并且扩展性较好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。
选型
- 兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
- 通用性:支持跨平台、跨语言
- 性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长
协议层
概念
- 特殊结束符
一个特殊字符作为每个协议单元结束的标示
message body + \r\n
- 变长协议
以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度
length + message body
协议构造
协议解析
网络通信层
Sockets API
网络库
- 提供易用API:封装底层Socket API;连接管理和事件分发
- 功能:协议支持:tcp、udp和uds等;优雅退出、异常处理等
- 性能:应用层buffer减少copy;高性能定时器、对象池等
稳定性
保障策略
- 熔断:保护调用方,防止被调用的服务出现问题而影响整个链路
- 限流:保护被调用方,防止大流量把服务压垮
- 超时控制:避免浪费资源在不可用节点上
从某种程度上讲超时、限流和熔断也是一种服务降级的手段
请求成功率
-
负载均衡
-
重试
注意:重试有放大故障的风险。首先,重试会加大直接下游的负载。如图,假设A服务调用B服务,当B高负载时很可能调用不成功,这时A调用失败重试B,B服务的被调用量快速增大,导致B的负载继续升高,甚至直接打挂。
防止重试风暴,限制单点重试和限制链路重试。
长尾请求
长尾请求一般是指明显高于均值的那部分占比较小的请求。业界关于延迟有一个常用的P99标准,P99单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99值,那后面这1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动,GC,系统调度。
我们预先设定一个阈值t3(比超时时间小,通常建议是RPC请求延时的pct99),当Req1发出去后超过t3时间都没有返回,那我们直接发起重试请求Req2,这样相当于同时有两个请求运行。然后等待请求返回,只要Resp1或者Resp2任意一个返回成功的结果,就可以立即结束这次请求,这样整体的耗时就是t4,它表示从第一个请求发出到第一个成功结果返回之间的时间,相比于等待超时后再发出请求,这种机制能大大减少整体延时。
注册中间件
Kitex Client和Server的创建接口均采用Option模式,提供了极大的灵活性,很方便就能注入这些稳定性策略
易用性
- 开箱即用:合理的默认参数选项、丰富的文档
- 周边工具:生成代码工具、脚手架工具
简单易用的命令行工具:
- 生成服务代码脚手架
- 支持protobuf和thrift
- 内置功能丰富的选项
- 支持自定义的生成代码插件
扩展性
- Middleware
- Option
- 编解码层
- 协议层
- 网络运输层
- 代码生成工具插件扩展
观测性
- Log、Metric、Tracing
- 内置观测性服务
高性能
目标:
- 高吞吐
- 低延迟
场景:
- 单机多机
- 单连接多连接
- 单/多client 单/多server
- 不同大小的请求包
- 不同请求类型:例如pingpong、streaming等
手段:
- 连接池
- 多路复用
- 高性能编解码协议
- 高性能网络库
整体架构 - Kitex
- Kitex Core:核心组件
- Kitex Byted:与公司内部基础设施集成
- Kitex Tool:代码生成工具
自研网络库 - 背景
- 原生库无法感知连接状态:在使用连接池时,池中存在失效连接,影响连接池的复用
- 原生库存在goroutine暴涨的风险:一个连接一个goroutine的模式,由于连接利用率低下,存在大量goroutine占用调度开销,影响性能。
自研网络库 - Netpoll
- 解决无法感知连接状态问题:引入epoll主动监听机制,感知连接状态
- 解决goroutine暴涨的风险:建立goroutine池,复用goroutine
- 提升性能:引入Nocopy Buffer,向上层提供NoCopy的调用接口,编解码层面零拷贝
扩展性设计
支持多协议,也支持灵活的自定义协议扩展
性能优化 - 网络库优化
- 调度优化:
epoll_wait在调度上的控制
gopool重用goroutine降低同时运行协程数
- LinkBuffer:
读写并行无锁,支持nocopy地流式读写
高效扩缩容
Nocopy Buffer池化,减少GC
- Pool
引入内存池和对象池,减少GC开销
性能优化 - 编解码优化
- Codegen:
预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
inline减少函数调用次数和避免不必要的反射操作等
自研了Go语言实现的Thrift IDL解析和代码生成器,支持完善的Thrift IDL语法和语义检查,并支持了插件机制 - Thriftgo
- JIT:
使用JIT编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担
基于JIT编译技术的高性能动态Thrift编解码器 - Frugal
合并部署
微服务过微,传输和序列化开销越来越大
将亲和性强的服务实例尽可能调度到同一个物理机,远程RPC调用优化为本地IPC调用!
- 中心化的部署调度和流量控制
- 基于共享内存的通信协议
- 定制化的服务发现和连接池实现
- 定制化的服务启动和监听逻辑