RPC原理与实现
1. 基本概念
1.1 本地函数调用
- 当前状态压栈
- 函数计算,将结果存在栈中(结果参数少的情况存寄存器)
- 返回状态,回传结果值
1.2 远程函数调用(RPC-Remote Procedure Calls)
需要解决的问题:
- 函数映射(通过函数id确定执行哪个函数)
- 数据转换成字节流
- 高效稳定的网络传输
1.3 RPC概念模型
调用端:用户本地调用User-stub将数据打包,交给RPCRuntime。
RPCRuntime将数据传送给被调用端的RPCRuntime
被调用端:将数据传给Server-stub解压数据并传给服务器。服务器处理数据后进行相反的过程将数据传给用户。
1.4 一次RPC的完整过程
IDL文件:相比于本地函数调用,远程调用时,两台主机必须知道有哪些方法以及参数形式。IDL通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。
生成代码:通过编译器工具把IDL文件转换成语言对应的静态库。
编解码:从内存中表示到字节序列的转换成为编码,反之为解码,也常叫做序列化和反序列化。
通信协议:规范了数据在网络中的传输内容和格式。除必须的请求/响应数据外,通常还会包含额外的元数据。
网络传输:通常基于成熟的网络库走TCP/UDP传输。
1.5 RPC的好处
- 单一职责,有利于分工协作和运维开发。开发(使用的语言)、部署以及运维(上线时间)都是独立的。
- 可扩展性强,能够扩缩资源,复用资源,使资源使用率更优。
- 故障隔离,服务整体可靠性更高。
1.6 RPC框架需要解决什么问题
- 服务宕机,对方应如何处理?
- 调用过程中发生网络异常,如何保证消息的可达性?
- 请求突增导致服务无法及时处理,有哪些应对措施?
2. 分层设计
2.1 编解码层
图中的紫色和绿色部分为编解码层。包括生成代码层和框架编解码层。
生成代码层可以看作编解码层的一部分,因为内部包含了编解码的逻辑。用户和服务器依赖同一份IDL生成codegen(可以是不同语言的)。
IDL的数据格式如何选择?
- 语言特定的格式:许多编程语言都内建了将内存对象编码为字节序列的支持,例如
Java
有Java.io.Serializable
。好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。此外还有安全性和兼容性的问题。 - 文本格式:JSON、XML、CSV等文本格式具有人类可读性。但数字编码多有歧义之处,如XML和CSV不能区分数字和字符串;JSON不能区分整数和浮点数,且不能指定精度。处理大数据时存在问题。没有强制模型约束使得实际操作中往往以文档方式进行约定,可能会给调试带来不便。由于JSON在一些语言中的序列化和反序列化需要采用反射机制,所以性能也比较差。
- 二进制编码:有TLV、Varint等多种编码方式,具备跨语言和高性能等优点,常见的有Thrift的BinaryProtocol,Protobuf等。
TLV编码
- Tag:标签,记录变量类型。
- Length:字节长度。
- Value:值,也可以是个TLV结构,形成嵌套。
上图schema的某一实例的编码如下图所示,其中tag部分记录的是schema中key字符串前面的数字。这样解码时就可以根据这个数字找到对应的属性名称(节约了空间)。第二个字段类型里包含长度64,因此编码时无需再记录。
TLV编码的缺点是tag和length有额外开销。属性全为基础类型时这个开销占全部编码的比例更大。
编码方式选择
- 兼容性:移动互联网时代业务变更快,需求不断涌现且老的系统仍需要维护。需要可扩展性,支持自动增加新的字段而不影响老的服务,这将提高系统的灵活度。
- 通用性:技术层面通用,支持跨平台、跨语言。流行程度通用,更低的学习成本,流行也说明方式更加成熟。
- 性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码消耗时长。空间方面,序列化需要在原有的数据上加入描述字段用于解析,如果序列化引入的额外空间开销过高,可能导致过大的网格,磁盘等方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的额外空间开销往往意味着高昂的成本。时间方面,复杂的序列化协议会导致较长的解析时间,可能使序列化和反序列化成为整个系统的瓶颈。
2.2 协议层
我们并不是直接将报文编码就发送给对方,而是要添加一些元数据作为通讯协议。
- 特殊结束符:http协议头就是以
\r\n
结尾的。必须要全部读入在能进行处理(否则不知道长度)。需要防止用户使用结束符或使用转义符。 - 变长协议:由header和payload组成,header记录消息长度,payload记录消息。此方法使用更加广泛。
协议构造:
- LENGTH:数据包大小,不包含自身
- HEADER MAGIC:标识版本信息,协议解析时快速校验
- FLAGS:预留字段,默认0x0000
- SEQUENCE NUMBER:表示数据包的seqID,可用于多路复用,单连接内递增
- HEADER SIZE:头部长度(从第14个字节开始计算一直到PAYLOAD前(最大64K)
- PROTOCOL ID:编解码方式,有Binary和Compact两种
- TRANSFORM ID:压缩方式,如zlib和snappy
- INFO ID:传递一些定制的meta信息
- PATLOAD:消息体
协议解析:
找到版本号,读取编解码方式,解码。
2.3 网络通信层
Sockets API
操作系统在应用层和传输层之间提供了Sockets接口。使用socket必须绑定ip和端口。
服务器会监听连接。由于服务器不止负责连接,而且要负责处理其它事物,于是会先将连接放入消息队列。队列大小(back log)一般设为128。
如果队列未满,客户端直接将状态设为已连接,若队列已满,则客户端阻塞,等待队列更新。
客户端与服务器连接完成后将进行数据读写,直到一方关闭连接。
网络库
衡量指标:
- 提供易用API:封装底层Socket API。连接管理和事件分发
- 功能:支持tcp、udp和uds等协议。能够优雅的退出、进行异常处理等
- 性能:应用层buffer减少copy,高性能定时器、对象池等
3.关键指标
3.1 稳定性
保障策略:
- 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
- 限流:保护被调用方,防止大量流量把服务压垮
- 超时控制:避免浪费资源在不可用节点上
三种保障策略都算是降级手段。
请求成功率
请求成功率是衡量服务稳定性的重要标准。可以通过负载均衡和重试(多次重试中一次成功即为成功,全部失败才算失败)来提高。
重试方式会有放大故障的风险。假设服务A需要调用服务B,有两台物理机可以提供服务B。其中B1突然不可用。A请求B1失败以后会向B2重试,导致B2负载突然增加,B2也将存在故障的风险。
长尾请求
长尾请求的一般标准为用时超过99%其它请求的请求。长尾请求总是会存在,造成长尾请求的原因包括网络抖动、GC、系统调度等。
backup request(备份请求):我们先预设一个阈值t3(比超时时间小,通常建议是RPC请求实验的pct99),当req1发出后超过t3没有返回,我们就直接发起重试请求req2。此时相当于有两个请求同时运行,任何一个成功返回后就可以立即结束这次请求。这样整体耗时就是t4,它表示从第一个请求出发到第一个结果返回的时间。相比与超时后再请求,大大减少了时延。
注册中间件
框架通过注册中间件的方式使用上述功能。用户或服务器在创建时可选是否加入上述功能,这种灵活的方式保障了整体的稳定性。
3.2 易用性
- 开箱即用:合理的默认参数选项,丰富的文档
- 周边工具:框架提供一系列工具辅助用户来更好的使用框架,如生成代码工具,脚手架工具等。
3.3 扩展性
框架提供尽可能多的扩展点,如中间件,参数选项,编解码层、协议层、网络传输层使用方式,支持代码生成工具的插件等。
3.4 观测性
让用户能够观察服务中的数据。通常使用日志、监控、链路追踪的方式。此外RPC框架一般会有内置的观测服务。
3.5 高能性
高性能指的是高吞吐、低延迟(一般更重要)。
在不同的场景下性能表现可能是不一样的:
- 单机多机
- 单连接多连接
- 单多用户、单多服务器
- 不同大小请求包
- 不同请求类型,如pingpong,streaming等
可用的手段包括:
- 连接池
- 多路复用
- 高性能编解码协议
- 高性能网络库
4.企业实践
4.1 整体架构
kitex分为三个部分:核心组件core、与字节公司内部基础设施集成的部分Byted、代码生成工具tool。
核心部分除了用户端和服务端之外,还包括注册中心(register)、服务发现(discovery)、负载均衡(loadbalance)、熔断(circuitbreak)、重试(retry)、限流(limit)等。关键的数据结构包括结点(endpoint)、rpc元数据(rpcinfo)等。remote是与远端交互的一层,transport可以和网络库交互,codec负责编解码。
代码生成工具提供命令行工具(cmd)、解析器(parser)、插件机制(plugin)、生成器(generator)、自更新(selfupdate)。
4.2 自研网络库-Netpoll
为什么要自研网络库?
- 原生库(go net)无法感知连接状态,使用连接池时,池中存在失效连接,影响连接池的复用。
- 原生库存在goroutine暴涨的风险:使用一个连接对应一个goroutine的模式,利用率低下,存在大量goroutine占用调度开销,影响性能。
Netpoll解决了什么问题:
- 解决无法感知状态连接问题:引入epoll主动监听机制,感知连接状态
- 解决goroutine池:建立goroutine池,复用goroutine
- 提升性能:引入Nocopy Buffer,向上层提供NoCopy接口,编解码层面零拷贝
4.3 扩展性设计
支持多协议,也支持自定义协议扩展
TransHandler对应之前的remote,对接了go net或netpoll等网络库。编解码codec支持thrift和protobuf。
4.4 性能优化-网络库优化
调度优化:
- epoll wait在调度上的控制
- gopool重用goroutine降低同时运行协程数
LinkBuffer:
- 读写并行无锁,支持nocopy的流式读写
- 高效扩缩容
- nocopy buffer池化,减小GC开销
pool:
- 引入内存池、对象池,减小GC开销
4.5 性能优化-编解码优化
Codegen(代码生成):
- 预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
- inline减少函数调用次数和避免不必要得反射操作等
- 自研基于go的thrift IDL解析和代码生成器,支持完善的Thrift IDL语法和语义检查,支持插件机制-Thriftgo
JIT(just in time,即时编译,即将执行时再编译): 使用JIT编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担。 基于JIT编译技术的高性能动态Thrift编解码器-frugal。
4.6 合并部署
微服务过微使传输和序列化开销越来越大。
将亲和性强的服务实例尽可能调度到同一个物理机,远程RPC调用优化为本地IPC调用。
框架改造,使用:
- 中心化的部署调度和流量控制
- 基于共享内存的通信协议
- 定制化的服务发现和连接池实现
- 定制化的服务启动和监听逻辑
引用