解析Go语言RPC框架 | 青训营

101 阅读8分钟

一、基本概念

1、本地函数调用

如图所示,这是一个非常常见的本地函数调用的过程:

2、RPC调用

RPC调用即远程过程调用(Remote Procedure Call)。

比如双十一时我们在网上购物,有一个付款的操作。比如付款的时候要支付100块钱,那就需要调用一个支付服务。支付服务就会进行一些操作(把余额减100),最后返回一个code,提示付款成功的信息。

远程函数调用和本地函数调用的区别就是多了一个“远程”。“网上商城”和“支付服务”部署在两台不同的机器上,中间是需要通过网络来交互的。相比于本地函数调用,RPC调用有以下几个问题需要解决:

  1. 函数映射问题。 比如“网上商城”如何告诉“支付服务”要调用“付款”这个函数,而不是“退款”或“充值”。在本地调用时,可以通过函数指针来直接指定要调用的函数体,编译器通过这个函数指针就可以完成相对应函数的调用。但是在RPC调用中,肯定不能通过函数指针的方式进行调用。因为两个不同的服务香断关于是两个进程,两个进程的地址空间是完全不同的,所以要给每一个函数一个自己的ID。在进行RPC调用时要附上这个ID。因为ID与函数之间有对应关系,那么就可以通过ID来找到相应的函数,再去执行。
  2. 如何把参数提交给远程。 比如“网上商城”怎么把参数告诉“支付服务”。在本地调用时候只需压栈弹栈即可,但在RPC调用中,客户端和服务端是不同的进程,不能通过内存来传递参数。这时候就需要客户端将数据先转换成 字节流 ,再传递给服务端,最后再把字节流转换成自己能够读取的一个格式。
  3. 网络传输。 远程调用往往是发生在网络上的,如何保证在网络上高效、稳定地传输数据。

3、RPC的概念模型

1984年 Nelson 发表了论文《lmplementing Remote Procedure Calls》,其中提出了 RPC 的过程由 5个模型组成: User、User-Stub、RPC-Runtime、Server-Stub、Server。

对照这个模型图,一次RPC的调用的理论过程如下:

4、一次RPC调用的实际过程

上面解析了一次RPC调用的理论模型。我们再来解析一下实际过程。在这之前,简单介绍以下几个概念。

(1)IDL文件

远程函数调用时,调用方是不知道被调用方有哪些方法的,以及参数是什么样的。所以需要有一种方式来声明或者描述被调用方的方法签名。这样,大家就能按照这个约定来调用。

IDL文件(Interface description language)就是起这样一个规范的作用。IDL通过一种中立的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信。

(2)生成代码

刚才我们提到,服务双方是通过约定的规范进行远程调用,双方都依赖同一份IDL文件,这需要通过工具来生成对应的“生成文件”。

具体调用的时候,用户代码需要依赖“生成代码”,所以可以把用户代码和生成代码看做一个整体。

(3)编解码

从内存中表示的数据格式到字节序列的这样一个转换称为编码,反之为解码,也常叫做序列化和反序列化。

(4)通信协议

编解码只解决了数据交互的格式。但是要真正进行通讯,还需要通信协议。它规范了数据在网络中的传输内容和格式。除必须的请求/响应数据外,通常还会包含额外的元数据。

(5)网络传输

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

下面演示了具体的过程:

5、RPC的好处

抖音APP的一些服务调用关系如下:

可以总结出RPC的好处如下:

  1. 单一职责,有利于分工协作和运维开发。不同的服务可以采用不同的语言进行开发,以及它们各自的部署、上线都是独立的,也可以有不同的团队去维护。
  2. 可扩展性强,资源使用率更优。压力大的时候可以独立地去扩充资源。比如双十一的时候,直播间购物服务的压力比较大,那么就可以针对它进行扩容。至于视频,广告等服务就不需要额外扩容占用资源。同时,底层服务还可以复用,比如基础的个人信息,地理位置信息等服务。
  3. 故障隔离,服务的整体可靠性更高。某一个服务发生故障的时候,不会引起整体服务的崩溃。

6、RPC的弊端

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

这些问题,都可以交给RPC 框架来解决。

二、RPC框架的分层设计

RPC框架有三层:编解码层,协议层和网络通信层。

以 Apache Thrift 为例:

(client和server就是caller和callee,只是这是在框架中的叫法)

1、编解码层

(1)生成代码与客户端服务端的关系

(2)数据格式的分类

  1. 语言特定的格式。许多编程语言都内建了将内存对象编码为字节序列的支持,例如 Java 有java.io.Serializable。好处是非常方便,可以用很少的额外代码来实现内存对象的保存和恢复。缺点是存在兼容性问题。
  2. 文本格式。JSON、XML、CSV 等文本格式,好处是具有人类可读性,缺点是具有一些缺陷。比如json无法区分整数和浮点数,也不能处理精度。JSON在编程语言中要实现往往采用反射的机制,所以性能也存在一些问题。
  3. 二进制编码。把数据转换成二进制流。具备跨语言和高性能等优点,常见有 Thrift 的 BinaryProtocol,Protobuf 等。

(3)二进制编码之TLV编码

TLV是Tag,Length,Value三个单词的的简写。

  • Tag:标签,可以理解为类型。
  • Lenght:长度。
  • Value:值,Value 也可以是个TLV结构。

来看一个具体的例子。在本地IDE中定义一个结构体,即编码前的数据:

编码后:

优点是扩展性好,Value中也可以再嵌套一个TLV结构。缺点是增加了Tag和Length两个冗余信息,有额外的内存开销。

(4)编码格式的选型

需要考量的方面:

  1. 兼容性:支持自动增加新的字段,而不影响老的服务,这将提高系统的灵活度。
  2. 通用性:支持跨平台、跨语言。
  3. 性能:从空间和时间两个维度来考虑,也就是编码后数据大小和编码耗费时长。

2、协议层

(1)基本概念

有这两种常见的协议:

特殊结束符的协议是设定专门的标志位来标识结束的位置。比如HTTP协议就以回车+换行来作为特殊结束符。定长协议用一个Length来描述后面真正的消息体的长度。通过这个指定的长度值,也能知道读取的时候应该在哪里结束。

(2)协议构造

以 Apache Thrift 为例子来解析一下RPC的通信协议构造。

  1. LENGTH:数据包大小,不包含自身。

  2. HEADER MAGIC:标识版本信息,协议解析时候快速校验。

  3. SEQUENCE NUMBER:表示数据包的segID可用于多路复用(同一个连接中有多个请求流),单连接内递增。

  4. HEADER SIZE:头部长度,从第14个字节开始计算一直到 PAYLOAD(有效载荷能力,可以理解为消息体) 前。

  5. PROTOCOL ID:编解码方式,有 Binary(TLV) 和 Compact 两种。

  6. TRANSFORM ID:压缩方式(编码后要进行压缩),如 zlib 和 snappy。

  7. INFO ID:传递一些定制的 meta 信息。

  8. PAYLOAD:消息体。

框架中如何对协议进行解析?

首先从内存中读取指定的一部分数据。读取MagicNumber从而知道使用的是什么类型的协议,然后读取编码方式(这样才能确定用怎样的方式解码),然后解码得到Payload。

3、网络通信层

(1)socket API

Socket API介于应用层和传输层之间。

(2)网络库

网络库的衡量指标有:

  1. 提供易用 API:封装底层 Socket API;连接管理和事件分发。
  2. 功能:协议支持 tcp、udp 和 uds 等;优雅退出、异常处理等。
  3. 性能:应用层 buffer 减少 copy;高性能定时器、对象池等。