基本概念
本地函数的调用
比如下面这段代码就是一个本地函数调用的例子:
func main() {
var a := 2
var b := 3
result := calculate(a, b)
fmt.Println(result)
return
}
func calculate(x, y int) {
z := x * y
return z
}
上面这段代码首先会将 a 和 b 的值压栈。然后通过函数指针找到 calculate 函数,进入函数取出栈中的值 2 和 3,将其赋予 x 和 y。然后计算出 x * y 的结果存储到 z 中。随后将 z 的值压栈,然后从 calculate 返回。最后从栈中取出 z 返回值,赋给 result。
堆区(heap):一般有程序员分配释放,若程序员不释放,程序结束时可能有 OS 回收。注意它与数据结构中的堆时两回事,分配方式类似于链表
栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
上面的程序知识为了说明原理,事实上编译器经常会做优化,对于参数和返回值少的情况会直接将其存放再寄存器中,而不需要压栈弹栈的过程,甚至都不需要调用 call,而直接做 inline 操作。
远程函数调用
比如网上购物的时候需要支付 100 元,那么就会去调用支付服务的一些操作,然后返回扣款的信息。如下图
远程函数和本地函数的区别就在于,不同的服务部署在不同的机器上,中间相隔了一个网络,所以远程函数调用(RPC)就存在一些需要解决的问题: 1. 函数映射。2. 数据转换成字节流。 3. 网络传输
:::info 函数映射
我们如何告诉支付服务,我们需要调用付款这个函数,而不是退款或者充值呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用哪个方法,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一致的,所以函数都有自己的一个 ID,在做 RPC 的时候要附上这个 ID,还得有个 ID 和函数的对照关系表,通过 ID 找到对应的函数并执行。
客户端怎么把参数值传给远程的函数呢?
在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。这时候就需要客户端把参数先转成一个字节流,传给服务器后,再把字节流转成自己能读取的格式。
远程调用往往用在网络上,如何保证在网络上高效稳定地传输数据?
:::
RPC 概念模型
在 1984 的时候 Nelson 发表了论文,提出 RPC 的过程是由 5 个模型组成: User、User-Stub、RPC-Runtime、Server-Stub、Server。如下图
call machine 指调用端,callee machine 指被调用端。
一次 RPC 的完整过程
- IDL(Interface description language)文件
IDL 通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。
- 生成代码
通过编译器工具把 IDL 文件转换成语言对应的静态库。
- 编译码
从内存中表示到字节序列的转换称为编码,反之为解码,也常叫做序列化和反序列化。
- 通信协议
规范了数据在网络中的传输内容和格式,除了必须的请求/响应数据外,通常还会包含额外的元数据。
- 网络传输
通常基于成熟的网络库走 TCP/UDP 传输。
:::info 相比本地函数调用,远程调用的话我们不知道对方有哪些方法,以及对应的参数长什么样,所以需要有一种方式给来描述或者声明有哪些方法,方法的参数都是什么样子的,这样的话大家就能按照这个来调用,这个描述文件就是 IDL 文件。
服务双方通过约定的规范进行远程调用,双方都依赖同一份 IDL 文件,需要通过工具来生成对应的生成文件,具体调用的时候用户代码需要依赖生成代码,所以可以把用户代码和生成代码看成一个整体。
编码只是解决了语言的数据交换格式,但是如何通讯呢?需要指定通讯协议,以及数据如何进行传输,网络模型应该是什么样子的。这些就是 transfer 需要做的事情。
:::
RPC 的优点
- 单一职责,开发(采用不同的语言)、部署以及运维(上线独立)都是独立的,有利于分工协作和运维开发
- 可拓展性强,资源使用率更优,例如压力过大的时候可以独立扩充资源,底层基础服务可以复用,节省资源
- 故障隔离,服务的整体可靠性更高,某个模块发生故障,不会影响整体的可靠性
RPC 的缺点
- 服务宕机,对方应该如何处理?
- 在调用过程中发生网络异常,如何保证信息的可达性?
- 请求量突增导致服务无法及时处理,有哪些应对措施?
分层设计
分层设计-以 Apache Thrift 为例
编解码层
其实生成的代码也可以看作是编解码层的一部分,因为里面封装了编解码逻辑。
生成代码
数据格式
- 语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持,比如 java 有 java.io.Serializable
非常方便,可以使用很少的额外代码实现内存对象的保存和恢复,但是一般会与特定的语言深度绑定,其它语言很难读取这种数据。如果以这类编码存储或传输数据,那么就和这门语言绑死在一起了。安全和兼容性也是问题。
- 文本格式
JSON、XML、CSV 等文本格式,具有人类可读性。描述可能不会很严谨。比如 JSON 虽然可以区分区分字符串和数字,但是不会区分整数和浮点数,而且不能指定精度,处理大量数据时,这个问题更加严重了;没有强制模型约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不变。由于 JSON 在一些语言中的序列化和反序列化都需要采用反射机制,所以在性能方面会比较差。
- 二进制编码
具备跨语言和高性能等优点,常见的有 Thrift 的 BinaryProtocol,Protobuf 等。二进制编码实现可以有很多种,比如 TLV 编码和 Varint 编码。
二进制编码-TLV 编码
主要包括三部分
- Tag: 标签,可以理解为类型
- Length: 长度
- Value: 值,Value 也可以是一个 TLV 结构
例如下面结构体可以实现下图中的接口
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
我们可以看到第一个 byte 是类型,主要用来表示是 string 还是 int 等数据类型。这里不写 key 的字符串了,比如上面的 userName,faviruteNumber 等,取而代之的是一个 field'tag 的东西,这个会设置层 1,2,3 和上面的 schema 中 key 字符串前面的数字,也就是用来取代了具体的 key 值,从而减少总体的大小。
TLV 结构简单清晰,并且扩展性好,但是由于增加了 Type 和 Length 两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。
选型
- 兼容性
支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度
移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提高系统的灵活度。
- 通用性
支持跨平台、跨语言
通用性有两个层面的意义:
第一、技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就大大降低了。
第二、流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
- 性能
从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长
第一、空间开销,序列化需要在原有的数据上加上描述字段,为反序列解析用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以 TB 为单位,巨大的额外空间开销意味着高昂的成本。
第二、时间开销,负责的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。
协议层
概念
协议是双方确定的交流语义,比如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。这个协议很简单,首先发送一个 4 字节的消息总长度,然后再发送 1 字节的字符串 charset 长度,接下来就是消息的 payload,字符集和字符串正文。
特殊结束符:一个特殊字符作为每个协议单元结束的标志。过于简单,对于一个协议单元必须要全部读入才能够进行处理,除此之外必须要放置用户传输的数据不能同结束符相同,否则就会出现紊乱。
HTTP 协议头就是以回车(CR)加换行(LF)符号序列结尾
变长协议:一般都是自定义协议,有 header 和 payload 组成,会以定长加不定长的部分组成,其中定长部分需要描述不定长的内容长度,使用比较广泛。
协议构造
- LENGTH 字段 32bits,包括数据包剩余部分的字节大小,不包含 LENGTH 自身长度
- HEADER MAGIC 字段 16bits,值为:0x1000,用于表示协议版本信息,协议解析的时候可以快速校验
- FLAGS 字段 16bits,为预留字段,暂未使用,默认值为 0x0000
- SEQUENCE NUMBER 字段 32bits,表示数据包的 seqld,可用于多路复用,最好确保单个连接内递增。多路复用:同一个连接内可以用多个请求流
- HEADER SIZE 字段 16bits,等于头部长度字节数/4,头部长度计算从 14 个字节开始计算,一直到 PAYLOAD 前(备注:header 的最大长度为 64K)
- PROTOCOL ID 字段 uint8 编码,取值有: ProtocolIDBinary = 0,ProtocolIDCompact = 2
- NUM TRANSFORMS 字段 uint8 编码,表示 TRANSFORM 个数
- TRANSFORM ID 字段 unit8 编码,具体取值参考下文,表示压缩方式 zlib or snappy
- INFO ID 字段 uint8 编码,具体取值参考下文,用于传递一些定制的 meta 信息
- PAYLOAD 消息内容
协议解析
从内存中读取指定的一部分数据->读取 magic 所在的位置(知道是什么类型的协议)->读取编解码方式(知道用什么方式解码)->解出 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 会一直阻塞
writd 从 fd 写数据,socket 默认是阻塞模式的,如果对方没有写数据,write 会一直阻塞
socket 关闭套接字,当另一端 socket 关闭后,这一端读写情况:
尝试去读会得到一个 EOF,并返回 0
尝试去写会触发一个 SIGPIPE 信号,并返回 -1 和 error=EPIPE,SIGPIPE 的默认行为是终止程序,所以通常我们应该忽略这个信号,避免程序终止。
如果这一端不去读写,我们可能没办法知道对端的 socket 关闭了
网络库
- 提供易用的 API
封装底层 Socket API
连接管理和事件分发
- 功能
协议支持: tcp、udp 和 uds 等
优雅推出、异常处理等
- 性能
应用层 buffer 减少 copy
高性能定时器、对象池等
关键指标
稳定性
保障策略
- 熔断:保护调用方,放置被调用的服务出现问题而影响到整个链路
一个服务 A 调用服务 B 时,服务 B 的业务逻辑又调用了服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 继续频繁地调用服务 B,服务 B 就可能因为堆积大量地请求而导致服务宕机,由此就导致了服务雪崩地问题。
- 限流:保护被调用饭,防止大流量把服务压垮
当调用端发送请求过来时,服务端在执行业务逻辑之前先执行检查限流逻辑,如果发现访问量过大并且超出了限流条件,就让服务端直接剪辑处理或者返回给调用方一个限流异常。
- 超时控制:避免浪费资源在不可用节点上
当下流的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,避免浪费资源。
从某种程度上讲,超时、限流和熔断也是服务降级的手段。
请求成功率
:::info 因为重试有放大故障的风险,首先,重试会加大下游的负载,如上图,假设 A 服务调用 B 服务,重试次数未 r(包括首次请求),当 B 高负载时很可能会调用不成功,这时候 A 调用失败重试 B,B 服务的被调用量快速增大,最坏情况下可能放大到 r 倍,不仅不能请求成功,还可能会导致 B 的负载继续升高,甚至直接打卦。
防止重试风暴,限制单点重试和限制链路重试。
:::
长尾请求
:::info 长尾请求一般是指明显高于均值的那部分占比较小的请求。业界关于延迟有一个常用的 p99 标准,p99 单个请求响应耗时从小到大排列,顺序处于 99%位置的值即为 p99 值,那后面的这 1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖音,GC,系统调度。
我们预先设定一个阙值 t3(比超时时间小,通常建议是 RPC 请求延时的 pct99),当 Req1 发出去后超过 t3 时间都没有返回,那我们直接发去重试请求 Req2,这样相当于同时有两个请求运行。然后等待请求返回,只要 Resp1 或者 Resp2 任意一个返回成功的结果,就可以立即结束这次请求,这样整体的耗时就是 t4,它表示从第一个请求发出到第一个成功结果返回之间的时间,相比于等待超时后再发出请求,这种机制能大大减少整体延时。
:::
注册中间件
KitexClient 和 Server 的创建接口均采用 Option 模式,提供了极大的灵活性,很方便就能注入这些稳定性策略。
易用性
- 开箱即用
合理的默认参数选项、丰富的文档
- 周边工具
生成代码工具、脚手架工具
简单易用的命令行工具:
- 生成服务代码脚手架
- 支持 protobuf 和 thrift
- 内置功能丰富的选项
- 支持自定义的生成代码插件
扩展性
一次请求发起首先会经过治理层面,治理相关的逻辑被封装再 middleware 中,这些 middleware 会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等,mv 执行后就会进入到 remote 模块,完成与远端的通信。
观测性
除了传统的 Log、Metric、Tracing 三件套之外,对于框架来说可能还不够,还有些框架自身状态需要暴露出来,例如当前的环境变量、配置、Client/Server 初始化参数、缓存信息等。
高性能
:::info 这个图需要换一下。这里分为两个维度,高性能意味着高吞吐和低延迟,两者都很重要,甚至大部分场景下低延迟更重要。
多路复用可以大大减少连接带了的资源消耗,并且提高了服务端性能,我们的测试中服务端吞吐可提升 30%。
右边的图可以帮助理解连接多路复用,调用端向服务端的一个节点发送请求,并发场景下,如果是非连接多路复用,每个请求都会持有一个连接,知道请求结束连接才会被关闭或者放入连接池复用,并发量与连接数是对等的关系。
而使用连接多路复用,所有请求都可以再一个连接上完成,大家可以明显看到连接资源利用上的差异。
:::
总结
- 框架通过中间件来注入各种服务治理策略,保障服务的稳定性
- 通过提供合理的默认配置和方便的命令行工具可以提升框架的易用性
- 框架应当提供丰富的扩展点,例如核心的传输层和协议层
- 观测性除了传统的 Log、Metric 和 Tracing 之外,内置状态暴露服务也很有必要
- 性能可以从多个层面去优化,例如选择高性能的编解码协议和网络库
企业实践
企业内部大范围使用 go 语言进行开发,而 kitex 是内部多年最佳实践沉淀出来的一个高性能高可扩展性的 go RPC 框架,在内部有几万哥微服务在使用,在去年也开源了回馈给社区。主要是给一个技术视野的拓展。
整体架构-Kitex
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 解析、校验、代码生成、插件支持、自更新等,未来生成代码的逻辑还会做一些拆分,便于给用户提供更友好的扩展。
自研网络库-背景
- 原生库无法感知连接状态
在使用连接池时,池中存在失效连接,影响连接池的复用。
- 原生库存在 goroutine 暴涨的风险
一个连接一个 goroutine 的模式,由于连接利用率低下,存在大量 goroutine 占用调度开销,影响性能。
Go Net 使用 Epoll ET,Netpoll 使用 LT
Netpoll 在大包场景下会占用更多的内存
GoNet 只有一个 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 的调用接口,编解码层面零拷贝
:::info
- go net 无法检测连接对端关闭(无法感知连接状态)
- 在使用长连接池时,池中存在失效连接,严重影响了连接池的使用和效率
- 希望通过引入 epoll 主动监听机制,感知连接状态
- go net 缺乏对协程数量的管理
- Kite 采取一个连接一个 goroutine 模式,由于连接利用率低,服务存在较多无用的 goroutine,占用调度开销,影响性能
- 希望建立协程池,提升性能
netpoll 基于 epoll,同时采用 Reactor 模型,对于服务端则是主从 Reactor 模型,服务端的主 reactor 用于接受调用端的连接,然后将建立好的连接注册到某个从 reactor 上,从 reactor 负责监听连接上的读写事件,然后将读写事件分发到协程池里进行处理。
- 为了提升性能,引入了 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝
:::
扩展性设计
:::info kitex 支持多协议的并且也是可扩展的,交互方式上前面已经说过支持 ping-pong、streaming、oneway
编解码支持 thrif、Protobuf
应用层协议支持 TTHeader、Http2、也支持裸的 thrif 协议
传输层目前支持 TCP,未来考虑支持 UDP、kernel-bypass 的 RDMA
如右图所示,框架内部不强依赖任何协议和网络模块,可以基于接口扩展,在传输层上则可以集成其它库进行扩展
目前集成的自研的 Netpoll,基于 netpoll 实现的 http2 库,用于 mesh 场景通过共享内存高效通信的 shm-ipc,以后也可以增加对 RDMA 支持的扩展
:::
性能优化-网络库优化
- 调度优化
epoll_wait 在调度上的控制
gopool 重用 goroutine 降低同时运行协程数
- LinkBuffer
读写并行无锁,支持 nocopy 地流式读写
高效扩缩容
Nocopy Buffer 池化,减少 GC
- Pool
引入内存池和对象池,减少 GC 开销
性能优化-编解码优化
- Codegen
预计算并预分配内存,减少内存操作次数,包括内存分配和拷贝
Inline 减少函数调用次数和避免不必要的反射操作等
自研了 Go 语言实现 Thrift IDL 解析和代码生成器,支持完善的 Thrift IDL 语法和语义检查,并支持了插件机制 - Thriftgo
- JIT
使用 JIT 编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担
基于 JIT 编译技术的高性能动态 Thrift 编解码器 - Frugal
:::info 序列化和反序列化的性能优化从大的方面来看可以从时间和空间两个维度进行优化。从兼容已有的 binary 协议来看,空间上的优化似乎不太可行,只能从时间维度来进行优化,包括以下几点:..
代码生成 code-gen 的优点是库开发者实现起来相对简单,缺点是增加业务代码的维护成本和局限性
JIT 编译(just-in-time compilation): 狭义来说是当某段代码第一次被执行时进行编译,因而叫"即时编译"
即时编译 JIT 则将编译过程移到了程序的加载(或首次解析)阶段,可以一次性编译生成对应的 codec 并高效执行
:::