RPC原理与实现 | 青训营笔记

86 阅读10分钟

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

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

1.2 远程函数调用(Remote Procedure Calls)

image.png

RPC需要解决的问题:

  • 函数映射
  • 数据转换为字节流
  • 网络传输

1.3 RPC 概念模型

image.png

1984年Nelson发表了论文Implementing remote procedure calls | ACM Transactions on Computer Systems,其中提出来RPC的过程由五个模型组成:User、User-Stub、RPC-Runtime、Server-Stub、Server。

1.4 一次RPC完整过程

image.png

  • IDL(Interface description language)文件:通过一种中立方式描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。
  • 生成代码:通过编译器把IDL文件转换为语言对应的静态库。
  • 编解码:从内存中表示到字节序列的转换称为编码,反之称为解码,也常叫做序列化、反序列化
  • 通信协议:规范了数据在网络中的传输内容和格式。除必须的请求/响应数据之外,通常还会包含额外的元数据。
  • 网络传输:通常基于成熟的网络库走TCP/UDP传输。

1.5 RPC的优缺点

优点

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

image.png

RPC带来的问题

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

-> 通过RPC框架进行解决

2. 分层设计

主要分三层:编解码层、协议层、网络通信层

Apache Thrift为例

image.png

2.1 编解码层

image.png

2.1.1 生成代码

image.png

2.1.2 数据格式

数据格式:

1. 语言特定的格式

许多编程语言都内建了将内存对象编码为字节序列的支持,例如Java有java.io.Serializable 

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

2. 文本格式

JSON、XML、CSV等文本格式,具有人类可读性 

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

3. 二进制编码

具备跨语言和高性能等优点,常见有Thrift的BinaryProtocol, Protobuf 等 

实现可以有很多种,TLV 编码 和 Varint 编码

TLV编码

  • Tag:标签,可以理解为类型

  • Lenght:长度

  • Value:值,Value也可以是个TLV结构

选型:

  • 兼容性

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

  • 通用性

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

  • 性能

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

2.2 协议层

image.png

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

2.2.1 概念 

  • 特殊结束符

一个特殊字符作为每个协议单元结束的标示。(过于简单,对于一个协议单元必须要全部读入才能够进行处理,除此之外必须要防止用户传输的数据不能同结束符相同,否则就会出现紊乱 HTTP 协议头就是以回车(CR)加换行(LF)符号序列结尾。)

  • 变长协议

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

2.2.2 协议构造

image.png

  • LENGTH(32bits) :数据包大小,不包含自身长度
  • HEADER MAGIC(16bits) :标识版本信息,协议解析时候快速校验
  • SEQUENCE NUMBER(32bits) :表示数据包的seqlD,可用于多路复用,单连接内递增
  • HEADER SIZE(16bits) :头部长度,从第14个字节开始计算一直到PAYLOAD前
  • PROTOCOL ID(unit8编码) :编解码方式,取值有Binary和Compact两种。
  • TRANSFORM ID(unit8编码) :压缩方式,如zlib和snappy
  • INFO ID(unit8编码) :传递一些定制的meta信息
  • PAYLOAD:消息体

image.png

2.3 网络通信层

image.png

2.3.1 Sockets API

image.png

image.png

套接字编程中的客户端必须知道两个信息:服务器的 IP 地址,以及端口号

socket函数创建一个套接字,bind 将一个套接字绑定到一个地址上。listen 监听进来的连接,backlog的含义有点复杂,这里先简单的描述:指定挂起的连接队列的长度,当客户端连接的时候,服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,backlog则指定这个队列的长度,accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。如果队列未满,客户端调用connect马上成功,如果满了可能会阻塞等待队列未满(实际上在Linux中测试并不是这样的结果,这个后面再专门来研究)。Linux的backlog默认是128,通常情况下,我们也指定为128即可。

connect 客户端向服务器发起连接,accept 接收一个连接请求,如果没有连接则会一直阻塞直到有连接进来。得到客户端的fd之后,就可以调用read, write函数和客户端通讯,读写方式和其他I/O类似:

  • read 从fd读数据,socket默认是阻塞模式的,如果对方没有写数据,read会一直阻塞着;

  • write 写fd写数据,socket默认是阻塞模式的,如果对方没有写数据,write会一直阻塞着;

socket关闭套接字,当另一端socket关闭后,这一端读写的情况:

  • 尝试去读会得到一个EOF,并返回0。

  • 尝试去写会触发一个SIGPIPE信号,并返回-1和errno=EPIPE,SIGPIPE的默认行为是终止程序,所以通常我们应该忽略这个信号,避免程序终止。

  • 如果这一端不去读写,我们可能没有办法知道对端的socket关闭了。

2.3.2 网络库

  • 提供易用API

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

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

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

3. 关键指标

  • 稳定性
  • 易用性
  • 扩展性
  • 观测性
  • 高性能

3.1 稳定性

3.1.1 保障策略

  • 熔断:保护调用方,防止被调用的服务出现问题而影响到整个链路
  • 限流:保护被调用方,防止大流量把服务压垮(降级处理/返回限流异常)
  • 超时控制:避免浪费资源在不可用节点上(超时主动停掉不太重要的业务) (以上三种都是快速返回,避免资源浪费在不可调用的请求上,也是服务降级的手段)

image.png

3.1.2 请求成功率

  • 负载均街
  • 重试(会加大直接下游的负载,有放大故障的风险。防止重试风暴,限制单点重试和限制链路重试。)

image.png

image.png

3.1.3 长尾请求

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

Normal Request:

image.png

Backup Request:

image.png

3.1.4 注册中间件

image.png

3.2 易用性

  • 开箱即用: 合理的默认参数选项、丰富的文档

  • 周边工具: 生成代码工具、脚手架工具

image.png

Kitex使用Suite来打包自定义的功能,提供「一键配置基础依赖」的体验

简单易用的命令行工具

  • 生成服务代码脚手架
  • 支持protobuf 和thrift
  • 内置功能丰富的选项
  • 支持自定义的生成代码插件

3.3 扩展性

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

image.png

3.4 观测性

  • Log、Metric、Tracing

  • 内置观测性服务

image.png

3.5 高性能

这里分两个维度,高性能意味着高吞吐低延迟,两者都很重要,甚至大部分场景下低延迟更重要。

image.png

多路复用可以大大减少了连接带来的资源消耗,并且提升了服务端性能,我们的测试中服务端吞吐可提升30%。

  • 调用端向服务端的一个节点发送请求,并发场景下,如果是非连接多路复用,每个请求都会持有一个连接, 直到请求结束连接才会被关闭或者放入连接池复用,并发量与连接数是对等的关系。

  • 而使用连接多路复用,所有请求都可以在一个连接上完成,连接资源利用上的差异明显。