这是我参与「第五届青训营 」伴学笔记创作活动的第 14 天
一、本堂课重点内容
- 基本概念
- 分层设计
- 关键指标
- 企业实践
二、详细知识点介绍
基本概念
本地函数调用
func main(){
var a = 2
var b = 3
result := calculate(a, b)
fmt.Println(result)
return
}
func calculate(x, y int) int {
z := x * y
return z
}
如上述代码所示,本地函数调用的过程是(以calculate为例):
- 将a和b的值压栈
- 通过函数指针找到calculate函数,进入函数取出栈中的值2和3,将其赋予x和y
- 计算x*y,并将结果存在z
- 将z的值压栈,然后从calculate返回
- 从栈中取出z返回值,并赋值给ressult
以上步骤只是说明原理,事实上编译器经常会做出优化,对于参数和返回值少的情况会直接将其存放在寄存器中,而不需要压栈弹栈的过程,甚至不需要调用call,直接inline到调用处。
远程函数调用(RPC - Remote Procedure Calls)
以下图为例
用户在客户端(网上商城)发起了支付100元的远程调用,服务端(支付服务)收到并进行处理后将余额减100,之后返回扣款成功的信息给客户端。
而要实现这个过程,需要解决以下问题:
-
函数映射
我们怎么告诉支付服务我们要调用付款这一个函数,而不是退款或者充值呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用哪个方法,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。所以函数都有自己的一个ID,在做RPC的时候要附上这个ID,还得有个ID和函数的对照关系表,通过ID找到对应的函数并执行。
-
数据转换成字节流
客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈中,然后让函数自己去栈里读取就行了。但是在远程调用时,客户端和服务端是不同的进程,不能通过内存来传递参数。这时候就需要客户端把参数转换成一个字节流,传给服务端后,再把字节流转换成自己能读取的格式。
-
网络传输
远程调用往往用在网络上,如何保证在网络上高效稳定地传输数据?
RPC概念模型
1984年Nelson发表论文《Implementing Remote Procedure Calls》,其中提出了RPC的过程由5个模型组成:User、User-Stub、RPC-Runtime、Server-Stub、Server。
RPC中的User-stub就是一个位于客户端但代表着服务端程序的一个东西,其使得RPC就好像是本地函数调用一样,但实际上服务器端程序在远端,stub内部只是将参数打包,然后交给RPC Runtime进行处理,处理返回后再通过stub返回给客户端,看上去就像是本地调用了一样;Server-stub同理。
stub就是存根,就像现实中的票据一样,其本身不是钱,但是却能代表钱的存在
RPC中的同理,本身不是服务端程序,但是却能代表服务端程序的存在
一次RPC的完整过程
-
IDL(Interface description language)文件
相比本地函数调用,远程调用不知道对方有哪些方法以及参数长什么样,所以要有一种方式来描述或者声明有哪些方法,方法的参数是什么样,这样就能按照这个来调用,而这个描述文件就是IDL文件。IDL通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序能够相互通信。
-
生成代码
通过编译器工具把IDL文件转换成语言对应的静态库。这样就能在客户端和服务端使用了。客户端和服务端依赖于生成的代码,在其基础上进行开发。
-
编解码
从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化和反序列化。编解码是RPC的核心,也是RPC的性能瓶颈。编解码的过程中,需要考虑字节序、数据类型、数据长度等问题。
-
通信协议
其规范了数据在网络传输中的传输内容和格式。除了必须的请求/响应数据外,通常还会包含额外的元数据。
-
网络传输
通常基于成熟的网络库走TCP/UDP传输。
以上图为说明,调用方生成代码后,通过编码器将参数打包,然后通过网络传输协议传输给服务端,服务端通过解码器解码,然后调用服务端的方法,然后将结果通过编码器编码,然后通过网络传输协议传输给调用方,调用方通过解码器解码,然后得到结果。
RPC的好处
- 单一职责,有利于分工协作和运维开发
- 可扩展性强,资源使用率更优
- 故障隔离,服务的整体可靠性更高
RPC带来的问题
- 服务宕机,对方应该如何处理?
- 在调用过程中发生网络异常,如何保证消息的可达性?
- 请求量突增导致服务无法及时处理,有哪些应对措施?
以上问题都是RPC框架需要解决的问题。
分层设计
上图为Apache Thrift的分层设计。接下来自顶向下分析每一层。
编解码层
编解码层包含两部分,一部分是生成代码中的部分编解码逻辑,另一部分是框架的编解码层。
生成代码
服务端和客户端依赖于同一份IDL文件,通过生成代码工具来生产不同语言的代码。
数据格式
在编解码层,需要对各种数据格式进行处理。主要有以下几种:
-
语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java有java.io.Serializable。这种编码形式的好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复,这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据,如果这类编码存储或传输数据,那么语言就被限定死了,安全性和兼容性都会收到影响。
-
文本格式
JSON、XML、CSV等文本格式,具有人类可读性。但是其对于数字的编码多有歧义之处,比如CSV和XML就无法区分数字和字符串,而JSON虽然能区分字符串和数字,但是无法区分整数和浮点数,而且不能指定精度,处理大量数据时,这个问题就更加严重了。除此之外,文本格式也没有强制的模型约束,实际操作中往往只能够采用文档的方式来进行约定,这很有可能会给调试带来不便。此外,JSON在一些语言中的序列化和反序列化需要采用反射的方式,这会带来一定的性能损耗。
-
二进制编码
其具备跨语言和高性能等优点,常见有Thrift的BinaryProtocol、Protobuf等。其实现也有很多种,常见的有TLV编码和Varint编码。
二进制编码
TLV编码
- Tag:标识字段的类型,占1个字节
- Length:长度
- Value:值,Value也可以是一个TLV结构
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
上述结构的TLV编码如下:
TLV编码的结构简单清晰,并且扩展性较好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。
选型
对于不同编解码协议的选择,主要有三点考虑:
-
兼容性
移动互联时代,业务系统需求的更新周期变快,新的需求不断涌现,而老的系统还是需要维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提升系统的灵活度。
-
通用性
-
技术层面
序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就会大大降低。
-
流行程度
序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
-
-
性能
-
空间开销(Verbosity)
序列化需要在原有的数据上加上描述字段,来用于反序列化的解析。如果序列化的过程引入的额外开销过高,可能会导致过大的网络、磁盘方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的额外空间开销衣卫着高昂的成本。
-
时间开销(Complexity)
复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。
-
协议层
协议层主要指框架的协议层。
概念
对于一个传输的字符串,该如何完整的接收到整个字符串?主要有两种方式:
-
特殊结束符
以一个特殊字符作为每个协议单元结束的标识
-
变长协议
以定长加上不定长的部分组成,其中定长的部分需要描述不定长的内容长度。
协议构造
以上为协议的整体结构,其中:
-
LENGTH字段
32bits,表示的是除了LENGTH字段本身的长度外,后面所有字段的长度之和。
-
HEADER MAGIC字段
16bits,值为0x1000,用于标识该协议的版本,协议解析时可以快速校验。
-
FLAGS字段
16bits,预留字段,未使用。
-
SEQUENCE NUMBER字段
32bits,表示数据包的seqID,可以用多路复用,单连接内递增。
-
HEADER SIZE字段
16bits,等于头部长度字节数/4,头部长度从第14个字节开始计算,一致到PAYLOAD钱(HEADER最大长度为64K)
-
PROTOCOL ID字段
uint8,表示编解码的方式,有Binary(0)和Compact(2)两种方式。
-
NUM TRANSFORMS字段
uint8,表示后面的TRANSFORMS的个数。
-
TRANSFORM ID字段
uint8,表示压缩算法的ID,如ZLIB和SNAPPY。
-
INFO ID字段
uint8,用于传递一些定制的元信息。
-
PAYLOAD字段
消息内容。
协议解析
首先,从内存中取出数据,然后读取MagicNumber,判断使用的是什么协议,然后再读取,看使用的是什么编解码方式,然后使用对应的解码方式解码,最后得到一个完整的Payload。
网络通信层
主要指的是最后一层框架的网络通信层。
Sockets API
套接字编程中的客户端必须知道两个信息:服务器的 IP 地址,以及端口号。
socket函数创建一个套接字,bind 将一个套接字绑定到一个地址上。listen 监听进来的连接,backlog的含义有点复杂,这里先简单的描述:指定挂起的连接队列的长度,当客户端连接的时候,服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,backlog则指定这个队列的长度,accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。如果队列未满,客户端调用connect马上成功,如果满了可能会阻塞等待队列未满(实际上在Linux中测试并不是这样的结果,这个后面再专门来研究)。Linux的backlog默认是128,通常情况下,我们也指定为128即可。
connect 客户端向服务器发起连接,accept 接收一个连接请求,如果没有连接则会一直阻塞直到有连接进来。得到客户端的fd之后,就可以调用read, write函数和客户端通讯,读写方式和其他I/O类似。
read 从fd读数据,socket默认是阻塞模式的,如果对方没有写数据,read会一直阻塞着。
write 写fd写数据,socket默认是阻塞模式的,如果对方没有写数据,write会一直阻塞着。
socket 关闭套接字,当另一端socket关闭后,这一端读写的情况:
- 尝试去读会得到一个EOF,并返回0。
- 尝试去写会触发一个SIGPIPE信号,并返回-1和errno=EPIPE,SIGPIPE的默认行为是终止程序,所以通常我们应该忽略这个信号,避免程序终止。
如果这一端不去读写,我们可能没有办法知道对端的socket关闭了。
网络库
- 提供易用API
- 封装底层Socket API
- 连接管理和时间分发
- 功能
- 协议支持:tcp、udp和uds等
- 优雅退出、异常处理等
- 性能
- 应用层buffer减少copy
- 高性能定时器、对象池等
关键指标
稳定性
保障策略
-
熔断
一个服务A调用服务B时,服务B又调用服务C,而如果服务C响应超时,由于服务B依赖C,C的超时直接导致B的业务逻辑一致等待,而此时服务A又频繁调用B,服务B就可能因为堆积大量请求导致服务宕机,由此就导致了服务的雪崩问题,因此需要在服务B中设置熔断机制,当服务C响应超时时,服务B会直接返回错误,而不是等待C的响应。这样就可以保护调用方,防止被调用服务出现问题而影响到整个链路。
-
限流
到调用方发生请求过来时,服务端在执行业务逻辑之前先执行检查限流逻辑,如果发现访问量过大并且超出了限流条件,那么就让服务端直接降级处理或则返回给调用方一个限流异常。这样就可以保护被调用方,防止大量流量把服务压垮。
-
超时控制
当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,避免浪费资源。
从某种程度上讲超时、限流和熔断也是一种服务降级的手段。
请求成功率
一个请求发送出去,要尽可能地保证成功返回。而提高成功率的方法主要有两种:
- 负载均衡
- 重试
不过重试要防止重试风暴,要限制单点重试和限制链路重试。
长尾请求
普通的请求如下所示:
长尾请求如下所示:
可以看到,长尾请求的响应时间远远大于普通请求的响应时间,这就会导致整个服务的响应时间变长,甚至会导致服务超时。因此需要对长尾请求进行优化。也就是在超过一定时间的请求,不再等待响应,而是发送一个新的请求,这个新的请求一般会会比长尾请求响应更快。
注册中间件
Kitex Client 和 Server 的创建接口均采用 Option 模式,提供了极大的灵活性,很方便就能注入这些稳定性策略。
易用性
-
开箱即用
合理的默认参数选项、丰富的文档。
-
周边工具
生成代码工具、脚手架工具。
扩展性
- Middleware
- Option
- 编解码层
- 协议层
- 网络传输层
- 代码生成工具插件扩展
一次请求发起首先会经过治理层面,治理相关的逻辑被封装在middleware中,这些middleware会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等,mw执行后就会进入到remote 模块,完成与远端的通信。
观测性
- Log、Metric、Tracing
- 内置观测性服务
除了传统的 Log、Metric、Tracing 三件套之外,对于框架来说可能还不够,还有些框架自身状态需要暴露出来,例如当前的环境变量、配置、Client/Server初始化参数、缓存信息等。
高性能
目标
- 高吞吐
- 低延迟
场景
- 单机多机
- 单连接多连接
- 单/多client、单/多server
- 不同大小的请求包
- 不同请求类型
- pingpong
- streaming
- ...
手段
- 连接池
- 多路复用
- 高性能编解码协议
- 高性能网络库
企业实践
主要介绍Kitex。
整体架构
主要包含三个部分:
-
Kitex Core
核心组件
-
Kitex Byted
与公司内部基础设施集成
-
Kitex Tool
代码生成工具
core是它的的主干逻辑,定义了框架的层次结构、接口,还有接口的默认实现,如中间蓝色部分所示,最上面client和server是对用户暴露的,client/server option的配置都是在这两个package中提供的,还有client/server的初始化,在第二节介绍kitex_gen生成代码时,大家应该注意到里面有client.go和server.go,虽然我们在初始化client时调用的是kitex_gen中的方法,其实大家看下kitex_gen下service package代码就知道,里面是对这里的 client/server的封装。
client/server下面的是框架治理层面的功能模块和交互元信息,remote是与对端交互的模块,包括编解码和网络通信。
右边绿色的byted是对字节内部的扩展,集成了内部的二方库还有与字节相关的非通用的实现,在第二节高级特性中关于如何扩展kitex里有介绍过,byted部分是在生成代码中初始化client和server时通过suite集成进来的,这样实现的好处是与字节的内部特性解耦,方便后续开源拆分。
左边的tool则是与生成代码相关的实现,我们的生成代码工具就是编译这个包得到的,里面包括idl解析、校验、代码生成、插件支持、自更新等,未来生成代码逻辑还会做一些拆分,便于给用户提供更友好的扩展
自研网络库
Netpoll
背景
-
原生库无法感知连接状态
在使用连接池时,池中存在失效连接,影响连接池的复用。
-
原生库存在goroutine暴涨的风险
一个连接一个goroutine的模式,由于连接利用率低下,存在大量goroutine占用调度开销,影响性能。
- Go Net 使用 Epoll ET ,Netpoll 使用 LT。
- Netpoll 在大包场景下会占用更多的内存。
- Go Net 只有一个 Epoll 事件循环(因为 ET 模式被唤醒的少,且事件循环内无需负责读写,所以干的活少),而 Netpoll 允许有多个事件循环(循环内需要负责读写,干的活多,读写越重,越需要开更多 Loops)。
- Go Net 一个连接一个 Goroutine,Netpoll 连接数和 Goroutine 数量没有关系,和请求数有一定关系,但是有 Gopool 重用。
- Go Net 不支持 Zero Copy,甚至于如果用户想要实现 BufferdConnection 这类缓存读取,还会产生二次拷贝。Netpoll 支持管理一个 Buffer 池直接交给用户,且上层用户可以不使用 Read(p []byte) 接口而使用特定零拷贝读取接口对 Buffer 进行管理,实现零拷贝能力的传递。
Netpoll
-
解决无法感知连接状态问题
引入epoll主动监听机制,感知连接状态
-
解决goroutine暴涨的风险
建立goroutine池,复用goroutine
-
提升性能
引入Nocopy Buffer,向上层提供NoCopy的调用接口,编解码层面零拷贝
-
go net 无法检测连接对端关闭(无法感知连接状态)
- 在使用长连接池时,池中存在失效连接,严重影响了连接池的使用和效率。
- 希望通过引入 epoll 主动监听机制,感知连接状态。
-
go net 缺乏对协程数量的管理
- Kite 采取一个连接一个 goroutine 模式,由于连接利用率低,服务存在较多无用的 goroutine,占用调度开销,影响性能。
- 希望建立协程池,提升性能。
netpoll基于epoll,同时采用Reactor模型,对于服务端则是主从Reactor模型,如右图所示:服务端的主reactor 用于接受调用端的连接,然后将建立好的连接注册到某个从Reactor上,从Reactor负责监听连接上的读写事件,然后将读写事件分发到协程池里进行处理。
- 为了提升性能,引入了 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝
扩展性设计
支持多协议,也支持灵活的自定义协议扩展
-
kitex支持多协议的并且也是可扩展的,交互方式上前面已经说过支持ping-pong、streaming、oneway
-
编解码支持thrift、Protobuf
-
应用层协议支持TTHeader、Http2、也支持裸的thrift协议
-
传输层目前支持TCP,未来考虑支持UDP、kernel-bypass的RDMA
如上图所示,框架内部不强依赖任何协议和网络模块,可以基于接口扩展,在传输层上则可以集成其他库进行扩展。目前集成的有自研的Netpoll,基于netpoll实现的http2库,用于mesh场景通过共享内存高效通信的shm-ipc,以后也可以增加对RDMA支持的扩展。
性能优化
网络库优化
- 调度优化
- epoll_wait在调度上的控制
- gopool重用goroutine降低同时运行协程数
- LinkBuffer
- 读写并行无锁,支持nocopy地流式读写
- 高效扩缩容
- Nocopy Buffer池化,减少GC
- Pool
- 引入内存池和对象池,减少GC开销
编解码优化
序列化和反序列的性能优化从大的方面来看可以从时间和空间两个维度进行优化。从兼容已有的 Binary 协议来看,空间上的优化似乎不太可行,只能从时间维度进行优化,包括下面的几点:
- Codegen
- 预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
- Inline减少函数调用次数和避免不必要的反射操作等
- 自研Go语言实现的Thrift IDL解析和代码生成器,支持完善的Thrift IDL语法和语义检查,并支持了插件机制 - Thriftgo
- 优点是库开发者实现起来相对简单,缺点是增加业务代码的维护成本和局限性
- JIT
- 使用JIT编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担
- 基于JIT编译技术的高性能动态Thrift编解码器 - Frugal
JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。
即时编译 JIT 则将编译过程移到了程序的加载(或首次解析)阶段,可以一次性编译生成对应的 codec 并高效执行,目前公司内部正在尝试,压测数据表明性能收益还是挺不错的,目的是不损失性能的前提下,减轻用户的维护负担生成代码的负担。
合并部署
微服务过微,传输和序列化开销越来越大。将亲和性强的服务实例尽可能调度到同一个物理机,远程RPC调用优化为本地IPC调用。
三、实践练习例子
本节课程并没有给出用于练习的例子。
四、课后个人总结
本次课程围绕RPC的基本概念以及实现原理进行了学习,主要包括RPC的基本概念、RPC的实现原理、RPC的性能优化、RPC的合并部署等内容。其中重点讲解了RPC框架的核心三层,即编解码层、协议层、网络传输层。同时也对RPC框架的核心性能指标进行了探讨。