RPC 需要注意的一些实践

151 阅读11分钟

RPC 的全称是 Remote Procedure Call,即远程过程调用 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。

所以 RPC 一般默认采用 TCP 来传输。我们常用的 HTTP 协议也是建立在 TCP 之上的。 网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。 

调用方持续地把请求参数序列化成二进制后,经过 TCP 传输给了服务提供方。服务提供方从 TCP 通道里面收到二进制数据,那如何知道一个请求的数据到哪里结束,是一个什么类型的请求呢? 大多数的协议会分成两部分,分别是数据头和消息体。数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等。 根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。

RPC 框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远程方法。

我们知道只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。对于服务提供方应用来说,他会从 TCP 通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?

所以呢,为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。

这还要从 RPC 的作用说起,相对于 HTTP 的用处,RPC 更多的是负责应用间的通信,所以性能要求相对更高。但 HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;还有一个更重要的原因是,HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。

序列化:对象怎么在网络中传输?

网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以我们需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。 这时,服务提供方就可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程我们称之为“反序列化”。

总结来说,序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。

使用 JSON 进行序列化有这样两个问题,你需要格外注意:JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。所以如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。

ProtobufProtobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类,它的优点是:序列化后体积相比 JSON、Hessian 小很多;IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;序列化反序列化速度很快,不需要通过反射获取类型;消息格式升级和兼容性不错,可以做到向后兼容。

RPC框架在网络通信上io模型

常见的网络 IO 模型分为四种:同步阻塞 IO(BIO)、同步非阻塞 IO(NIO)、IO 多路复用和异步非阻塞 IO(AIO)。在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。

同步阻塞 IO

阻塞 IO(blocking IO)中同步阻塞 IO 是最简单、最常见的 IO 模型,在 Linux 中,默认情况下所有的 socket 都是 blocking 的,先看下操作流程。首先,应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。这里我们可以看到,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态,如果是基于 Java 多线程开发,那么每一个 IO 操作都要占用线程,直至 IO 操作结束。

io多路复用

多个网络连接的 IO 可以注册到一个复用器(select)上,当用户进程调用了 select,那么整个进程会被阻塞。同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。这里我们可以看到,当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。同样好比我们去餐厅吃饭,这次我们是几个人一起去的,我们专门留了一个人在餐厅排号等位,其他人就去逛街了,等排号的朋友通知我们可以吃饭了,我们就直接去享用了。

什么是零拷贝?

阻塞 IO 的时候我讲到,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。这里我们可以看到,一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据。

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样是不是很浪费 CPU 和性能呢?那有没有什么方式,可以减少进程间的数据拷贝,提高数据传输的效率呢?

零拷贝是为了避免用户空间和内核空间的来回拷贝

所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。

零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,mmap+write 方式的核心原理就是通过虚拟内存来解决的。

动态代理:面向接口编程,屏蔽RPC处理流程

服务注册发现

对于服务调用方和服务提供方来说,其契约就是接口,相当于“通信录”中的姓名,服务节点就是提供该契约的一个具体实例。服务 IP 集合作为“通信录”中的地址,从而可以通过接口获取服务 IP 的集合来完成服务的发现。这就是我要说的 RPC 框架的服务发现机制

服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。

服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。

服务健康检查-可用率计算

路由策略-负载均衡实现

实现自适应负载均衡

异常重试:在约定时间内安全可靠地重试

优雅关闭:如何避免服务停机带来的业务损失?

关闭由外到内;启动从内到外

优雅启动:如何避免流量打到没有启动完成的节点?

RPC 里面的一个实用功能——启动预热。

服务保护一般就是限流、熔断、降级。

流量回放

在团队中,我们经常是多个需求并行开发的,在开发新需求的过程中,我们还可能夹杂着应用的重构和拆分。每到这个时候,我们基本很难做到不改动老逻辑,那只要有改动就有可能会存在考虑不周全的情况。如果你比较严谨的话,那可能在开发完成后,你会把项目里面的 TestCase 都跑一遍,并同时补充新功能的 TestCase,只有所有的 TestCase 都跑通后才能安心

我可以先把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里重新请求一遍,最后比对一下改造前后的响应结果是否一致,这就间接达到了使用线上流量测试的效果。有了线上的请求参数和响应结果后,我们再结合持续集成过程,就可以让我们改动后的代码随时用线上流量进行验证,这就跟我录制球赛视频一样,只要我想看,我随时都可以拿出来重新看一遍。

如何实现像 TcpCopy、Nginx 等