RPC | 青训营

97 阅读9分钟

RPC

函数调用 IPC

本地函数调用

image-20230603142823685.png

远程函数调用 RPC

image-20230603142919796.png 两个进程的地址空间,都有自己的ip,远程对应关系通过ip执行

在本地调用的时候只需要把数据压栈就可以了,但是在远程调用中是不同的地址,这时候需要客户端先将数据转换成字节流方便传输

保证网络搞笑稳定的传输数据

RPC是指远程过程调用,也就是说两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。

概念模型

image-20230603143834773.png

image-20230603143848390.png 左边的是调用的,右边是被调用的

User首先会发起一个本地调用,将参数打包给RPCRuntime,然后RPCRuntime会将数据传送给服务端

RPCRuntime接收完参数后将其交给server-stub进行解压,灾后在server中进行处理

返回流程相似

基本概念

image-20230603144301886.png

优缺点

优点
  1. 单一职责,有利于分工协作和运维开发(不同的服务可以由不同的语言进行开发)
  2. 可扩展性强,资源使用率更优(压力大时可以独立的扩充资源,且底层服务可以共享,如客户信息等)
  3. 故障隔离,服务的整体可靠性更高
缺点
  1. 服务宕机处理方案
  2. 调用过程中发生网络异常,如何保证消息的可达性
  3. 请求量徒增导致服务无法及时处理

以上问题可以由RPC框架来处理

分层设计

image-20230603145339098.png

编解码层

(生成层+编码器层)

生成代码

image-20230603145440927.png

数据格式

image-20230603145518431.png

二进制编码

image-20230603145654720.png 结构比较清晰,但是增加了tag等与value无关的字段,且一些字段开销比较大

编码格式选择
  1. 兼容性:支持自动增加新的字段 而不影响老的服务以提高系统的灵活性
  2. 通用性:支持跨语言 跨平台
  3. 性能:编码后数据大小和编码耗时

协议层

(生成+编码+协议)

image-20230603150823451.png

协议构造

image-20230603150911801.png

协议解析

image-20230603151055535.png 通过magicnumber知道具体是什么协议

payloadcodec读取编码方式,然后进行解码得到消息体

通信层

sockets API

介于应用层和传输层之间

image-20230603151520458.png 进行socket的时候必须知道IP和端口,通过bind操作获得,然后进行监听,然后放到队列中

再然后客户端如果有请求的时候服务端通过accept进行接收,如果没有请求的时候处于阻塞状态

网络库

image-20230603151913521.png

关键性能指标

稳定性

image-20230603152140166.png 熔断:a调用b b调用c 这时c出现问题,那么会影响整个链路,a会频繁的向b发送请求,可能会导致b宕机

image-20230603152317956.png 提高请求成功率的两种方式:

  1. 负载均衡:若a调用b 增b需要均匀的响应,以免某个节点压力过大
  2. 重试:

image-20230603152525170.png 很明显高于平均响应时间并且占比比较小的请求

t3表示99%请求都可以在这个时间段返回,如果没有返回的话 重新发送了一个resp2请求,用时t4 表明1是个长尾请求

长尾请求产生原因:

一种是依赖的系统和环境出现了问题,例如硬盘慢了, 操作系统执行了后台操作造成cpu调度走了。

第二种是自己的调度原因, 例如采用了简单的锁竞争机制,导致有的倒霉的线程一直得不到运行权。

第三种是系统压力太大,造成了任务队列积压。

解决方法:

作为外部的客户端来说, 一个通用的解决长尾请求的方案是发送备用请求, 例如对一个后端的数据请求, 本来只需要发送1次, 而我们分别给三个实例发送三次, 最快的一个返回即终止其余的两个请求即可。

image-20230603153123025.png

易用性

image-20230603153234696.png

扩展性

image-20230603153344655.png 中间件、参数

观测性

image-20230603153430384.png 通过日志、监控、追踪(超时 可以查看每个阶段的耗时)的方式进行观测

内置观测性服务:暴露一些中间件等内部运行状况,一般是http服务

高性能

image-20230603153712364.png 没有很好的衡量标准

企业实践

整体架构

image-20230603154159770.png

自研网络库-字节

背景

image-20230603154411905.png

goroutine是Go并发设计的核心,也叫协程,它比线程更加轻量,因此可以同时运行成千上万个并发任务。

不仅如此,Go语言内部已经实现了goroutine之间的内存共享,它比线程更加易用、高效和轻便。

在Go语言中,每一个并发的执行单元叫作一个goroutine。我们只需要在调用的函数前面添加go关键字,就能使这个函数以协程的方式运行。

一旦我们使用了go关键字,函数的返回值就会被忽略,故不能使用函数返回值来与主线程进行数据交换,而只能使用channel。

解决

image-20230603154732679.png

扩展性设计

image-20230603154818447.png 目前 Kitex 支持的交互模式和协议列表

模式\支持编码协议传输层协议
Pingpongthrift/protobufthrift/gRPC
Onewaythrift/protobufthrift
StreamingprotobufgRPC
  • Pingpong 发起一个请求等待一个响应
  • Oneway 发起一个请求不等待一个响应
  • Streaming 发起一个或多个请求, 等待一个或多个响应

gitee.com/weibo-25660…

性能优化

网络库优化

image-20230603155228481.png

编解码优化

image-20230603155326265.png

合并部署

image-20230603155512550.png

image-20230603155614466.png

HTTP协议

http协议

前后端分离框架

image-20230603155721377.png 前后端分离的框架需要使用http协议进行数据的传输

协议

image-20230603155931017.png 超文本:除了text外还可以传输视频音频

image-20230603160037370.png 结构

image-20230603160145614.png

image-20230603160709864.png

PATCH方法是新引入的,是对PUT方法的补充,用来对已知资源进行局部更新

局部更新: 假设我们有一个UserInfo,里面有userId, userName, userGender等10个字段。可你的编辑功能因为需求,在某个特别的页面里只能修改userName,这时候的更新怎么做? 人们通常(为徒省事)把一个包含了修改后userName的完整userInfo对象传给后端,做完整更新。但是比较浪费带宽

patch诞生,只传一个userName到指定资源去,表示该请求是一个局部更新,后端仅更新接收到的字段。

而put虽然也是更新资源,但要求前端提供的一定是一个完整的资源对象,理论上说如果你用了put,但却没有提供完整的UserInfo,那么缺了的那些字段应该被清空

请求头和响应头:有逻辑相关和业务相关,比如head就是逻辑相关,业务相关自己定义 (与数据有关)

demo

image-20230603160947338.png 给sis发送数据 需要返回OK的demo的代码

image-20230603160929011.png 请求流程

image-20230603161326360.png route:根据对应的api选择执行的handler

不足

image-20230603161415002.png http1

后面的分片需要等前面的分片到来才能发送数据(队头阻塞,TCP问题)

http2

二进制协议传输更加高效,但是没有解决队头阻塞的问题

分层设计

网络分层

image-20230603161735672.png http框架分层

image-20230603161844632.png 层与层之间使用接口进行解耦

Application:抽象、提供丰富的应用api 和用户直接打交道

中间件层:实现预处理和后处理的逻辑

路由层:注册寻址相关的操作

协议层:有了协议的抽象接口可以实现协议的扩展

网络层:不同网络使用场景不同

common: 放一些公共的文件

应用层

image-20230603162538873.png 可见性:

  1. 安全性:不能随意更改接口
  2. 接口使用复杂

中间件设计

image-20230603162716383.png 经典中间件模型

image-20230603162753484.png 预处理:日志、metrics(通用逻辑)

业务逻辑处理(核心逻辑)

后处理:(通用逻辑)

中间件定义

image-20230603163006323.png 调用链

image-20230603163205422.png

路由设计

image-20230603163515435.png 静态路由构建

image-20230603163634889.png 参数路由构建

image-20230603163714046.png 问题

image-20230603163837487.png

image-20230603163852941.png

如何设计

image-20230603163911325.png

协议层设计

抽象出合适的接口

image-20230603164301134.png 0. 不要吧context(上下文传递)存储在struct中,而是要通过函数的第一个参数传递进来 0. 返回值就行需要处理的数据

传输层

BIO和NIO

image-20230603164412632.png bio 阻塞io,必须read完成之后在业务逻辑处理,需要用户管理buffer

image-20230603165027919.png nio 注册一个监听器,坚挺到有足够的数据之后再进行业务逻辑处理,自动管理底层的buffer

image-20230603164740705.png

性能优化

网络库

主流操作系统都有网络支持,并且提供 C 语言接口的网络 API。

但是通常来说,这些网络 API 很底层,并不是很好用。举例来说,send() 函数只能传递 char* 对象过去,如果我是 std::string,或者 std::array<unsigned char,4096>(话说我就经常用这个做buffer),那就要自己转换了。而网络库往往会帮你做好这种封装,让你调用起来可以写更少的代码,代码里的逻辑不会因为调用了这些API而显得混乱。此外还能跨平台(原生网络API是不跨平台的,每个系统都要单独写一份),能使开发、维护都更有效率。

字节跳动网络库 netpollNetpoll 是一款 Go 语言高性能、I/O 非阻塞 (NIO) 网络库,专注于 RPC 场景。

go net问题

image-20230603165101198.png 优化

image-20230603165131109.png 大部分包都是在4k以下的

netpoll 问题

image-20230603165402951.png 采用链表的方式,实现buffer的无锁化,但是链表可能会导致跨节点的问题,如图header和body都不在同一个节点上,想要进行完整解析的话 需要先将两个header拼接并拷贝到一个完整的内存中才能解析

解决

image-20230603165858248.png 比较

image-20230603165943926.png

协议优化

header解析

image-20230603170404624.png 可以使用SIMD进行加速优化, SIMD 的全称是 Single Instruction Multiple Data,中文名“单指令多数据”。顾名思义,一条指令处理多个数据。

image-20230603170336372.png

热点资源池化

image-20230603170629120.png 如果对每一个请求有一个一个requestcontext,那么压力比较大

所以维护了一个requestcontext池 有一个请求就从池中拿出来一个requestcontext

image-20230603170832111.png

image-20230603170842454.png 将requestcontext放回尺子中 会有一些复杂的reset操作,因为需要进行复用

如果reset超出声明周期的话 可能会带来数据不一致的问题