PRC远程函数调用丨青训营笔记

121 阅读11分钟

这是我参与「第五届青训营 」笔记创作活动的第13天

RPC 框架的基本概念

RPC(Remote Procedure Call)远程过程调用,简单的理解是一个节点请求另一个节点提供的服务。

本地函数调用和 RPC 调用的区别

本地函数调用:

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
}
复制代码
  1. 将 a 和 b 的值压栈
  2. 通过函数指针找到 calculate 函数,进入函数取出栈中的值 2 和 3,将其赋予 x 和 y。
  3. 计算 x * y,并将结果存在 z
  4. 将 z 的值压栈,然后从 calculate 返回
  5. 从栈中取出 z 返回值,并赋值给 result。

远程函数调用(RPC - Remote Procedure Calls):

在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行,但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数,这时候需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。

RPC 的概念模型

概念模型组成:User、User-Stub、RPC-Runtime、Server-Stub、Server 提出于1984年 Nelson的论文《Implenmenting Remote Procedure Calls》

一次 RPC 的完整过程

一次 RPC.png

IDL(Interface description language)文件

IDL 通过一种中立的方式描述接口,使得在不同平台上运行的·对象和用不同语言编写的程序可以相互通信。

生成代码

通过编译器工具把 IDL 文件转换成语言对应的静态库。

编解码

从内存中表示到字节序列的转换称之为编码,反之为解码,也常叫做序列化和反序列化。

通信协议

规范了数据在网络中的传输内容和格式。除必须的请求/响应数据外,通常还会包含额外的元数据。

网络传输

  • IO 网络模型: 网络 IO 的本质是 socket的读取 ,socket在linux系统被抽象为流,IO 可以理解为对流的操作。

    • 阻塞 IO(blocking IO): 需要内核IO操作彻底完成后,才返回到用户空间,执行用户操作

    • 非阻塞 IO (non-blocking IO): 不需要内核IO操作彻底完成后,才返回到用户空间。

      阻塞/非阻塞指的是用户空间程序的执行状态

    • 多路复用 IO (IO multiplexing): 通过select/epoll系统调用,单个应用程序的线程,可以不断轮询成百上千的socket连接,当某个或者某些socket网络连接有IO就绪的状态,就返回对应的可以执行的读写操作。

    • 信号驱动 IO(signal driven IO,SIGIO): 当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。

    • 异步 IO(Asynchronous IO): 整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓存区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。

  • 传输层协议: 传输层的主要功能是实现分布式进程之间的通信。利用网络层提供的服务,在源主机的应用进程与目的主机的应用进程建立“端—端”连接。 传输层之间传输的报文称为“传输协议数据单元(TPDU)”,TPDU有效载荷称为应用层的数据

    • TCP(传输控制协议): TCP是传输控制协议是一种面向连接的、可靠的、基于字节流的传输层通信协议,由 IETF 的RFC 793定义。TCP 是面向连接的、可靠的流协议。流就是指不间断的数据结构,你可以把它想象成排水管中的水流。
    • UDP(用户数据报协议): UDP是一个简单的面向数据报的传输层协议。提供的是非面向连接的、不可靠的数据流传输。UDP不提供可靠性,也不提供报文到达确认、排序以及流量控制等功能。

PCR 的好处

  1. 单一职责,有利于分工协作和运维开发
  2. 可扩展性强,资源使用率更优
  3. 故障隔离,服务的整体可靠性更高

RPC 框架的分层设计

编解码层

以 Apache Thrift 为例 1.png 数据格式

  • 语言特定的格式: 许多编程语言都内建了将内存对象编码为字节序列的支持,例如java.io.Serializable。这种编码形式好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复。
  • 文本格式: JSON、XML、CSV等文件格式,具有人类可读性。
  • 二进制编码: 具有跨语言和高性能等优点,常见有 Thift 的 BinaryProtocol等。

二进制编码

TLV编码

  • Tag:标识字段的类型,占1个字节
  • Length:长度
  • Value:值,Value也可以是一个TLV结构
struct Person {
    1: required string        userName,
    2: optional i64           favoriteNumber,
    3: optional list<string>  interests
}
复制代码

上述结构的TLV编码如下:

image.png

TLV编码的结构简单清晰,并且扩展性较好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费。

选型

对于不同编解码协议的选择,主要有三点考虑:

  • 兼容性

    移动互联时代,业务系统需求的更新周期变快,新的需求不断涌现,而老的系统还是需要维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提升系统的灵活度。

  • 通用性

    • 技术层面

      序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就会大大降低。

    • 流行程度

      序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。

  • 性能

    • 空间开销(Verbosity)

      序列化需要在原有的数据上加上描述字段,来用于反序列化的解析。如果序列化的过程引入的额外开销过高,可能会导致过大的网络、磁盘方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的额外空间开销衣卫着高昂的成本。

    • 时间开销(Complexity)

      复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。

协议层

概念

协议是双方确定的交流语义,比如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。

消息切分

  • 特殊结束符:一个特殊字符作为每个协议单元结束的标示。除此之外必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱。

    message body \r\n message body \r\n

  • 变长协议:以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度

    length message body length message body

协议构造

  • LENGTH:字段 32 bits,数据包大小,不包含自身长度。

  • LEADER MAGIC:字段 16 bits,值为0×1000,用于标识 协议版本信息,协议解析的时候可以快速效验。

  • SEQUENCE NUMBER 字段 32 bits,表示数据包的 seqld,可用于多路复用,最好确保单个连接内递增。

  • HEADER SIZE:字段 16 bits,等于头部长度字节数/4,头部长度计算从第 14 个字节开始计算一直到 PAYLOAD 前。

  • PROTOTCOL ID:字段 uint8 编码

    • Binary = 0
    • Compact = 2
  • INFO ID:字段 uint8 编码 用于传递一些定制的 meta 的信息

  • PAYLOAD 消息体

网络通信层

Sockets API Sockets API.png

网络库

  • 提供易用 API

    • 封装底层 Socket API
    • 连接管理和事件分发
  • 功能

    • 协议支持:tcp、udp 和 uds 等
    • 优雅退出、异常处理等
  • 性能

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

RPC 框架的核心指标

稳定性

  • 保障策略

    • 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
    • 限流:保护被调用方,防止大流量把服务压垮
    • 超时控制:避免浪费资源在不可用节点上。
  • 请求成功率 重试.png

    • 负载均衡:重试会加大下游的负载。
    • 重试:为防止重试风暴,限制单点重试和限制链路重试。
  • 长尾请求: 一般是指明显高于均值的那部分占比较小的请求。常见于网络抖动、GC、系统调度

易用性

  • 开箱即用

    • 合理的默认参数选项、丰富的文档
  • 周边工具

    • 生成代码工具、脚手架工具

扩展性

  • Middleware
  • Option
  • 编解码层
  • 协议层
  • 网络传输层
  • 代码生成工具插件扩展

观测性

  • Log、Metric、Tracing
  • 内置观测性服务

高性能

  • 场景

    • 单机多机
    • 单连接多连接
    • 单/多client 单/多server
    • 不同请求类型:例如pingpong、streaming 等
  • 目标

    • 高吞吐
    • 低延迟
  • 手段

    • 连接池
    • 多路复用
    • 高性能编解码协议
    • 高性能网络库

Kitex 的企业实践分享

整体架构 - Kitex

整体架构.png

  • Kitex Core 核心组件:定义框架的层次结构、接口、还有接口的默认实现。如 client 和 server 是对用户暴露的,client/server option 的配置都是在这两个 package 中提供的,还有 client/server 的初始化。

    • client/server 下面的是框架治理层面的功能模块和交互信息,remote是与对端交互的模块,包含编解码是网络通信。
  • Kitex Byted 与公司内部基础设施集成:是对字节内部的扩展,byted 部分是在生成代码中初始化 client 和 cerver 时通过 suite 集成进来的,这样实现的好处是与字节内部特性解耦,方便后续开源拆分。

  • Kitex Tool 代码生成工具:里面包括idl解析、效验、代码生成、插件支持、自更新等。

自研网络库

背景

  • 原生库无法感知连接状态

    • 在使用连接池时,池中存在失效连接,影响连接池的复用。
  • 原生库存在 goroutine 暴涨的风险

    • 一个连接一个 goroutine 的模式,由于连接利用率低下,存在大量 goroutine 占用调度开销,影响性能。

Netpoll

  • 引入 epoll 主动监听机制,感知连接状态
  • 建立 goroutine 池,复用 goroutine
  • 引入 Nocopy Buffer,向上层提供 NoCopy 的调用接口,编解码层面零拷贝。

扩展性设计

支持多协议,也支持灵活的自定义协议扩展

  • 编解码支持thrift、Protobuf
  • 应用层协议支持TTHeader、Http2、也支持裸的thrift协议
  • 传输层目前支持TCP

性能优化

网络库优化

  • 调度优化

    • epoll_wait 在调度上的控制
    • gopool 重用 goroutine 降低同时运行协程数
  • LinkBuffer

    • 读写并行无锁,支持 nocopy 地流式读写
    • 高效扩缩容
    • Nocopy Buffer 池化,减少 GC
  • Pool

    • 引入内存池和对象池,减少GC 开销

编解码优化

  • Codegen

    • 预计算并预分配,减少内存操作次数,包括内存分配和拷贝
    • lnline 减少函数调用次数和避免不必要的反射操作等
    • 自研了 Go 语言实现的 Thrift IDL 解析和代码生成器,支持完善的 Thrift IDL 语法和语义检查,并支持了插件机制 - Thriftgo
  • JIT

    • 使用 JIT 编译技术改善用户体验的同时带来了更强的编解码性能,减轻用户维护生成代码的负担。
    • 基于 JIT 编译技术的高性能动态 Thrift 编解码器 - Frugal

合并部署

微服务过微,引入的额外的传输序列化开销越来越大。

将亲和性强的服务实例尽可能调度到同一个物理机,远程 RPC 调用优化本地 IPC 调用。