这是我参与「第三届青训营 -后端场」笔记创作活动的第四篇笔记。
本节课程主要包含以下四个方面:
- 基本概念
- 分层设计
- 关键指标
- 企业实践
1 基本概念
1.1 本地函数调用
以上步要只是为了说明原理。事实上编译器经常会做优化,对于参数和返回值少的情况会直接将其存放在寄存器,而不需要压栈弹栈的过程,甚至都不需要调用call,而直接做inline操作
1.2远程函数调用(RPC-Remote Procedure Calls)
函数映射问题如何解决?
要怎么告诉支付服务我么要调用付款这个服务,而不是退款或者充值呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用哪个方法,编译器就自动帮我们调用对应的函数指针。但在远程调用中,函数指针是不行的,因为两个进程的地址空间是安全不一样的。所以函数都要有自己的一个ID,在做RPC的时候要附上这个ID,还有ID和函数的对照关系表,通过ID找到对应的函数并执行。
客户端怎么把参数传给远程的函数呢?
在本地调用中,我们只需要把参数压到栈里,然后让函数自己到栈里读就行。但是在远程过程调用时,客户端和服务端是不同的进程,不能通过内存来传递参数,这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。
远程调用往往用在网络上,如何保证在网络上高效稳定地传输数据?
1.3 PRC概念模型
从上图可以看出,客户程序要进行远程函数调用,首先要调用客户桩程序(stub),客户桩程序再调用系统RPC,系统将该调用请求封装在TCP/IP包中发送给服务器,服务器会在该TCP端口进行侦听,收到请求后再把该请求发送给服务主程序,服务主程序则启动相应的服务函数,并将函数的返回值返还给系统,系统把该结果封装在TCP/IP包中送还给客户机,直至客户机主程序得到返回值。
当服务主程序启动时,会调用RPC建立RPC服务,系统会选择一个未用的TCP端口进行侦听,在该TCP端口上提供RPC服务,客户机进行远程函 数调用前需要用服务器IP地址,RPC服务的标识(ID)连接服务器,通过上述信息,客户机能得到服务器上提供该服务的端口号,并自动建立TCP/IP连接。
虽然远程函数调用的原理很复杂,但用户所要面对的实现细节却并不复杂,从上图可以看出,系统RPC调用是系统内置的,无需考虑,服务主程序和客户桩程序是rpcgen实用程序根据用户编写的一个接口定义文件自动生成的。只有客户主程序和远程服务函数是和具体业务相关的,需要用户自行编写。
1.4 一次RPC的完整过程
1.5 RPC的好处
1.6 RPC带来的问题
小结
2 分层设计
2.1 分层设计--以Apache Thrift为例
2.2 编解码层
2.3 编解码层--生成代码
2.4 编解码层--数据格式
2.5 编解码层--二进制编码
我们可以用二进制编码的方式,表示任意的信息。只要建立起字符集和字符编码,并且得到大家的认同,我们就可以在计算机里面表示这样的信息了。
2.6 编解码层--选型
通用性:
通用性有两个层面的意义:
第一、技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就大大降低了。
第二、流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着易贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。
兼容性:
移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。
2.7 协议层
这层除了通讯的数据还会增加一些需要的数据。
2.8 协议层--概念
2.9 协议层--协议构造
2.10 协议层解析
通过MagicNumber知道协议的类型,从PayloadCodeC得到编码的方式,进行解析。
2.11 网络通信层
2.12 网络通信层--Sockets API
套接字编程中的客户端必须知道两个信息:服务器的IP地址,以及端口号。
socket函数创建一个套接字,bind将一个套接字绑定到一个地址上。listen监听进来的连接,backlog的含义有点复杂,这里先简单的描述:指定挂起的连接队列的长度,当客户端连接的时候,服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,backlog则指定这个队列的长度,accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。如果队列未满,客户端调用connect马上成功,如果队列末满,客户端调用connect马上成功,如果满了可能会阻塞等待队伍未满(实际上在linux中测试并不是这样的结果)。Linux的backlog默认是128,通常情况下,我们也指定为128即可。
2.13 网络通信层--网络库
小结
3 关键指标
3.1 稳定性--保障策略
熔断:一个服务A调用服务B时,服务B的业务逻辑又调用了服务C,而这时服务C响应超时了,由于服务B依赖服务C,C超时直接导致B的业务逻辑一直等待,而这个时候服务A继续频繁地调用服务B,服务B就可能会因为堆积大量的请求而导致服务宕机,由此就导致了服务雪崩的问题。
限流:当调用端发送请求过来时,服务端在执行业务逻辑之前先执行检查限流逻辑,如果发现访问量过大并且超出了限流条件,就让服务端直接降级处理或者返回给调用方一个限流异常。
超时:当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,避免浪费资源。
3.2 稳定性--请求成功率
注意,因为重试有放大故障的风险,首先,重试会加大直接下游的负载。如上图,假设A服务调用B服务,重试次数设置为r(包括首次情求),当B高负载时很可能调用不成功,这时A调用失败重试B,B服务的被调用量快速增大,最坏情况下可能放大到r倍,不仅不能请求成功,还可能导致B的负载继续升高,甚至直接打挂。
防止重试风暴,限制单点重试和限制链路重试。
3.3 稳定性--长尾请求
长尾请求一般是指明显高于均值的那部分占比较小的请求。业界关于延迟有一个常用的P99标准,P99单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99值,那后面这1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动,GC,系统调度。
我们预先设定一个阈值t3(比超时时间小,通常建议是RPC请求延时的ptc99),当Req1发出去后超过t3时间都没有返回,那我们直接发起重试清求Req2,这样相当于同时有两个请求运行。然后等待请求返回,只要Resp1或者Resp2任意一个返回成功的结果,就可以立即结束这次请求,这样整体的耗时就是t4,它表示从第一个请求发出到第一个成功结果返回之间的时间,相比于等待超时后再发出请求,这种机制能大大减少整体延时。
3.4 稳定性--注册中间件
不同的框架叫法不同。
3.5 易用性
3.6 扩展性
需要提供更多的可扩展点。
一次请求发起首先会经过治理层面,治理相关的逻辑被封装在middleware中,这些middleware会被构造成一个有序调用链逐个执行,比如服务发现、路由、负载均衡、超时控制等,middleware执行后就会进入到remote模块,完成与远端的通信。
3.7 观测性
3.8 高性能
小结
4 企业实践
4.1 整体架构--Kitex
core是它的的主干逻辑,定义了框架的层次结构、接口,还有接口的默认实现,如中间蓝色部分所示,最上面client和server是对用户易露的。client/server option的配置都是在这两个package中提供的,还有client/server的初始化,在第二节介绍kitex_ gen生成代码时,大家应该注意到里面有client.go和server.cgo,虽然我们在初始化client时调用的是Kitex_gen中的方法,其实大家看下kitex_gen下service package代码就知道,里面是对这里的client/server的封装。
client/server下面的是框架治理层面的功能模块和交互元信息,remote是与对端交互的模块,包括编解码和网络通信。
右边绿色的byted是对字节内部的扩展,集成了内部的二方库还有与字节相关的非通用的实现,在第二节高级特性中关于如何扩展kitex里有介绍过,byted部分是在生成代码中初始化client和server时通过suite集成进来的,这样实现的好处是与字节的内部特性解耦,方便后续开源拆分。
左边的tool则是与生成代码相关的实现,我们的生成代码工具就是编译这个包得到的,里面包括idl解析、校验、代码生成、插件支持、自更新等,未来生成代码逻辑还会做一些拆分,便于给用户提供更好的扩展。
4.2 自研网络库--背景
- Go Net 使用Epoll ET,Netpoll使用LT。
- Netpoll在大包场景下会占用更多的内存。
- Go Net只有一个 Epoll事件循环(因为ET模式被唤醒的少,且事件循环内无需负责读写,所以干的活少),而Nepoll允许有多个事件循环(循环内需要负责读写,干的活多,读写越重,越需要开更多oops)。
- Go Net一个连接一个Goroutine,Netpoll连接数和Goroutine数量没有关系,和请求数有一定关系,但是有Gopool重用。
- Go Net 不支持Zero Copy,甚至于如果用户想要实现BufferdConnection这类缓存读取,还会产生二次拷贝。Netpoll支持管理一个Buffer池直接交给用户,且上层用户可以不使用Read(p []byte)接口而使用特定零拷贝读取接口对Buffer进行管理,实现零拷贝能力的传递。
4.3 自研网络库--Netpoll
netpoll基于epoll,同时采用Reactor模型,对于服务端则是主从Reactor模型,服务端的主reactor用于接受调用端的连接,然后将建立好的连接注册到某个从Reactor上,从Reactor负责监听连接上的读写事件,然后将读写事件分发到协程池里进行处理。
3.为了提升性能,引入了Nocopy Buffer,向上层提供NoCopy 的调用接口,编解码层面零拷贝。
4.4 扩展性设计
4.5 性能优化--网络库优化
4.6 性能优化--编解码优化
序列化和反序列的性能优化从大的方面来看可以从时间和空间两个维度进行优化。从兼容已有的 Binay 协议来看,空间上的优化似乎不太可行,只能从时间维度进行优化。
代码生成code-gen的优点是库开发者实现起来相对简单,缺点是增加业务代码的维护成本和局限性。
JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫"即时编译"。
即时编译JIT则将编译过程移到了程序的加载(或首次解析)阶段,可以一次性编译生成对应的codec并高效执行,目前公司内部正在尝试,压测数据表明性能收益还是挺不错的,目的是不损失性能的前提下,减轻用户的维护负担生成代码的负担。