RPC框架 | 青训营笔记

91 阅读8分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第12篇笔记

RPC框架基本概念

基本概念

image-20220531095436247

RPC,Remote Procedure Calls
RPC框架解决的问题
1. 一般来说如果在本地调用函数的话,只需要找到对应的函数指针,编译器就会直接调用。如果是远程调用的话,函数指针就不是通用方法了,那么需要先找到对应的函数ID,再通过函数ID找到对应函数在本地的位置才能完成调用。Java天生就有函数ID的概念,也就是符号引用
2. 远程调用的时候,需要先将要传递的对象转换为字节流,然后再从网络中的字节流重现这个对象
3. 如何保证网络能高效稳定的传输数据

概念模型

image-20220531100756266

image-20220531105245806

1984年Nelson发表了论文《Implementing Remote Procedure Calls》,其中提出了RPC的过程由5个模型组成User、User-Stub、RPC-Runtime、Server-Stub、Server
也就是分为编解码层stub、协议以及传输层RPC-Runtime

优点以及带来的问题

image-20220531105412960

优点:
1. 单一职责,有利于分工协作和运维开发
2. 可扩展性强,资源使用率更优
3. 故障隔离,服务整体可靠性更高,假如一个服务崩溃不会影响到其他服务

问题:
1. 服务宕机,应该如何处理
2. 在调用过程中发生网络异常,如何保证消息的可达性
3. 请求量突增导致服务无法及时处理,有哪些应对措施

分层设计

Apache Thrift框架

image-20220531110216508

编解码层

image-20220531150900892

语言特定的格式:
许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java有java.io.Serializable。但是这种方法具有一定局限性,就是只有特定语言才能使用,有语言局限性
文本格式:
JSON、XML、CSV等文本格式,具有人类可读性。但是这种方式不是特别严谨,不能区分整数与浮点数,而且没有约束。并且在大部分语言的实现中,需要使用反射的方式,效率比较低
二进制编码:
也是目前比较常见的编码方式,具备跨语言和高性能的优点,常见的有Thrift的BinaryProtocol、Google的Protobuf等
二进制编码也就是将数据转换为二进制流,二进制编码方式也有很多种,比如说TLV编码NIV

TLV编码
- Tag:标签,可以理解为类型
- Length:长度
- Value:值,Value也可以是TLV结构


兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度。不能是只靠维护IDL文件来增加新的字段
通用性:支持跨平台、跨语言,一般来说,大部分人使用的编码方式就是当下比较流行的编码方式
性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长。如果编码会加入很多无关的字段,就会引起更多的网络IO,导致网络传输效率低下


协议层

image-20220531161032832

两种常见的协议:
特殊结束符:使用一个特殊字符作为每个协议单元结束的标示
变长协议:以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度


Apache的Thrift协议中的KeyHeader:
Length:数据包大小
Header Magic:一个魔数,代表着版本信息,在读取的时候可以快速获取到这是什么类型的协议
Sequence Number:表示数据包的seqID,可用于多路复用,单连接内递增
Header Size:头部长度,从第14个字节开始计算一直到PayLoad前,也就是不包括前面的所有信息的长度,换一句话说就是前面的长度是固定不变的
Protocol ID:编解码方式,有Binary和Compact两种
Transform ID:压缩方式,如zlib和snappy
Info ID:传递一些定制的meta信息
PayLoad:消息体


协议 -> 编码 -> 解码

网络通信层

image-20220531162230534
提供易用API
对上层提供简单易用的API,封装底层Socket API,连接管理以及可读可写事件分发处理

功能
协议支持:tcp、udp和uds等
优雅退出、异常处理,对底层网络异常进行处理,并向上层友好的返回

性能
使用应用层buffer减少copy
高性能的定时器、对象池

关键指标

稳定性

image-20220531170548997image-20220531170525238

image-20220531170647762

保障策略:
熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路。也就是当A调用B、B调用C的时候,C如果响应不及时,A就会超时然后就会重复调用,导致大量请求堆积到B中,引起雪崩
限流:保护被调用放,防止大流量把服务压垮。调用端发起请求,被调用端在执行逻辑的时候,首先会检查线路逻辑,如果判断当前访问量超过本身设置的限流条件,就会直接返回一个限流的异常错误
超时控制:被调用端如果发现响应较慢,则快速返回并释放资源,防止资源浪费在不可用的节点上


请求成功率:
负载均衡:A调用B的时候,尽量保证请求能均衡的打到对应的服务器上去
重试:重试可以增加请求的成功率


快速重传:Backup Request
当请求的时间超过过往经验的时间的时候,就会认为请求丢失或者是失败,就会再次发送请求


易用性

image-20220531171009169image-20220531170959504

开箱即用:
拥有合理的默认参数选项,不需要过多配置就可以直接使用,并且还有丰富的文档帮助配置

周边工具
生成代码的工具,简单易用的命令行工具,具有丰富的编解码协议。具有脚手架工具,不用做冗杂的多余代码配置

扩展性

image-20220531171230960
拥有更多的拓展点
Middleware:中间件
Option:
编解码层:
协议层:
网络传输层:
代码生成工具插件扩展:

观测性

image-20220531223400063
Log:日志
Metric:监控,比如说可以通过监控的面板来观测到当前系统的QPS、延时等参数
Tracing:链式跟踪,比如说能观测到一个请求在各个阶段的耗时


除了这些三件套,还能提供一些别的信息,比如说
配置、环境变量、线程、协程、中间件

就类似与linux的top指令一样观测到系统的状态

高性能

image-20220531223810351

高吞吐:在单位时间内尽可能的处理大量请求
低延时:一个连接发出去所需要的时间尽可能的短


实际中低延时是更重要的

提高连接的复用率,减少创建销毁连接的开销
高性能的网络传输

企业实践

整体架构

image-20220531224306593

自研网络库

背景:
	原生库无法感知连接状态:在使用连接池时,池中存在失效连接,影响连接池的复用
	原生存在goroutine保障的风险:一个连接一个goroutine的模式,由于连接利用率底下,存在大量goroutine占用调度开销,影响性能


解决-Netpoll:
	解决无法感知连接状态问题:引入epoll主动监听机制,感知连接状态
	解决goroutine暴涨的风险:建立goroutine池,复用goroutine
	提升性能:引入Nocopy Buffer,向上层提供NoCopy的调用接口,编解码层面零拷贝

扩展性设计

image-20220531224809489

在transhandler这一层,支持了多种网络协议,可以使用多种网络协议进行网络操作

性能优化

总之就是大量引入池化提升复用率,并且合理对池化资源进行调度
Nocopy的流式读写很大的提升了网络IO性能

网络库优化:
调度优化:
	epoll_wait在调度上的支持
	gopool重用goroutine降低同时运行协程数
LinkBuffer:
	读写并行无锁,支持nocopy地流式读写
	高效扩缩容
	Nocopy Buffer池化,减少GC
Pool:
	引入内存池和对象池,减少GC开销


编解码优化:
Codegen:
	预计算并预分配内存,如果可以提前知道将要分配的内存大小,则一次分配完成,减少内存操作次数,包括内存分配和拷贝等
	Inline减少函数调用次数和避免不必要的反射操作等
	自研了Go语言实现的Thrift IDL解析和代码生成器,支持完善的Thrift IDL语法和语义检查,并支持了插件机制-Thriftgo

JIT(just in time,当一段代码即将第一次被运行的时候,再进行编译)
	JIT将编译过程移到了程序加载的阶段,一次性生成对应的编解码并执行
	传统维护代码需要首先将维护好的代码编译后上传,并要即时将代码的变更提醒到每一个调用者身上,比较的费时费力。当使用了JIT技术,维护代码就变得很简单,只需要不断地维护写好的代码即可,当调用者发现代码变更的时候,会自动进行编译运行
	基于JIT编译技术的高性能动态Thrift编解码器-Frugal

合并部署

image-20220531230455040

image-20220531230603108

当亲和性比较强的微服务被合并到同一台物理机上进行部署的时候,其各种通信、服务发现就需要响应的进行更改

通信协议这时候就会从网络IO更改为共享内存,服务发