深入浅出RPC框架 | 青训营笔记

111 阅读11分钟
  • 这是我参与「第五届青训营 」伴学笔记创作活动的第 14 天

1. 基本概念

1.1 本地函数调用

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

1.2 远程函数调用

RPC需要解决的问题:

  1. 函数映射

    在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。所以函数都有自己的一个ID,在做RPC的时候要附上这个ID,还得有个ID和函数的对照关系表,通过ID找到对应的函数并执行。

  2. 数据转换成字节流

  3. 网络传输

客户端怎么把参数值传给远程的函数?

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

1.3 RPC过程

  • IDL(Interface description language)文件:

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

  • 生成代码:

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

  • 编解码:

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

  • 通信协议:

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

  • 网络传输:

    通常基于成熟的网络库走TCP/UDP传输。

好处:

  • 单一职责,开发(采用不同的语言)、部署以及运维(上线独立)都是独立的,有利于分工协作和运维开发
  • 可扩展性强,例如压力过大的时候可以独立扩充资源,底层基础服务可以复用,节省资源
  • 故障隔离,服务的整体性可靠性更高

带来的问题:

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

2. 分层设计

2.1 编解码层

生成代码:依赖同一份IDL文件,生成不同语言的CodeGen

数据格式:

  • 语言特定编码格式:

    许多语言都内建了将内存对象编码为字节序列的支持。这种编码形式好处是非常方便,可以用很少的额外代码实现内存对象的保存与恢复,这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。安全和兼容性也是问题。

  • 文本格式:

    文本格式具有人类可读性,数字的编码多有歧义之处,比如XML和CSV不能区分数字和字符串,JSON虽然区分字符串和数字,但是不区分整数和浮点数,而且不能指定精度,处理大量数据时,这个问题更严重了;没有强制模型约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便。 由于JSON在一些语言中的序列化和反序列化需要采用反射机制,所以在性能比较差。

  • 二进制编码:

    具备跨语言和高性能等优点,常见有Thrift的BinaryProtocol,Protocolf等。实现可以有很多种,如TLV编码 和Varint编码。

    TLV编码

    • TLV编码结构简单清晰,并且扩展性较好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有不小的空间浪费
    • Tag:标签,可以理解为类型
    • Length:长度
    • Value:值,Value也可以是个TLV结构

选型:

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

  • 通用性:

    1. 技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就会大大降低
    2. 流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包
  • 性能:

    1. 空间开销(Verbosity),序列化需要在原有的数据上加上描述字段,以为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的的额外空间开销意味着高昂的成本
    2. 时间开销(Complexity),复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈

2.2 协议层

概念:

  • 协议是双方确定的交流语义。

    例如:我们设计一个字符串传输的协议,它允许客户端发送一个字符串,服务端接收到对应的字符串。这个协议很简单,首先发送一个4字节的消息总长度,然后再发送1字节的字符集charset长度,接下来就是消息的payload,字符集名称和字符串正文。

特殊结束符:

  • 过于简单,对于一个协议单元必须要全部读入才能够进行处理,除此之外必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱

变长协议:

  • 一般都是自定义协议,有header和payload组成,会以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度,使用比较广泛

协议构造:

  • LENGTH:字段32bits,包括数据包剩余部分的字节大小,不包含 LENGTH 自身长度
  • HEADER MAGIC:字段16bits,值为0x1000,用于标识协议版本信息,协议解析的时候可以快速校验 FLAGS 字段16bits,为预留字段,暂未使用,默认值为0x0000
  • SEQUENCE NUMBER:字段32bits,表示数据包的seqId,可用于多路复用,单连接内递增
  • HEADER SIZE:字段16bits,等于头部长度字节数/4,头部长度计算从第14个字节开始计算,一直到PAYLOAD前(备注:header的最大长度为64K)
  • PROTOCOL ID:字段uint8编码,取值有Binary、Compact两种
  • NUM TRANSFORMS:字段uint8编码,表示TRANSFORM个数
  • TRANSFORM ID:字段uint8编码,具体取值参考下文,表示压缩方式,如zlib、snappy
  • INFO ID:字段uint8编码,具体取值参考下文,用于传递一些定制的meta信息
  • PAYLOAD:消息体

协议解析:

-Peek->MagicNumber-Peek->PayloadCodec-Decode->Payload

2.3 网络通信层

网络库:

  • 提供易用API:

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

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

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

3. 关键指标

3.1 稳定性

保障策略

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

长尾请求:

  • 长尾请求一般是指明显高于均值的那部分占比较小的请求。 业界关于延迟有一个常用的P99标准,P99单个请求响应耗时从小到大排列,顺序处于99%位置的值即为P99值,那后面这1%就可以认为是长尾请求。在较复杂的系统中,长尾延时总是会存在。造成这个的原因非常多,常见的有网络抖动、GC、系统调度。

3.2 易用性

  • 开箱即用:

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

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

3.3 扩展性

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

3.4 观测性

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

3.5 高性能

  • 高吞吐
  • 低延迟

4. 企业实战

4.1 整体架构-Kitex

Kitex Core:

  • 主干逻辑,定义了框架的层次结构、接口,还有接口的默认实现

Kitex Byted:

  • 是对字节内部的扩展,集成了内部的二方库还有与字节相关的非通用的实现

Kitex Tool:

  • 代码生成工具

背景:

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

    在使用连接池时,池中存在失效连接、影响连接池的复用。

  • 原生库在goroutine暴涨的风险

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

  1. Go Net使用Epoll ET,Netpoll使用LT。
  2. Netpoll在大包场景下会占用更多的内存。
  3. Go Net只有一个Epoll事件循环(因为ET模式被唤醒的少,且事件循环内无需负责读写,所以干的活少),而Netpoll允许有多个事件循环(循环内需要负责读写,干的活多,读写越重,越需要开更多Loops)。
  4. Go Net一个连接一个Goroutine,Netpoll连接数和Goroutine数量没有关系,和请求数有一定关系,但是有Gopool重用。
  5. Go Net不支持Zero Copy,甚至于如果用户想要实现BufferdConnection这类缓存读取,还会产生二次拷贝Netpoll支持管理一个Buffer池直接交给用户,且上层用户可以不使用Read(p []byte)接口而使用特定零拷贝读取接口对Buffer进行管理,实现零拷贝能力的传递。

4.2 自研网络库

  • 解决无法感知连接状态的问题

    引入epoll主动监听机制,感知连接状态。

  • 解决goroutine暴涨的风险

    建立goroutine池,复用goroutine。

  • 提升性能

    引入Nocopy Buffer,向上层提供NoCopy的调用接口,编解码层面零拷贝。

扩展性设计:支持多协议,也支持灵活的自定义协议扩展

网络库优化:

  • 调度优化:

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

    • 读写并行无锁,支持nocopy地流式读写
    • Nocopy Buffer池化,减少GC
  • Pool:

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

编解码优化:

  • Codegen:

    预计算并分配内存,减少内存操作次数,包括内存分配和拷贝。

    Inline减少函数调用次数和避免不必要的反射操作等。

    自研了Go语言实现的Thrift解析和代码生成器,支持完善的Thrift IDL语法和语义检查,并支持插件机制-ThriftGo。

  • JIT:

    使用JIT编译技术改善用户体验的同时带来更强的编解码性能,减轻用户维护生成代码的负担。

    基于JIT编译技术的高性能动态Thrift编解码器-Frugal。

4.3 合并部署

微服务过微,传输和序列化开销越来越大。将亲和性强的服务实例尽可能调度到同一物理机,远程RPC调用优化为本地IPC调用。

  • 中心化的部署调度和流量控制
  • 基于共享内存的通信协议
  • 定制化的服务发现和连接池实现
  • 定制化的服务启动和监听逻辑