这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记
01 基本概念
1.1 本地函数调用
本地函数调用
- 函数名称
- 参数
- 返回值
1.2 远程函数调用RPC
RPC需要解决的问题
- 函数映射(怎么调用这个函数)
- 数据转换成字节流(参数和返回值怎么传递)
- 网络传输(怎么保证网络高效稳定传输)
1.3 RPC概念模型
1.4 一次RPC的完整过程
分别需要
IDL
生成代码
编解码
通信协议:规范了数据在网络传输中的内容和格式。除必须的请求和响应数据外,还需要包含额外的元数据
网络传输:tcp,udp
1.5 RPC的好处
- 单一职责,有利于分工协作和运维开发
- 可扩展性强,资源利用率高
- 故障隔离,服务的整体的稳定性更强
1.6 RPC带来的问题
- 服务宕机,对方应该怎么处理
- 在调用过程中发生网络异常,然后保证消息的可达性?
- 请求量激增导致服务无法及时处理,有哪些应对措施?
RPC框架来解决
02 分层设计
编码解码、协议层、网络通信
2.1 编码解码
2.1.1 生成代码
2.1.2 数据格式
- 语言特定的格式 例如 java的java.io.Serializable
- 文本格式 JSON XML CSV 文本格式,人类可读
- 二进制编码 Thrift的BinaryProtocol,Protobuf
2.1.3 二进制编码
TLV 编码 Tag:标签,可以理解为类型 Length:长度 Value:值,Value也可以是个TLV结构
缺点:增加了Type和Length两个冗余的信息,有额外的内存开销,特别是大部分字段都是基本类型的情况下有不小的内存空间浪费。
2.1.4 选型
- 兼容性 在移动互联的时代,业务需求变更的周期变得更快,新的需求不断涌现,老的需求还要继续维护。如果序列化协议有好的扩展性,支持自动增加新的业务字段,而不影响老的服务,那么将大大提高系统的灵活性。
- 通用性
- 技术层面,序列化协议是否支持跨平台、跨语言
- 流行程度
- 性能
- 空间开销:在元数据的下,尽可能的减少额为的存储信息。要不然网络和磁盘会有大的压力。
- 时间开销:复杂的序列化协议可能造成大的时间开销,成为系统瓶颈。
2.2 协议层
2.2.1 概念
- 特殊的结束符
一个特殊字符作为每个协议单元结束的标示
- 变长协议 以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度
2.2.2 协议构造
- LENGTH 数据包的大小,不包含自身
- HEADER MAGIC 标识版本协议,用于快速验证
- SEQUENCE NUMBER 表示数据包的seqid,用于多路复用,单连接内递增
- HEADER SIZE 头部长度
- PROTOCOL ID 编码方式
- TANSFORM ID 压缩方式
- INFO ID 传递一些定制的meta信息
- PAYLOAD 消息体
2.2.3 协议解析
2.3 网络通信层
2.3.2 Sockets API
2.3.3 网络库
- 提供易用的API 封装底层Socket API 连接管理和事件分发
- 功能 协议支持tcp,udp,uds 优雅退出、异常处理
- 性能 应用层buffer减少copy 高性能定时器、对象池等
03 关键指标
稳定性、易用性、扩展性、观测性、高性能
3.1 稳定性
3.1.1 保障策略
- 熔断:保护调用方,防止被调用方的服务出现问题而影响整个链路
服务A调用服务B,服务B又调用服务C,而这时服务C又响应超时了,服务B就一直等待,而这时服务A又频繁的调用服务B,服务B就会应为堆积了大量的请求而导致宕机,而造成服务雪崩的问题。
- 限流:保护被调用方,防止大流量把服务压垮
当调用端发送请求过来的时候,服务端在执行业务逻辑之前先检查限流逻辑,如果访问量过大并且超过了限流条件,就让服务端直接降级处理或者给调用方一个限流异常。
- 超时控制:避免浪费资源在不可用节点上
当下游的任务以为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放处服务器资源,避免资源浪费。
3.1.2 请求成功率
因为重试有放大故障的风险 重试会加大直接下游的负载,假设服务A调用服务B,重试次数设置为r,当B高负载时很可能调用不成功,这时A重试调用B,B的被调用量快速增大,最坏的情况可能放大r倍,不仅不能成功反而可能会挂掉。
防止重试风暴,限制单点重试和限制链路重试。
3.1.3 长尾请求
backup request 备份请求
长尾请求,一般是指明显高于均值的那部分占比较小的请求。业界关于延迟有一个常用的标准,P99标准,P99标准是指,当个请求的响应时间从小到大排序,顺序处于99%位置的就是P99。后面的百分之1就是长尾请求。在复杂的请求环境中,长尾请求总是存在的,造成这个的原因有很多,如网络抖动,GC,系统调用。
我们现设值一个时间t3(小于超时时间,一般为p99)当请求发送出去后,超过t3时间没有返回,那我们直接重试请求,这样同时运行的有两个请求,其中任意一个返回就可以结束这次请求,这样整体的耗时时间为t4。这种相对于超时之后再请求的方式大大的减少了整体的时延。
3.1.4 注册中间件
3.2 易用性
- 开箱即用 合理的默认参数选项,丰富的文档
- 周边工具 生成代码工具,脚手架工具
3.3 扩展性
- Middleware
- Option
- 编解码层
- 协议层
- 网络传输层
- 代码生成工具插件扩展
3.4 观测性
除了传统的Log、Metric、Tracing三件套外,对于框架来说还是不够,还需要框架自身需要暴露,例如当前的环境变量,配置,client,service初始化参数,缓存信息等。
3.5 高性能
一般来说低延迟更重要
04 企业实践
整体架构、自研网络库、扩展性设计、性能优化、合并部署
4.1 整体架构
4.2 自研网络库
4.2.1 背景
- 原生库无法感知连接状态 在使用连接池时,池中存在无效连接,影响连接池的复用。
- 原生库存在goroutine暴涨的风险 一个连接一个gorountine的模式,由于连接利用率低下,存在goruntine占用调度开销,影响开销。
- go net使用epoll et,netpoll使用lt
- netpoll在大包场景下会占用更多的内存
- go net只有一个epoll时间循环(因为et模式被唤醒的少,且事件循环内无需负责写,所以干的活少),而netpoll允许有多个事件循环(循环内需要负责读写,干的活多,读写越重,越需要开更多loops)
- go net一个连接一个goroutine,netpoll连接数和gorouitne数量没有关系,和请求数有一定关系
- go net不支持零拷贝,甚至用户想要实现bufferedConnect这类缓存,还会产生二次拷贝。netpoll支持管理一个buffer池直接交给用户,且上层可以不使用read接口而使用特定的零拷贝接口对buffer进行管理,实现零拷贝能力的传递。
4.2.2 netpoll
- 解决了无法感知连接状态问题 引入epoll主动监听机制,感知连接状态
- 解决goroutine暴涨的风险 建立gorutine池,复用goroutine
- 提升性能 引入nocopy buffer,向上层提供nocopy的调用接口,编解码层面的零拷贝
4.3 扩展性设计
支持多协议,也支持灵活的自定义协议扩展
4.4 性能优化
4.4.1 网络库优化
- 调度优化 epoll_wait在调度上优化 gopool重用goroutine降低同时运行协程数
- LinkBuffer 读写并行无锁,支持nocopy流式读写 高效扩缩容 nocopy buffer池化,减少gc
- Pool 引入内存池和对象池,减少gc
4.4.2 编解码优化
- codegen
- 预计算并分配内存,减少内存的操作次数,包括内存的分配和拷贝
- inline减少函数调用次数和不必要的反射操作
- 自研了go语言实现的thrift idl解析和代码生成器
- jit
- 使用jit编译技术改善了用户体验,并且减少了用户维护生成代码的负担
- 基于jit编码技术的高性能动态thrift编解码器
4.5 合并部署
微服务过微,传输和序列化开销越来越大,将亲和性强的服务实例尽可能调度到同一台物理机,转化为ipc调用