分布式调用 - 服务间的远程调用RPC
导图
服务和应用的调用基于场景的不同会分为几种情况
-
系统外的客户端调用系统内的服务时需要通过反向代理和负载均衡的方式; ------负载均衡
-
系统架构内部服务之间的调用需要通过 API 网关; ------API 网关
-
服务之间的互相感知需要用到服务注册与发现; ------服务注册与发现
-
服务之间的通信会使用 RPC 架构,RPC 的核心原理以及 Netty 的最佳实践 ------服务间的远程调用
Pre
RPC 概述
无论 API 网关,还是服务注册和发现,都在探讨服务与服务如何发现对方、如何选择正确路径进行调用,描述的是服务之间的关系。厘清关系后,我们再来谈谈服务之间的调用是如何完成的。
在分布式系统中,应用或者服务会被部署到不同的服务器和网络环境中,特别是在有微服务的情况下,应用被拆分为很多个服务,每个服务都有可能依赖其他服务。
假设客户端调用下单服务时,还会调用商品查询服务、扣减库存服务、订单更新服务,如果这三个服务分别对应三个数据库,那么一次客户端请求就会引发 6 次调用,要是这些服务或者数据库都部署在不同的服务器或者网络节点,这 6 次调用就会引发 6 次网络请求。因此,分布式部署方式在提高系统性能和可用性的前提下,对网络调用效率也发起了挑战。
为了面对这种挑战,需要选择合适的网络模型,对传输的数据包进行有效的序列化,调整网络参数优化网络传输性能。为了做到以上几点我们需要引入 RPC,下面就来介绍 RPC 是如何解决服务之间网络传输问题
RPC 调用过程
RPC 是 Remote Procedure Call(远程过程调用)的缩写,该技术可以让一台服务器上的服务通过网络调用另一台服务器上的服务,简单来说就是让不同网络节点上的服务相互调用。因此 RPC 框架会封装网络调用的细节,让调用远程服务看起来像调用本地服务一样简单。
由于微服务架构的兴起,RPC 的概念得到广泛应用,在消息队列、分布式缓存、分布式数据库等多个领域都有用到。可以将 RPC 理解为连接两个城市的高速公路,让车辆能够在城市之间自由通行。由于 RPC 屏蔽了远程调用和本地调用的区别,因此程序开发者无须过多关注网络通信,可以把更多精力放到业务逻辑的开发上.
上图描述了服务调用的过程,这里涉及左侧的服务调用方和右侧的服务提供方。既然是服务的调用过程,就存在请求过程和响应过程,这两部分用虚线圈出来了。
- 从图左侧的服务调用方开始,利用“动态代理”方式向服务提供方发起调用,这里会制定服务、接口、方法以及输入的参数;
- 将这些信息打包好之后进行“序列化”操作,由于 RPC 是基于 TCP 进行传输的,因此在网络传输中使用的数据必须是二进制形式,序列化操作就是将请求数据转换为二进制,以便网络传输;
- 打好二进制包后,需要对信息进行说明,比如协议标识、数据大小、请求类型等,这个过程叫作“协议编码”,说白了就是对数据包进行描述,并告诉数据接收方数据包有多大、要发送到什么地方去。
至此,数据发送的准备工作就完成了,数据包会通过“网络传输”到达服务提供方
- 服务提供方接收到数据包以后,先进行“协议解码”,并对解码后的数据“反序列化”,然后通过“反射执行”获取由动态代理封装好的请求
- 此时随着箭头到了图的最右边,顺着向下的箭头,服务提供方开始“处理请求”,处理完后就要发送响应信息给服务调用方了,之后的发送过程和服务调用方发送请求的过程是一致的,只是方向相反,依次为序列化→协议编码→网络传输→协议解码→反序列化→接收响应”。
以上便是整个 RPC 调用的请求、响应流程。
分析上述的 RPC 调用流程后,发现无论是服务调用方发送请求,还是服务提供方发送响应,有几个步骤都是必不可少的,分别为动态代理、序列化、协议编码和网络传输
RPC 动态代理
服务调用方访问服务提供方的过程是一个 RPC 调用。作为服务调用方的客户端通过一个接口访问作为服务提供方的服务端,这个接口决定了访问方法和传入参数,可以告诉客户端如何调用服务端,实际的程序运行也就是接口实现是在客户端进行的。
RPC 会通过动态代理机制,为客户端请求生成一个代理类,在项目中调用接口时绑定对应的代理类,之后当调用接口时,会被代理类拦截,在代理类里加入远程调用逻辑即可调用远程服务端。
来看个例子:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
// 1. 接口命名规范
public interface ServerProvider {
public String sayHello(String name);
}
// 2. 实现类命名规范
public class ServerProviderImp implements ServerProvider {
@Override
public String sayHello(String name) {
System.out.println(name);
return name;
}
}
// 3. 动态代理类,遵循标准命名和格式
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class DynamicProxy implements InvocationHandler {
private final Object realObject;
/**
* 创建DynamicProxy实例,用于代理真实对象
*
* @param realObject 被代理的真实对象
*/
public DynamicProxy(Object realObject) {
this.realObject = realObject;
}
/**
* 当代理对象被调用时,此方法将被调用.
* 它的主要作用是将方法调用委托给实际对象.
*
* @param proxy 代理对象,通常我们不会直接使用它.
* @param method 被调用的方法对象,它代表了实际要执行的方法.
* @param args 方法参数,传递给实际方法的参数.
* @return 返回的是实际方法执行后的结果.
* @throws Throwable 如果实际方法调用过程中抛出了异常,此方法也会抛出.
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 这里可以在方法调用前后添加日志或其他操作
System.out.println("Before invoking method: " + method.getName());
// 委托实际对象的方法调用,传递相同的参数,返回实际方法调用的结果.
Object result = method.invoke(realObject, args);
System.out.println("After invoking method: " + method.getName());
return result;
}
}
// 4. 客户端测试代码
public class Client {
/**
* 使用Java动态代理来代理真实的服务提供者
* 动态代理允许我们在运行时创建遵守现有接口行为的新类
*/
public static void main(String[] args) {
// 创建真实的服务提供者实例
ServerProvider realSeverProvider = new ServerProviderImp();
// 创建动态代理实例,它将代理真实的服务提供者
InvocationHandler handler = new DynamicProxy(realSeverProvider);
// 通过Java反射机制创建代理对象
// 这个代理对象将拦截并处理所有通过代理发送的方法调用
ServerProvider severProvider = (ServerProvider) Proxy.newProxyInstance(
handler.getClass().getClassLoader(),
realSeverProvider.getClass().getInterfaces(),
handler);
// 通过代理对象调用服务提供者的方法
System.out.println(severProvider.sayHello("artisan"));
}
}
1. 接口定义 (SeverProvider
)
public interface ServerProvider {
public String sayHello(String name);
}
ServerProvider
是一个服务提供者接口,定义了一个sayHello
方法,接收一个String
类型的参数str
。这个接口是服务端暴露的远程调用接口。(声明服务端,也就是服务提供者 ServerProvider,这是一个接口,其中定义了 sayHello方法)
2. 实现类 (ServerProviderImpl
)
public class ServerProviderImp implements ServerProvider {
@Override
public String sayHello(String name) {
System.out.println(name);
return name;
}
}
- 定义
ServerProviderImpl
类,实现ServerProvider
接口,其中 sayHello方法的功能是打印传入的参数。我们假设 ServerProvider和 ServerProviderImpl都定义在远程服务端
3. 动态代理类 (DynamicProxy
)
此时有一个客户端要调用第②步中远程服务里的 sayHello
方法,需要借助一个代理类(因为服务部署在远端,但是客户端需要在本地调用它,所以需要用代理类)。
于是定义代理类 DynamicProxy
,它实现了 InvocationHandler
接口(每一个动态代理类都必须实现 InvocationHandler
接口)
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class DynamicProxy implements InvocationHandler {
/**
* 定义变量 realObject,用于存放在代理类的构造函数中接收的需要代理的真实对象。注意这就是实现所谓的动态代理
**/
private final Object realObject;
/**
* 创建DynamicProxy实例,用于代理真实对象
*
* 代理类的构造函数 DynamicProxy的功能是将代理类和真实调用者(服务端实例)绑定到一起,绑定后就可以通过直接调用代理类的方式完成对远程服务端的调用
* 每个代理类的实例,都会关联到一个 handler,其中代理类的实例指的是需要代理的真实对象,也就是服务端实例;handler实际就是 DynamicProxy类,它在实现 InvocationHandler接口后通过反射的方式调用传入真实对象的方法,由于是通过反射,因此对真实对象没有限定,成为动态代理
* @param realObject 被代理的真实对象
*/
public DynamicProxy(Object realObject) {
this.realObject = realObject;
}
/**
* 当代理对象被调用时,此方法将被调用.
* 它的主要作用是将方法调用委托给实际对象.
*
* 当代理对象(DynamicProxy)调用真实对象的方法时,调用会被转发到 invoke方法中执行。在 invoke方法中,会针对真实对象调用对应的方法并执行
*
* @param proxy 代理对象,通常我们不会直接使用它.
* @param method 被调用的方法对象,它代表了实际要执行的方法.
* @param args 方法参数,传递给实际方法的参数.
* @return 返回的是实际方法执行后的结果.
* @throws Throwable 如果实际方法调用过程中抛出了异常,此方法也会抛出.
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 这里可以在方法调用前后添加日志或其他操作
System.out.println("Before invoking method: " + method.getName());
// 委托实际对象的方法调用,传递相同的参数,返回实际方法调用的结果.
Object result = method.invoke(realObject, args);
System.out.println("After invoking method: " + method.getName());
return result;
}
}
DynamicProxy
类实现了InvocationHandler
接口,后者是 Java 动态代理的核心接口。DynamicProxy
的构造方法接收一个Object
类型的realObject
参数,表示需要代理的真实对象(在这里是ServerProviderImpl
)。invoke
方法负责处理所有通过代理对象调用的方法。当代理对象的方法被调用时,invoke
方法会被触发,它通过反射(method.invoke(realObject, args)
)调用真实对象的方法。
4. 客户端 (Client
)
- 对于客户端而言,只需要指定服务端对应的调用接口,这个接口服务端就会通过某种方式暴露出来。所以,这里首先声明要调用的真实服务端
realSeverProvider
- 客户端只知道接口的名称,以及要传入的参数,通过这个接口生成调用的实例。然后在客户端声明一个代理类(DynamciProxy),其将真实对象的实例在构造函数中进行绑定。所以此处在动态代理初始化的时候,将真实服务提供者传入,表明对这个对象进行代理
- 再通过 newProxyInstance方法生成代理对象的实例,最后调用实际方法sayHello完成整个调用过程
通过 Proxy
的 newProxyInstance
创建代理对象的几个参数的定义如下。
- 第一个参数
handler.getClass().getClassLoader()
,我们这里使用 handler类的 ClassLoader对象来加载代理对象。 - 第二个参数
realSeverProvider.getClass().getInterfaces()
,指定服务端(也就是真实调用对象)的接口。 - 第三个参数
handler
,将代理对象关联到InvocationHandler
上
/**
* 使用Java动态代理来代理真实的服务提供者
* 动态代理允许我们在运行时创建遵守现有接口行为的新类
*/
public static void main(String[] args) {
// 创建真实的服务提供者实例
ServerProvider realSeverProvider = new ServerProviderImp();
// 创建动态代理实例,它将代理真实的服务提供者
InvocationHandler handler = new DynamicProxy(realSeverProvider);
// 通过Java反射机制创建代理对象
// 这个代理对象将拦截并处理所有通过代理发送的方法调用
ServerProvider severProvider = (ServerProvider) Proxy.newProxyInstance(
handler.getClass().getClassLoader(),
realSeverProvider.getClass().getInterfaces(),
handler);
// 通过代理对象调用服务提供者的方法
System.out.println(severProvider.sayHello("artisan"));
}
- 步骤解析:
realSeverProvider
创建了真实的服务端对象ServerProviderImpl
,这是一个实现了ServerProvider
接口的具体实例。handler
是DynamicProxy
的实例,它被用来处理代理对象的所有方法调用。- 通过
Proxy.newProxyInstance
创建动态代理对象。这个方法需要三个参数:- ClassLoader:
handler.getClass().getClassLoader()
用来加载代理类。 - Interfaces:
realSeverProvider.getClass().getInterfaces()
获取真实对象实现的接口,这里是SeverProvider
。 - InvocationHandler:
handler
,指定动态代理的处理逻辑。
- ClassLoader:
- 最后,通过代理对象
severProvider
调用sayHello("world")
,实际上调用的是DynamicProxy
中的invoke
方法,间接执行ServerProviderImpl
的sayHello
方法。
5. 代码工作流程
- 客户端 通过
Proxy.newProxyInstance
创建一个代理对象,并指定代理类的InvocationHandler
。 - 当客户端调用代理对象的方法时,代理对象的
invoke
方法被触发。 - 在
invoke
方法中,调用method.invoke(realObject, args)
来执行真实对象的方法。
6. 总结和注意点
- 动态代理:动态代理的核心在于
InvocationHandler
接口,代理类不会直接实现接口中的方法,而是通过反射机制调用真实对象的方法,这样就实现了方法调用的“拦截”。 - 代理机制:这段代码是一个简单的 Java 动态代理实现,通过代理对象使得客户端可以间接调用服务端实现的远程方法。
7. 结果输出
Before invoking method: sayHello
artisan
After invoking method: sayHello
artisan
这段代码展示了 Java 动态代理的基本使用,它使得客户端能够通过代理对象调用服务端的实际方法,同时提供了灵活的拦截和增强功能。
8. 小结
在客户端和服务端之间加入了一层动态代理,这个代理用来代理服务端接口。客户端只需要知道调用服务端接口的方法名字和输入参数就可以了,并不需要知道具体的实现过程。
在实际的 RPC 调用过程中,在客户端生成需要调用的服务端接口实例,将它丢给代理类,当代理类将这些信息传到服务端以后再执行。因此,RPC 动态代理是对客户端的调用和服务端的执行进行了解耦,目的是让客户端像调用本地方法一样调用远程服务。
RPC 序列化
序列化是将对象转化为字节流的过程,RPC 客户端在请求服务端时会发送请求的对象,这个对象如果通过网络传输,就需要进行序列化,也就是将对象转换成字节流数据。
反过来,在服务端接收到字节流数据后,将其转换成可读的对象,就是反序列化。如果把序列化比作快递打包的过程,那么收到快递后拆包的过程就是反序列化。
序列化和反序列化的核心思想是设计一种序列化、反序列化规则,将对象的类型、属性、属性值、方法名、方法的传入参数等信息按照规定格式写入到字节流中,然后再通过这套规则读取对象的相关信息,完成反序列化操作。
下面罗列几种常见的序列化方式供大家参考
1. JSON (JavaScript Object Notation)
JSON 是一种轻量级的数据交换格式,易于人类阅读和编写,也易于机器解析和生成。它采用键值对的结构,广泛应用于Web和微服务中,尤其是RESTful API中。
-
优点:
- 易于理解和使用,特别适用于与前端的交互。
- JSON在大多数编程语言中都有非常好的支持,跨语言使用非常方便。
- 支持自定义结构,灵活度高。
-
缺点:
- 相比二进制序列化,JSON的传输效率较低,占用的空间较大。
- 序列化和反序列化速度较慢,尤其对于复杂对象的处理。
-
应用场景:
- 适用于轻量级的、非性能敏感的场景,如Web API交互。
2. Hessian
Hessian 是由Caucho Technology开发的一种二进制Web服务协议,它具有高效的序列化和反序列化能力,并且可以支持多种编程语言。Hessian旨在提供一种既简洁又高效的RPC通信协议,广泛应用于Java应用中。
-
优点:
- 高效的二进制序列化,通常比JSON小且快速。
- 支持跨语言通信,原生支持Java,但也有其他语言的支持库。
- 支持复杂类型,包括Java对象的嵌套类型。
-
缺点:
- 相对而言,开发和调试不如JSON方便,缺乏可视化工具。
- 需要特定的解析库,跨语言使用时可能需要额外的配置。
-
应用场景:
- 适用于Java生态系统中的RPC通信,尤其在高性能需求的情况下。
3. Protobuf (Protocol Buffers)
Protobuf 是由Google开发的一种语言中立、平台中立、可扩展的序列化结构化数据的协议。它是一种高效、紧凑的二进制序列化协议,适用于跨语言、跨平台的RPC系统。
-
优点:
- 高效的二进制格式,序列化和反序列化速度非常快,占用空间小。
- 支持复杂数据结构,且有很好的向前和向后兼容性。
- 支持多种编程语言,跨平台能力强。
-
缺点:
- 相比于JSON,Protobuf的格式不够人类可读,调试和理解困难。
- 需要定义
.proto
文件,并生成代码,增加了开发的复杂度。
-
应用场景:
- 高性能要求的RPC通信,尤其适用于大数据、微服务架构等领域。
- 分布式系统中的数据交换,尤其是在需要跨语言和跨平台的环境中。
4. Thrift
Thrift 是由Facebook开发的一种跨语言的序列化协议和RPC框架,它既支持二进制格式也支持JSON格式,支持多种编程语言,并且为不同的语言生成代码。
-
优点:
- 支持多种编程语言,灵活的跨平台能力。
- 提供了高效的二进制序列化格式,具有很好的性能。
- 支持RPC和丰富的接口定义,可以灵活扩展。
-
缺点:
- 配置和使用复杂,尤其是在支持多语言时需要一定的学习成本。
- 相较于Protobuf,Thrift的序列化协议设计稍微复杂,可能不如Protobuf简洁。
-
应用场景:
- 适用于需要支持多语言和复杂结构的分布式系统。
- Facebook和其他大规模互联网公司使用Thrift来进行高效的服务间通信。
总结
序列化方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
JSON | 易于理解和使用,广泛支持 | 性能较差,数据量较大 | Web应用、前后端交互 |
Hessian | 高效的二进制序列化,跨语言支持 | 不如JSON直观,调试困难 | 高效的Java服务通信 |
Protobuf | 高效紧凑,支持多语言,良好的向前/向后兼容性 | 不人类可读,需要定义.proto 文件 | 高性能分布式系统,微服务架构 |
Thrift | 支持多语言,高效的二进制协议 | 配置复杂,设计相对复杂 | 大规模分布式系统,高效RPC通信 |
选择合适的序列化协议时,应该根据项目的具体需求(如性能要求、语言支持、数据结构复杂性等)进行权衡。如果需要更高的性能,Protobuf或Thrift是更好的选择;如果优先考虑易用性和可读性,JSON可能更适合。
协议编码
有了序列化功能,就可以将客户端的请求对象转化成字节流在网络上传输了,这个字节流转换为二进制信息以后会写入本地的 Socket 中,然后通过网卡发送到服务端。
从编程角度来看,每次请求只会发送一个请求包,但是从网络传输的角度来看,网络传输过程中会将二进制包拆分成很多个数据包,这一点也可以从 TCP 传输数据的原理看出。拆分后的多个二进制包会同时发往服务端,服务端接收到这些数据包以后,将它们合并到一起,再进行反序列化以及后面的操作。
实际上,协议编码要做的事情就是对同一次网络请求的数据包进行拆分,并且为拆分得到的每个数据包定义边界、长度等信息。如果把序列化比作快递打包过程,那么协议编码更像快递公司发快递时,往每个快递包裹上贴目的地址和收件人信息,这样快递员拿到包裹以后就知道该把包裹送往哪里、交给谁。当然这只是个例子,RPC 协议包含的内容要更为广泛。
RPC 协议的消息设计格式
在网络协议设计中,消息头是传输过程中非常重要的一部分。
1. 魔术位(Magic)
- 作用:魔术位用于标识协议的开始,帮助解码器快速识别消息的起始位置。通常,它是一个特定的字节序列,用来验证消息是否符合协议格式。
- 示例:
0xCAFEBABE
或其他独特的标识符,用于标记协议消息的开始。
2. 消息头长度(Header Size)
- 作用:用于描述消息头的长度,以便接收方能准确跳过消息头部分,定位到消息体部分。它是协议灵活扩展的关键。
- 示例:如果消息头可能有不同字段,或者未来可能增加新的字段,消息头长度可以动态调整。
3. 协议版本(Version)
- 作用:协议版本用于标识当前消息的协议版本号,确保客户端和服务器之间在版本不兼容时能够进行有效的协议升级或降级。
- 示例:
1.0
,2.0
等。协议版本可以帮助处理不同版本的协议兼容性问题。
4. 消息体序列化类型(ST)
- 作用:标识消息体内容的序列化格式,帮助接收方解码时知道如何解析消息体。这对系统的扩展性和兼容性非常重要。
- 示例:
0x01
表示 JSON,0x02
表示 Protocol Buffers(gRPC),0x03
表示 XML 等。
5. 心跳标记(HB)
- 作用:在长连接中,定期发送心跳消息用于确认连接是否依然有效。这个标记通常会出现在消息头中,用于告知接收方当前消息是否为心跳包。
- 示例:
0x01
表示是心跳消息,0x00
表示不是。
6. 单向消息标记(OW)
- 作用:标识消息是否为单向消息。单向消息通常指的是无响应的消息,它不会等待或期待接收方的返回。
- 示例:
0x01
表示单向消息,0x00
表示双向消息。
7. 响应消息标记(RP)
- 作用:标记当前消息是否是响应消息。响应消息通常是针对请求消息的反馈。
- 示例:
0x01
表示是响应消息,0x00
表示请求消息。
8. 响应消息状态码(Status Code)
- 作用:标记响应消息的状态或处理结果。它通常用来表示成功或失败的状态,以及错误的类型或原因。
- 示例:
200
表示成功,404
表示未找到,500
表示服务器错误。
9. 保留字段(Reserved)
- 作用:为了确保未来的协议扩展能够平滑过渡,通常会在协议头中保留一些未使用的字段。它们的存在是为了让协议在未来版本中可以继续扩展,而不会影响现有功能。
- 示例:通常使用
0x00
或空值填充。
10. 消息 ID(Message ID)
- 作用:消息 ID 是每个消息的唯一标识符,用于跟踪请求和响应的匹配,或者用于处理消息的重发、去重等操作。
- 示例:
UUID
、自增序列号
等。
11. 消息体长度(Body Size)
- 作用:描述消息体的实际长度。它用于接收方判断消息体的起始位置和结束位置,避免解析错误。
- 示例:如果消息体是一个大文件或大型数据结构,使用此字段帮助解码器正确解析消息体。
消息头格式示例
考虑一个简化的协议头设计, 格式可能类似于:
| Magic | Header Size | Version | ST | HB | OW | RP | Status Code | Reserved | Message ID | Body Size |
|-------|-------------|---------|----|----|----|----|-------------|----------|------------|-----------|
| 0xCAFEBABE | 16 | 1.0 | 0x01 | 0x00 | 0x01 | 0x00 | 200 | 0x00 | UUID12345 | 1024 |
假设有一个协议头字段“心跳标记(HB)”,它的含义如下:
0x01
(心跳包):当前数据包是一个心跳请求,目的是确认连接是否仍然活跃。0x00
(非心跳包):当前数据包不是心跳请求,而是业务数据。
协议头示例:
魔术位 (Magic) | 消息头长度 (Header Size) | 心跳标记 (HB) | 版本号 (Version) | 消息体长度 (Body Size) |
---|---|---|---|---|
0xCAFEBABE | 16 | 0x01 | 1.0 | 8 |
0x01
表示这是一个心跳包,用于保持连接活跃或检查连接状态。
总结
这些字段为协议设计提供了必要的扩展性和兼容性,确保数据传输的稳定性、灵活性以及高效性。在不同的应用场景中,这些字段的具体实现和功能可以根据需求做适当调整。例如,在一个高性能分布式系统中,心跳标记和消息ID尤为重要,用来保持连接活跃并确保请求与响应的匹配。在版本兼容性和扩展性方面,协议版本和保留字段为未来的改进奠定了基础。
消息头主要负责描述消息本身,其内容甚至比上面提到的更加详细。消息体的内容相对而言就显得非常简单了,就是提到的序列化所得的字节流信息,包括 JSON、Hessian、Protobuff、Thrift 等
网络传输
- 动态代理使客户端可以像调用本地方法一样调用服务端接口;
- 序列化将传输的信息打包成字节码,使之适合在网络上传输;
- 协议编码对序列化信息进行标注,使其能够顺利地传输到目的地。
做完前面这些准备工作后就可以进行网络传输了。
RPC 的网络传输本质上是服务调用方和服务提供方的一次网络信息交换过程。以 Linux 操作系统为例,操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,还拥有访问底层硬件设备(比说网卡)的所有权限。
为了保证内核的安全,用户的应用程序并不能直接访问内核。对此,操作系统将内存空间划分为两部分,一部分是内核空间,一部分是用户空间。如果用户空间想访问内核空间就需要以缓冲区作为跳板。
网络传输也是如此,如果一个应用程序(用户空间)想访问网卡发送的信息,就需要通过应用缓冲区将数据传递给内核空间的内核缓冲区,再通过内核空间访问硬件设备,也就是网卡,最终完成信息的发送。
RPC 应用程序读写 IO 数据
整个请求过程分为左右两边,左边是服务调用方,右边是服务提供方,左边是应用程序写入 IO 数据的操作,右边是应用程序读出 IO 数据的操作。
从左往右看这张图,图的最左边是服务调用方中的应用程序发起网络请求,也就是应用程序的写 IO 操作。然后应用程序把要写入的数据复制到应用缓冲区,操作系统再将应用缓冲区中的数据复制到内核缓冲区,接下来通过网卡发送到服务提供方。服务提供方接收到数据后,先将数据复制到内核缓冲区内,再复制到应用缓冲区,最后供应用程序使用,这便完成了应用程序读出 IO 数据的操作。
通过上面对 RPC 调用流程的描述,可以看出服务调用方需要经过一系列的数据复制,才能通过网络传输将信息发送到服务提供方,在这个调用过程中,我们关注更多的是服务调用方从发起请求,到接收响应信息的过程。在实际应用场景中,服务调用方发送请求后需要先等待服务端处理,然后才能接收到响应信息。
服务调用方在接收响应信息时,需要经历两个阶段,分别是等待数据准备和内核复制到用户空间。信息在网络上传输时会被封装成一个个数据包,然后进行发送,每个包到达目的地的时间由于网络因素有所不同,内核系统会将收到的包放到内核缓冲区中,等所有包都到达后再放到应用缓冲区。应用缓冲区属于用户空间的范畴,应用程序如果发现信息发送到了应用缓冲区,就会获取这部分数据进行计算。如果对这两个阶段再做简化就是网络 IO 传输和数据计算。网络 IO 传输的结果是将数据包放到内核缓冲区中,数据从内核缓冲区复制到应用缓冲区后就可以进行数据计算
1. 服务调用方写入 IO 数据
- 应用程序写入数据:首先,服务调用方的应用程序将数据准备好,并将数据复制到其应用缓冲区。
- 内核缓冲区:操作系统会将应用缓冲区的数据复制到内核缓冲区,接着数据通过网卡传输到网络,最终到达服务提供方。
2. 服务提供方接收数据
- 内核缓冲区:服务提供方的网卡接收数据后,首先将数据放入其内核缓冲区。
- 应用缓冲区:数据随后从服务提供方的内核缓冲区复制到应用缓冲区,供应用程序使用。
3. 服务调用方接收响应数据
- 等待数据准备:服务调用方发起请求后,必须等待服务端处理并返回响应。数据在网络上传输时,会被封装成数据包,逐个发送。
- 内核缓冲区到应用缓冲区:每个数据包到达后,会先被放到内核缓冲区,直到所有数据包都到达,内核才将这些数据包复制到应用缓冲区。
- 计算:一旦数据进入应用缓冲区,应用程序就可以获取并开始计算。
4. 简化后的过程
- 这个过程可以简单地分为两个主要阶段:
- 网络 IO 传输:将数据包从网络传输到服务调用方和服务提供方的内核缓冲区。
- 数据计算:从内核缓冲区到应用缓冲区的复制,应用程序开始计算。
关键概念
- 应用缓冲区:存放应用程序正在操作的数据,属于用户空间。
- 内核缓冲区:操作系统管理的数据缓冲区,位于内核空间,涉及数据的传输和处理。
- 网络传输:数据通过网络传输被封装为数据包,可能由于网络延迟导致各个数据包到达的时间不同。
- 数据计算:应用程序从缓冲区读取数据并进行计算。
可以看出网络 IO 传输和数据计算过程存在先后顺序,因此当前者出现延迟时会导致后者处于阻塞。另外,应用程序中存在同步调用和异步调用,因此衍生出了同步阻塞 IO(blocking IO)、同步非阻塞 IO(non-blocking IO)、多路复用 IO(multiplexing IO)这几种 IO 模式。
同步阻塞 IO(blocking IO)
在同步阻塞 IO 模型中,应用程序在用户空间向服务端发起请求。如果请求到达了服务端,服务端也做出了响应,那么客户端的内核会一直等待数据包从网络中回传。
此时用户空间中的应用程序处于等待状态,直到数据从网络传到内核缓冲区中,再从内核缓冲区复制到应用缓冲区。之后,应用程序从应用缓冲区获取数据,并且完成数据计算或者数据处理。也就是说,在数据还没到达应用缓冲区时,整个应用进程都会被阻塞,不能处理别的网络 IO 请求,而且应用程序就只是等待响应状态,不会消耗 CPU 资源。
简单来说,同步阻塞就是指发出请求后即等待,直到有响应信息返回才继续执行。如果用去饭店吃饭作比喻,同步阻塞就是点餐以后一直等菜上桌,期间哪里都不去、什么都不做
在 同步阻塞 I/O 模型 中,应用程序在发送请求后会进入一个等待状态,直到从网络中接收到响应数据。此时,应用程序的进程被阻塞,不能进行其他任务或处理其他 I/O 请求。在这个过程中,应用程序的 CPU 资源不会被消耗,因为它处于等待响应的状态,但它也无法执行其他工作。
主要特点
- 请求发起:应用程序发起网络请求,将数据写入应用缓冲区。
- 阻塞等待:应用程序在等待服务端响应时,进程被阻塞。此时,它不执行其他操作,也不会占用 CPU 资源。
- 数据接收:当数据包到达客户端的内核缓冲区后,再被复制到应用缓冲区。
- 数据处理:一旦数据从内核缓冲区复制到应用缓冲区,应用程序可以开始计算或处理数据。
同步阻塞的工作流
- 请求发起:客户端应用程序发送请求,数据写入应用缓冲区。
- 等待响应:应用程序处于阻塞状态,等待数据从网络传输过来并进入内核缓冲区。
- 数据到达并复制:网络中的数据包到达服务端后,响应数据传输回客户端,首先进入内核缓冲区,然后复制到应用缓冲区。
优缺点
-
优点:
- 简单直观,易于实现。因为应用程序会一直等待数据,不需要复杂的调度或状态管理。
- 适用于请求和响应时间较短、网络延迟较低的场景。
-
缺点:
- 资源浪费:如果有多个请求或大量请求时,客户端可能会长时间阻塞,无法处理其他任务。
- 性能瓶颈:一个阻塞的 I/O 操作会导致整个进程无法并行处理其他 I/O 请求,降低并发能力。
同步非阻塞 IO(non-blocking IO)
同步阻塞 IO 模式由于需要应用程序一直等待,在等待过程中应用程序不能做其他事情,因此资源利用率并不高。为了解决这个问题,有了同步非阻塞,这种模式下,应用程序发起请求后无须一直等待。
当用户向服务端发起请求后,会询问数据是否准备好,如果此时数据还没准备好,也就是数据还没有被复制到应用缓冲区,则内核会返回错误信息给用户空间。用户空间中的应用程序在得知数据没有准备好后,不用一直等待,可以做别的事情,只是隔段时间还会询问内核数据是否准备好,如此循环往复,直到收到数据准备好的消息,然后进行数据处理和计算,这个过程也称作轮询。
在数据没有准备好的那段时间内,应用程序可以做其他事情,即处于非阻塞状态。当数据从内核缓冲区复制到用户缓冲区后,应用程序又处于阻塞状态。
还是用去饭店吃饭作比喻,同步非阻塞就是指点餐以后不必一直等菜上桌,可以玩手机、聊天,时不时打探一下菜准备好了没有,如果没有准备好,可以继续干其他,如果准备好就可以吃饭了
在 同步非阻塞 I/O 模型 中,应用程序发起请求后不会一直等待数据返回,而是通过反复询问(轮询)内核数据是否准备好,直到数据准备好后才开始处理。这种方式避免了阻塞状态,允许应用程序在等待数据时执行其他操作,从而提高了资源利用率。
同步非阻塞 I/O 模型的工作流程
- 请求发起:应用程序向服务端发起请求,将数据写入应用缓冲区,操作系统将数据复制到内核缓冲区。
- 轮询检查:应用程序查询数据是否准备好(是否已经从内核缓冲区复制到应用缓冲区)。如果数据尚未准备好,应用程序会收到一个错误信息,表示数据还未准备好。
- 执行其他任务:如果数据未准备好,应用程序不会阻塞等待,而是可以执行其他操作,继续做其他事情。
- 再次查询:应用程序定期查询内核数据是否准备好,这个过程称为轮询。
- 数据准备好:当数据从内核缓冲区复制到应用缓冲区时,应用程序会得到通知,并进入阻塞状态,开始处理数据。
多路复用 IO(multiplexing IO)
虽然和同步阻塞 IO 相比,同步非阻塞 IO 模式下的应用程序能够在等待过程中干其他活儿,但是会增加响应时间。由于应用程序每隔一段时间都要轮询一次数据准备情况,有可能存在任务是在两次轮询之间完成的
还是举吃饭的例子,假如点餐后每隔 5 分钟查看是否准备好,如果餐在等待的 5 分钟之内就准备好了(例如:第 3 分钟就准备好了),可还是要等到第 5 分钟的时候才去检查,那么一定时间内处理的任务就少了,导致整体的数据吞吐量降低。
同时,轮询操作会消耗大量 CPU 资源,如果同时有多个请求,那么每个应用的进程都需要轮询,这样效率是不高的。
要是有一个统一的进程可以监听多个任务请求的数据准备状态,一旦发现哪个请求的数据准备妥当,便立马通知对应的应用程序进行处理就好了。
因此就有了多路复用 IO,实际上就是在同步非阻塞 IO 的基础上加入一个进程,此进程负责监听多个请求的数据准备状态
当进程 1 和进程 2 发起请求时,不用两个进程都去轮询数据准备情况,因为有一个复用器(selector)进程一直在监听数据是否从网络到达了内核缓冲区中,如果监听到哪个进程对应的数据到了,就通知该进程去把数据复制到自己的应用缓冲区,进行接下来的数据处理
复用器可以注册多个网络连接的 IO。当用户进程调用复用器时,进程就会被阻塞。内核会监听复用器负责的网络连接,无论哪个连接中的数据准备好,复用器都会通知用户空间复制数据包。此时用户进程再将数据从内核缓冲区中复制到用户缓冲区,并进行处理。这里有所不同的是,进程在调用复用器时就进入阻塞态了,不用等所有数据都回来再进行处理,也就是说返回一部分,就复制一部分,并处理一部分。
好比一群人吃饭,每个人各点了几个菜,而且是通过同一个传菜员点的,这些人在点完菜以后虽然是在等待,不过每做好一道菜,传菜员就会把做好的菜上到桌子上,满足对应客人的需求。
因此,IO 多路复用模式可以支持多个用户进程同时请求网络 IO 的情况,能够方便地处理高并发场景,这也是 RPC 架构常用的 IO 模式
在 多路复用 I/O 模式 中,通过引入一个 复用器(Selector) 进程来统一管理多个 I/O 请求的状态,避免了每个应用程序都进行轮询,进而减少了 CPU 资源的消耗并提高了效率。这种模式特别适用于高并发场景,能够显著提高网络请求的吞吐量。
多路复用 I/O 模型的工作流程
- 进程发起请求:多个进程发起 I/O 请求,各自将请求数据发送到网络,等待响应。
- 复用器监听:复用器进程(例如
selector
)统一监听所有网络连接的 I/O 状态,判断哪些连接的数据准备好。 - 通知应用程序:一旦复用器检测到某个进程的请求已经完成,复用器通知该进程去从内核缓冲区复制数据到应用缓冲区。
- 数据处理:应用程序从缓冲区读取数据并开始处理。
优点
- 减少轮询和CPU消耗:通过复用器进程,避免了每个进程都进行轮询,降低了不必要的 CPU 占用。
- 高并发支持:能够同时处理多个 I/O 请求,提升吞吐量,适应高并发场景。
- 提高效率:每当某个请求的数据准备好时,就立刻通知对应的进程进行数据复制和处理,而无需等待所有数据都准备好。
原文地址:https://blog.csdn.net/yangshangwei/article/details/144096381
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!