第06课:RPC——一切架构的基础

第06课:RPC——一切架构的基础

现代架构设计离不开分布式系统,而远程过程调用(Remote Process Call,RPC)则是一切分布式架构的基础。RPC 架构是基本的远程通信架构,主要由网络通信、序列化/反序列化、传输协议和服务调用等四个核心组件所构成。在本篇中,我们对 RPC 架构进行剖析,得到如下的结构图,该结构图包括了分布式环境下的各个基本功能组件。

从上图中,可以看到 RPC 架构有左右对称的两大部分构成,分别代表了一个远程过程调用的客户端和服务器端组件。客户端组件与职责包括:

  • RpcClient,负责导入(import)由 RpcProxy 提供的远程接口的代理实现;
  • RpcProxy,远程接口的代理实现,提供远程服务本地化访问的入口;
  • RpcInvoker,负责编码和发送调用请求到服务方并等待结果;
  • RpcProtocol,负责网络传输协议的编码和解码;
  • RpcConnector,负责维持客户端和服务端连接通道和发送数据到服务端;
  • RpcChannel,网络数据传输通道。

而服务端组件与职责包括:

  • RpcServer,负责导出(export)远程接口;
  • RpcInvoker,负责调用服务端接口的具体实现并返回结果;
  • RpcProtocol,负责网络传输协议的编码和解码;
  • RpcAcceptor,负责接收客户方请求并返回请求结果;
  • RpcProcessor,负责在服务方控制调用过程,包括管理调用线程池、超时时间等;
  • RpcChannel,网络数据传输通道。

在 RPC 架构实现思路上,远程服务提供者以某种形式提供服务调用相关信息,远程代理对象通过动态代理拦截机制生成远程服务的本地代理,让远程调用在使用上就如同本地调用一样。下图所示的就是动态代理的基本结构图,可以看到通过在目标类的执行方法前后动态加入前置通知和后置通知就可以实现基本的拦截操作。

以 JDK 自带的动态代理机制为例,其执行过程时序图如下所示,涉及到的核心 Proxy 和 InvocationHandler 类在图中都有所体现。

而在本地接口代理对象中,访问远程服务网络的通信应该与具体协议无关,通过序列化和反序列化方式对网络传输数据进行有效传输。本篇接下去内容就对 RPC 的各个组件进行具体展开。

网络通信

网络通信是任何分布式系统的基础组件。网络通信本身涉及面很广,这里并不打算介绍网络通信相关的方方面面。对于分布式架构而言,网络通信关注于网络连接、IO 模型和可靠性设计。

网络连接

基于 TCP 协议的网络连接有两种基本方式,也就是通常所说的长连接(也叫持久连接,Persistent Connection)和短连接(Short Connection)。当网络通信采用 TCP 协议时,在真正的读写操作之前,Server 与 Client 之间必须建立一个连接,当读写操作完成后,双方不再需要这个连接时就可以释放这个连接。连接的建立需要三次握手,而释放则需要四次握手,每个连接的建立都意味着需要资源和时间的消耗。

当客户端向服务器端发起连接请求,服务器端接收请求,然后双方就可以建立连接。服务器端响应来自客户端的请求就需要完成一次读写过程,这时候双方都可以发起关闭操作,所以短连接一般只会在客户端/服务器端间传递一次读写操作,也就是说 TCP 连接建立后,数据包传输完成即关闭连接。短连接结构简单,管理起来比较简单,存在的连接都是有用的连接,不需要额外的控制手段。

长连接则不同,当客户端与服务器端完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。这样当 TCP 连接建立后,就可以连续发送多个数据包,能够节省资源并降低时延。

长连接和短连接的产生在于客户端和服务器端采取的关闭策略,具体的应用场景采用具体的策略,没有十全十美的选择,只有合适的选择。在 RPC 框架实现过程中,考虑到性能和服务治理等因素,通常使用长连接进行通信。

IO 模型

说到网络通信,就不得不提 IO 模型。现代操作系统都包括内核空间(Kernel Space)和用户空间(User Space),内核空间主要存放内核代码和数据,是供系统进程使用的空间;而用户空间主要存放的是用户代码和数据,是供用户进程使用的空间。一般的 IO 操作都分为两个阶段,以网络套接口(Socket)的输入操作为例,它的两个阶段包括内核空间和用户空间之间的数据传输,即首先等待网络数据到来,当数据分组到来时,将其拷贝到内核空间的临时缓冲区中,然后再将内核空间临时缓冲区中的数据拷贝到用户空间缓冲区中。围绕 IO 操作的这两个阶段,存在几种主流的 IO 操作模式(见下图)。

上图中每个模式对应的不同的处理方式和效果如下。

  • 阻塞 IO

阻塞 IO(Blocking IO,BIO)在默认情况下,所有套接口都是阻塞的,意味着 IO 的发起和结束都需等待。任何一个系统调用都会产生一个由用户态到内核态切换,再从内核态到用户态切换的过程,而进程上下文切换是通过系统中断程序来实现的,需要保存当前进程的上下文状态,这是一个成本很高的过程。

  • 非阻塞 IO

如果采用非阻塞 IO(Non-blocking IO,NIO),即当我们把套接口设置成非阻塞时,会由用户进程不停地询问内核某种操作是否准备就绪,这就是我们常说的轮询(Polling)。这同样是一件比较浪费 CPU 的方式。

  • IO 复用

IO 复用主要依赖于操作系统提供的 select 和 poll 机制。同样会阻塞进程,但是这里进程是阻塞在 select 或者 poll 这两个系统调用上,而不是阻塞在真正的 IO 操作上。另外还有一点不同于阻塞 IO 的就是,尽管看起来 IO 复用阻塞了两次,但是第一次阻塞是在 select 上时,select 可以监控多个套接口上是否已有 IO 操作准备就绪,而不是像阻塞 IO 那种,一次只能监控一个套接口。

  • 信号驱动 IO

信号驱动 IO 就是说我们可以通过 sigaction 系统调用注册一个信号处理程序,然后主程序可以继续向下执行,当我们所监控的套接口有 IO 操作准备就绪时,由内核通知触发前面注册的信号处理程序执行,然后将我们所需要的数据从内核空间拷贝到用户空间。

  • 异步 IO

异步 IO(Asynchronous IO,AIO)与信号驱动 IO 最主要的区别就是信号驱动 IO 是由内核通知我们何时可以进行 IO 操作,而异步 IO 则是由内核告诉我们 IO 操作何时完成了。具体来说就是,信号驱动 IO 中当内核通知触发信号处理程序时,信号处理程序还需要阻塞在从内核空间缓冲区拷贝数据到用户空间缓冲区这个阶段,而异步 IO 是在第二个阶段完成后内核直接通知可以进行后续操作。

结合各个 IO 模型的效果图,我们发现前四种 IO 模型的主要区别是在第一阶段,因为它们的第二阶段都是在阻塞等待数据由内核空间拷贝到用户空间;而异步 IO 很明显与前面四种有所不同,它在第一阶段和第二阶段都不会阻塞。

可靠性

由于存在网络闪断、超时等网络状态相关的不稳定性以及业务系统本身的故障,网络之间的通信必须在发生上述问题时能够快速感知并修复。常见的网络通信保障手段包括链路有效性检测以及断线之后的重连处理。

从原理上讲,要确保通信链路的可靠性就必须对链路进行周期性的有效性检测,通用的做法就是心跳(Heart Beat)检测。心跳检测通常有两种技术实现方式,一种是在 TCP 层通过建立长链接在发送方和接收方之间传递心跳信息;另一种则是在应用层,心跳信息根据系统要求可能包含一定的业务逻辑。

当发送方检测到通信链路中断,会在事先约定好的重连间隔时间之后发起重连操作,如果重连失败,则周期性的使用该间隔时间进行重连直至重连成功。

序列化

所谓序列化(Serialization)就是将对象转化为字节数组,用于网络传输、数据持久化或其他用途,而反序列化(Deserialization)则是把从网络、磁盘等读取的字节数组还原成原始对象,以便后续业务逻辑操作。

序列化的方式有很多,常见的有文本和二进制两大类。XML 和 JSON 是文本类序列化方式的代表,而二进制实现的方案包括 Google 的 Protocol Buffer 和 Facebook 的 Thrift 等。对于一个序列化实现方案而言,以下几方面的需求可以帮我们作出合适的选择。

功能

序列化基本功能的关注点在于所支持的数据结构种类以及接口友好性。数据结构种类体现在对泛型和 Map/List 等复杂数据结构的支持,有些序列化工具并不内置这些复杂数据结构。接口友好性涉及是否需要定义中间语言(Intermediate Language,IL),正如 Protocol Buffer 需要 .proto 文件、Thrift 需要 .thrift 文件,通过中间语言实现序列化一定程度上增加了使用的复杂度。

另一方面,在分布式系统中,各个独立的分布式服务原则上都可以具备自身的技术体系,形成异构化系统,而异构系统实现交互就需要跨语言支持。Java 自身的序列化机制无法支持多语言也是我们使用其他各种序列化技术的一个重要原因。像前面提到过的 Protocol Buffer、Thrift 以及 Apache Avro 都是跨语言序列化技术的代表。同时,我们也应该注意到,跨语言支持的实现与所支持的数据结构种类以及接口友好性存在一定的矛盾。要做到跨语言就需要兼容各种语言的数据结构特征,通常意味着要放弃 Map/List 等部分语言所不支持的复杂数据结构,而使用各种格式的中间语言的目的也正是在于能够通过中间语言生成各个语言版本的序列化代码。

性能

性能可能是我们在序列化工具选择过程中最看重的一个指标。性能指标主要包括序列化之后码流大小、序列化/反序列化速度和 CPU/内存资源占用。下表中我们列举了目前主流的一些序列化技术,可以看到在序列化和反序列化时间维度上 Alibaba 的 fastJSON 具有一定优势,而从空间维度上看,相较其他技术我们可以优先选择 Protocol Buffer。

/ 序列化时间 反序列化时间 大小 压缩后大小
Java 8654 43787 889 541
hessian 6725 10460 501 313
protocol buffer 2964 1745 239 149
thrift 3177 1949 349 197
json-lib 45788 149741 485 263
jackson 3052 4161 503 271
fastjson 2595 1472 468 251

兼容性

兼容性(Compatibility)在序列化机制中体现的是版本概念。业务需求的变化势必导致分布式服务接口的演进,而接口的变动是否会影响使用该接口的消费方、是否也需要消费方随之变动成为在接口开发和维护过程中的一个痛点。在接口参数中新增字段、删除字段和调整字段顺序都是常见的接口调整需求,类如 Protocol Buffer 就能实现前向兼容性确保调整之后新、老接口都能保持可用。

传输协议

ISO/OSI 网络模型分成 7 个层次,自上而下分别是应用层、表示层、会话层、传输层、网络层、数据链路层和物理层。其中传输层实现端到端连接、会话层实现互连主机通讯、表示层用于数据表示、应用层则直接面向应用程序。

RPC 架构的设计和实现通常会涉及传输层及以上各个层次的相关协议,通常所说的 TCP 协议就属于传输层,而 HTTP 协议则位于应用层。TCP 协议面向连接、可靠的字节流服务,可以支持长连接和短连接。HTTP 是一个无状态的面向连接的协议,基于 TCP 的客户端/服务器端请求和应答标准,同样支持长连接和短连接,但 HTTP 协议的长连接和短连接本质上还是 TCP 的连接模式。

我们可以使用 TCP 协议和 HTTP 协议等公共协议作为基本的传输协议构建 RPC 架构,也可以使用基于 HTTP 协议的 Web Service 和 RESTful 风格设计更加强大和友好的数据传输方式。但大部分 RPC 框架内部往往使用私有协议进行通信,这样做的主要目的在于提升性能,因为公共协议出于通用性考虑添加了很多辅助性功能,这些辅助性功能会消耗通信资源从而降低性能,设计私有协议可以确保协议尽量精简。另一方面,出于扩展性的考虑,具备高度定制化的私有协议也比公共协议更加容易实现扩展。当然,私有协议一般都会实现对公共协议的外部对接。实现自定义私有协议的过程可以抽象成一个模型,自定义协议的通信模型和消息定义、支持点对点长连接通信、使用 NIO 模型进行异步通信、提供可扩展的编解码框架以及支持多种序列化方式是该模型的基本需求。

传输协议的消息包括消息头(Header)和消息体(Payload)两部分,消息体表示需要传输的业务数据,而消息头用于进行传输控制,传输协议的这种设计方法体现的是“信封(Envelope)”思想(见下图),通过现有消息上添加一层信封来对消息进行包装,从而有效区分传输协议的各个层次。作为协议的元数据,我们可以在消息头中添加各种定制化信息形成自定义协议。

下图是 Dubbo 分布式框架中采用的私有 Dubbo 协议的定义方式,我们可以看到 Dubbo 协议在会话层中添加了自定义消息头,该消息头包括多协议支持和兼容的 Magic Code 属性、支持同步转异步并扩展消息头的 Id 属性等。

Dubbo 协议的设计者认为远程服务调用时间主要消耗在于传输数据包大小,所以 Dubbo 序列化主要优化目标在于减少数据包大小,提高序列化反序列化性能。可以从下图中看出 Dubbo 协议对数据包大小的处理优于大多数相关工具和框架。

服务调用

服务调用存在两种基本方式,即同步调用和异步调用。

同步调用

同步调用会造成业务线程阻塞,但开发和管理相对简单。对于同步调用而言,发起调用的服务线程发送请求到 IO 线程之后就一直处于等待阶段,直到 IO 线程完成与网络的读写操作之后被主动唤醒。

异步调用

使用异步调用的目的在于获取高性能,消息传递系统和事件驱动架构都是实现异步调用的常见策略,但都需要依赖于基础中间件平台。下图所示的就是事件驱动架构的一种实现方法,事件发布器发布事件,简单订阅者直接处理事件,表现为一个独立的事件处理程序,这样就形成了基本的异步调用模式。而即时转发订阅者对应于事件的分发和使用阶段,一方面可以具备简单订阅者的功能,另一方面也可以把事件转为给其他订阅者。通常,把事件转发到消息队列是一个好的实践方法,现有的很多消息传递系统具备强大的一对一和一对多转发功能,可能满足远程订阅者处理事件的需求,这样就把事件驱动与消息传递整合到了一起。另外,我们还可以添加事件存储订阅者,该订阅者在处理事件的同时对事件进行持久化。存储的事件可以作为一种历史记录,也可以通过专门的事件转发器转发到消息队列。

关于异步调用,JDK 中的 Future 模式也为我们实现调用发起者和调用响应者之间的解耦提供了另一种思路。Future 模式有点类似于商品订单,在网上购物提交订单后,在收货的这段时间里无需一直在家里等候,可以先干别的事情。类推到程序设计中,提交请求的目的是期望得到响应,但这个响应可能很慢。传统做法是一直等待到这个响应收到后再去做别的事情,但如果利用 Future 模式就无需等待响应的到来,在等待响应的过程中可以执行其他程序。传统调用和 Future 模式调用对比可以参考下图,我们可以看到在 Future 模式调用过程中,服务调用者得到服务消费者的请求时马上返回,可以继续执行其他任务直到服务消费者通知 Future 调用的结果,体现了 Future 调用异步化特点。

以上关于服务调用的内容对于 RPC 用户而言通常是透明的,但掌握这些基本概念有助于更好的开展技术选型以及理解后续分布式架构中的其他组件。

下篇导读

本篇主要围绕 RPC 架构的四大核心组件展开了详细讨论,RPC 架构的目的是为了实现远程通信,而这也是构建分布式系统的基础。在 RPC 的基础上,我们下一篇内容将探讨分布式架构,这也是目前架构设计领域最为核心的架构模式。

上一篇
下一篇
目录