RPC远程过程调用(Remote Procedure Call)是一种通信协议,它允许程序调用位于不同地址空间(通常是网络上的另一台机器)的方法,而无需程序员显式编码这个远程调用的细节。这种技术隐藏了底层的通讯细节,使得调用远程服务就像调用本地函数一样简单。
文章目录
- 概述
- 项目整体架构
- 启动项目测试
- 项目主要实现内容
-
- 服务暴露与发现
- [集成 SpringBoot 完成自动配置](#集成 SpringBoot 完成自动配置)
- [RPC 调用方式](#RPC 调用方式)
- 设计消息协议与编解码
- 粘包拆包问题
- 动态代理
- 负载均衡算法
- 序列化算法
- 引入Netty心跳检测机制
- 实现本地缓存
- 实现SPI机制以及依赖注入
- 环境搭建
- 压力测试
概述
RPC框架一般必须包含三个组件,分别是注册中心、客户端以及服务端,一次完整的 RPC 远程调用流程通常如下:
-
服务注册:服务端在启动服务后会将其提供的服务列表信息注册到注册中心Zookeeper或者nacos;
-
服务订阅:客户端会向注册中心订阅相关的服务地址信息,为了节省网络开销,一般会将服务地址信息保存在本地;
-
服务调用:首先客户端会从本地的服务列表中根据负载均衡算法选择其中一个服务地址,在发起远程调用之前,会通过本地代理Proxy将调用的方法和参数等数据转化为网络字节流以便在网络中传输,客户端将数据转换成网络字节流后,通过网络发送给服务端,服务端得到数据后进行解析,调用对应的服务,然后将方法执行结果通过网络返回给客户端。
RPC 调用过程很简洁,但是实现一个完整的 RPC 框架设计到很多内容,例如服务注册与发现、动态代理、负载均衡、通信协议设计与编解码、序列化与反序列化、容错机制等,下面会详细讲解。
项目整体架构
rpc-server-spring-boot模块:rpc 服务端模块,负责启动服务,充当生产者的角色,接收和处理RPC请求,提供服务暴露、反射调用等功能;
provider模块:服务提供者,依赖于 rpc-server-spring-boot-starter 模块:
provider-api模块:服务提供者暴露的API;
rpc-server-spring-boot-stater模块:是rpc-server-spring-boot的stater模块,负责引入相应依赖进行自动配置;
rpc-client-spring-boot模块:rpc 客户端模块,封装客户端发起的请求过程,提供服务发现、动态代理,网络通信等功能;
consumer模块:服务的消费者,依赖于 rpc-client-spring-boot-starter 模块;
rpc-client-spring-boot-stater模块:是rpc-client-spring-boot的stater模块,负责引入相应依赖进行自动配置;
rpc-framework-core模块:是rpc核心依赖,提供负载均衡、服务注册发现、消息协议、消息编码解码、序列化与反序列化等功能;
启动项目测试
- 安装并启动 zookeeper或者nacos;
- 修改Provider和 Consumer模块下的 application.yml的注册中心地址rpc.client.registry-addr=zk或nacos地址,服务端则配置rpc.server.registry-addr属性;
- 启动 Provider 模块的SpringBoot 项目。
- 然后启动 Consumer 模块,通过 Controller 就可以进行远程调用了。
- 浏览器输入 http://localhost:8080/hello/world ,若成功返回:hello, world,表示rpc调用成功。
项目主要实现内容
服务暴露与发现
相对于RPC框架,传统的HTTP通信方式主要基于请求-响应模型,客户端发送一个HTTP请求到服务器,服务器处理请求后返回一个HTTP响应。HTTP通信的基本流程:
-
客户端发起请求:客户端构造一个HTTP请求,包括请求方法(如GET、POST等)、请求URI、请求头和请求体。
-
服务器接收并处理请求:服务器接收到HTTP请求后,根据请求方法和URI路由到相应的处理逻辑,服务器处理请求,可能涉及数据库查询、业务逻辑处理等。
-
服务器返回响应:服务器构造一个HTTP响应,包括状态码(如200表示成功,404表示未找到等)、响应头和响应体(如HTML页面、JSON数据等)。
-
客户端处理响应:客户端接收到HTTP响应后,根据状态码和响应头信息处理响应体内容,如渲染HTML页面、解析JSON数据等。
可以看出HTTP协议是无状态的,每个请求都是独立的,服务器不会记住客户端之前的请求状态,这就需要额外的机制(如Cookie、Session)来维护状态,增加了复杂性,而客户端这边若需要保存服务端的服务列表就需要主动感知服务端暴露的信息,客户端与服务端之间严重耦合,为了更好地将二者解耦,实现服务端的优雅上线以及下线,于是注册中心就出现了。
在 RPC 框架中通过注册中心可以实现服务暴露和发现的功能,服务端节点上线时会自动向注册中心暴露节点信息,当节点下线时,注册中心将对应的节点信息剔除,当客户端向服务端发起远程调用时,会先从注册中心获取服务端的服务列表,再通过负载均衡策略选择其中一个服务节点进行发起远程调用,这是通信最基本的发布订阅模式,不需要借助其他中间件,性能损耗也是最小的。
当服务节点在下线时需要从注册中心移除对应的节点元数据,那么注册中心就需要感知服务下线,最简单的实现方法就是让节点主动通知,当节点需要下线时,向注册中心发送下线请求,通知注册中心剔除对应的节点信息。但是若节点出现网络等异常情况提前退出,没得来通知注册中心,那么注册中心将会一直残留异常节点的信息,从而造成服务调用出现问题。
为了避免出现这个问题,实现服务节点优雅下线比较好的实现方式是采用主动通知和心跳检测的方案。
服务节点启动时,向注册中心注册节点信息,并开始发送心跳信号。服务节点在正常运行期间,处理客户端请求,同时向注册中心定期发送心跳信号,例如每 15s 发送一次心跳包,服务节点决定下线时,首先发送注销请求给注册中心,并设置一个等待时间,这个等待时间是为了确保所有正在进行的请求都能完成,注册中心收到注销请求后,确认服务节点的下线请求,并更新服务状态,当客户端再调用已下线服务节点时,被收到服务不可用的通知,客户端此时可以采取失败重试机制,注册中心再将客户端的请求分发到其他可用的服务节点处理。
由此可见,采用注册中心的好处是可以解耦客户端和服务端的关系,注册中心作为一个中心化的服务目录,存储了所有可用的服务实例信息,客户端可以通过查询注册中心来发现并连接到合适的服务实例,而无需硬编码服务地址这使得服务实例的动态添加、删除和迁移变得更加容易。
本项目已经实现了以 Zookeeper 和nacos 为注册中心。
本项目集成了 Spring 自定义注解提供服务注册与消费
当Spring应用程序上下文扫描到带有@RpcComponentScan注解的类时,会导入RpcBeanDefinitionRegistrar类。RpcBeanDefinitionRegistrar是一个实现了ImportBeanDefinitionRegistrar接口的类,它负责将RPC组件注册到Spring应用程序上下文中。
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(RpcBeanDefinitionRegistrar.class)
public @interface RpcComponentScan {
//......
}
java
public class RpcBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware {
private ResourceLoader resourceLoader;
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
/**
* 此方法会在 spring 自定义扫描执行之后执行,这个时候 beanDefinitionMap 已经有扫描到的 beanDefinition 对象了
*/
@Override
public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry registry) {
// 获取 RpcComponentScan 注解的属性和值
AnnotationAttributes annotationAttributes = AnnotationAttributes
.fromMap(annotationMetadata.getAnnotationAttributes(RpcComponentScan.class.getName()));
String[] basePackages = {};
if (annotationAttributes != null) {
// 此处去获取RpcComponentScan 注解的 basePackages 值
basePackages = annotationAttributes.getStringArray("basePackages");
}
// 如果没有指定名称的话
if (basePackages.length == 0) {
basePackages = new String[]{((StandardAnnotationMetadata) annotationMetadata).getIntrospectedClass().getPackage().getName()};
}
// 创建一个浏览 RpcService 注解的 Scanner
// 备注:此处可以继续扩展,例如扫描 spring bean 或者其他类型的 Scanner
RpcClassPathBeanDefinitionScanner rpcServiceScanner = new RpcClassPathBeanDefinitionScanner(registry, RpcService.class);
if (this.resourceLoader != null) {
rpcServiceScanner.setResourceLoader(this.resourceLoader);
}
// 扫描包下的所有 Rpc bean 并返回注册成功的数量(scan方法会调用register方法去注册扫描到的类并生成 BeanDefinition 注册到 spring 容器)
int count = rpcServiceScanner.scan(basePackages);
}
}
- @RpcService - 该注解用来标注需要暴露的服务实现类,被标注的类将会被注入到 Spring容器中,同时将对应服务信息注册到远程注册中心;
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RpcService {
// ......
}
java
public class RpcServerBeanPostProcessor implements BeanPostProcessor, CommandLineRunner {
/**
* 在 bean 实例化后,初始化后,检测标注有 @RpcService 注解的类,将对应的服务类进行注册,对外暴露服务,同时进行本地服务注册
*
* @param bean bean
* @param beanName beanName
* @return 返回增强后的 bean
* @throws BeansException Bean 异常
*/
@SneakyThrows
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
// 判断当前 bean 是否被 @RpcService 注解标注
if (bean.getClass().isAnnotationPresent(RpcService.class)) {
// 获取到该类的 @RpcService 注解
RpcService rpcService = bean.getClass().getAnnotation(RpcService.class);
String interfaceName;
if ("".equals(rpcService.interfaceName())) {
interfaceName = rpcService.interfaceClass().getName();
} else {
interfaceName = rpcService.interfaceName();
}
String version = rpcService.version();
String serviceName = ServiceUtil.serviceKey(interfaceName, version);
// 构建 ServiceInfo 对象
ServiceInfo serviceInfo = ServiceInfo.builder()
.appName(properties.getAppName())
.serviceName(serviceName)
.version(version)
.address(properties.getAddress())
.port(properties.getPort())
.build();
// 进行远程服务注册
serviceRegistry.register(serviceInfo);
// 进行本地服务缓存注册
LocalServiceCache.addService(serviceName, bean);
}
return bean;
}
}
- @RpcReference - 服务注入注解,被标注的属性会通过动态代理的方式注入服务的实现类
java
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RpcReference {
//......
}
java
public class RpcClientBeanPostProcessor implements BeanPostProcessor {
private final ClientStubProxyFactory proxyFactory;
public RpcClientBeanPostProcessor(ClientStubProxyFactory proxyFactory) {
this.proxyFactory = proxyFactory;
}
/**
* 在 bean 实例化完后,扫描 bean 中需要进行 rpc 注入的属性,将对应的属性使用 代理对象 进行替换
*
* @param bean bean 对象
* @param beanName bean 名称
* @return 后置增强后的 bean 对象
* @throws BeansException bean 异常
*/
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
// 获取该 bean 的类的所有属性(getFields - 获取所有的public属性,getDeclaredFields - 获取所有声明的属性,不区分访问修饰符)
Field[] fields = bean.getClass().getDeclaredFields();
// 遍历所有属性
for (Field field : fields) {
// 判断是否被 RpcReference 注解标注
if (field.isAnnotationPresent(RpcReference.class)) {
// 获得 RpcReference 注解
RpcReference rpcReference = field.getAnnotation(RpcReference.class);
// 默认类为属性当前类型
Class<?> clazz = field.getType();
try {
// 如果指定了全限定类型接口名
if (!"".equals(rpcReference.interfaceName())) {
clazz = Class.forName(rpcReference.interfaceName());
}
// 如果指定了接口类型
if (rpcReference.interfaceClass() != void.class) {
clazz = rpcReference.interfaceClass();
}
// 获取指定类型的代理对象
Object proxy = proxyFactory.getProxy(clazz, rpcReference.version());
// 关闭安全检查
field.setAccessible(true);
// 设置域的值为代理对象
field.set(bean, proxy);
} catch (ClassNotFoundException | IllegalAccessException e) {
throw new RpcException(String.format("Failed to obtain proxy object.");
}
}
}
return bean;
}
}
集成 SpringBoot 完成自动配置
实现了 rpc 客户端和 rpc 服务端的 starter 模块,编写客户端(RpcClientAutoConfiguration)和服务端(RpcServerAutoConfiguration)对应的自动配置类,并在各自的 spring.factories 文件中指明对应的自动配置类,最后在需要用到的地方引入对应的starter 即可完成自动配置功能。
客户端的自动配置类
java
@Configuration
@EnableConfigurationProperties(RpcClientProperties.class)
public class RpcClientAutoConfiguration {
/*......*/
}
客户端的spring.factories文件
java
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.czl.rpc.client.config.RpcClientAutoConfiguration
服务端的自动配置类
java
@Configuration
@EnableConfigurationProperties(RpcServerProperties.class)
public class RpcServerAutoConfiguration {
/*......*/
}
服务端的spring.factories文件
java
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.czl.rpc.server.config.RpcServerAutoConfiguration
RPC 调用方式
成熟的 RPC 框架一般会提供四种调用方式,分别为同步 Sync、异步 Future、回调 Callback和单向 Oneway。
- Sync 同步调用:同步调用是指客户端线程在发起远程过程调用之后,当前线程会保持阻塞状态,直到服务器返回结果或发生超时异常。
- Future异步调用:当客户端发起调用后,不会阻塞等待,而是得到一个由RPC框架返回的Future对象,服务端会缓存调用结果,客户端可以自行选择合适的时间点来获取这些结果,当客户端决定获取结果时,这个过程将会阻塞等待。
- Callback回调调用:当客户端在发起调用时,会将一个Callback对象交给RPC框架,并立即返回,无需同步等待返回结果,当获取到服务端的响应结果或发生超时异常,就会触发之前注册的Callback回调。因此,Callback接口通常会提供onResponse和onException两个方法,分别用于处理成功响应和异常情况。
- Oneway 单向调用:当客户端发起请求之后,直接返回,忽略返回结果。Oneway 方式是最简单的,具体调用过程如下图所示。
本项目实现了第一种 Sync 同步调用。核心实现类 com.czl.rpc.client.transport.netty.NettyRpcClient ,使用 io.netty.util.concurrent.Promise 去接收服务端的响应结果,将暂时还没处理的RpcResponse根据消息头中的消息序列号sequenceId存入ConcurrentHashMap 中,RpcResponseHadler 根据消息序列号 sequenceId 取出 Promise 对象存储的暂未处理的响应消息,处理后通过设置 promise的状态来通知等待结果的线程并返回,核心代码如下:
java
public class NettyRpcClient implements RpcClient {
/*......*/
//发送rpc消息请求
@Override
public RpcMessage sendRpcRequest(RequestMetadata requestMetadata) {
// 构建接收返回结果的 promise
Promise<RpcMessage> promise;
// 获取 Channel 对象
Channel channel = getChannel(new InetSocketAddress(requestMetadata.getServerAddr(), requestMetadata.getPort()));
if (channel.isActive()) {
// 创建 promise 来接受结果 指定执行完成通知的线程
promise = new DefaultPromise<>(channel.eventLoop());
// 获取请求的序列号 ID
int sequenceId = requestMetadata.getRpcMessage().getHeader().getSequenceId();
// 存入还未处理的请求
RpcResponseHandler.UNPROCESSED_RPC_RESPONSES.put(sequenceId, promise);
// 发送数据并监听发送状态
channel.writeAndFlush(requestMetadata.getRpcMessage()).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
log.info("The client send the message successfully, msg: [{}].", requestMetadata);
} else {
future.channel().close();
promise.setFailure(future.cause());
log.error("The client send the message failed.", future.cause());
}
});
// 等待结果返回(让出cpu资源,同步阻塞调用线程main,其他线程去执行获取操作(eventLoop))
promise.await();
if (promise.isSuccess()) {
// 返回响应结果
return promise.getNow();
} else {
throw new RpcException(promise.cause());
}
} else {
throw new IllegalStateException("The channel is inactivate.");
}
}
}
调用方根据消息序列号sequenceId取出Promise所对应的响应消息进行处理,处理后通过设置Promise的状态通知等待结果的线程。
java
public class NettyRpcClient implements RpcClient {
/*......*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, RpcMessage msg) throws Exception {
try {
MessageType type = MessageType.parseByType(msg.getHeader().getMessageType());
// 如果是 RpcRequest 请求
if (type == MessageType.RESPONSE) {
int sequenceId = msg.getHeader().getSequenceId();
// 拿到还未执行完成的 promise 对象
Promise<RpcMessage> promise = UNPROCESSED_RPC_RESPONSES.remove(sequenceId);
if (promise != null) {
Exception exception = ((RpcResponse) msg.getBody()).getExceptionValue();
if (exception == null) {
// System.out.println("成功发送请求并收到响应消息:======="+msg);
promise.setSuccess(msg);
// System.out.println("promise"+promise);
} else {
// System.out.println("执行失败,异常原因"+exception);
promise.setFailure(exception);
// System.out.println("promise"+promise);
}
}
} else if (type == MessageType.HEARTBEAT_RESPONSE) { // 如果是心跳检查请求
log.debug("Heartbeat info {}.", msg.getBody());
}
} finally {
// 释放内存,防止内存泄漏
ReferenceCountUtil.release(msg);
}
}
}
设计消息协议与编解码
RPC 本质上是一种远程调用,网络通信协议是其不可或缺的部分。在客户端向服务端发起请求之前,必须决定如何对请求信息进行编码并通过网络发送。鉴于 RPC 框架对性能的高要求,通信协议应尽可能简化以降低编解码过程中的性能损失。RPC 框架可以采用多种协议实现,其中最常见的是 TCP 和 HTTP 协议,而著名的 gRPC 框架则采用 HTTP2。尽管 TCP、HTTP 和 HTTP2 都非常稳定可靠,但在某些业务场景下,使用 UDP 协议也是可行的。成熟的 RPC 框架,如阿里巴巴开源的 Dubbo 框架,在众多互联网公司中得到了广泛应用,其可插拔的协议支持是一大亮点,这不仅为开发者带来了多种选择,还便于与异构系统集成。
自定义消息协议的标志位:
-
魔数:接收方根据该标志位判定消息是否是无效数据包,快速识别字节流是否是程序能够处理的,能处理才进行后面的耗时业务操作,如果不能处理,尽快执行失败,断开连接等操作
-
版本号:用于消息协议的升级
-
序列化类型:用于标识消息报文采用哪种序列化方式,根据此标志位可以进行扩展,例如:json、protobuf、hessian、jdk、kryo
-
消息类型:表示消息是注册、登录、单聊、群聊等... 跟具体业务相关,在本项目中用于区分心跳检测消息还是rpc远程调用消息
-
消息状态:用于表示请求消息是否处理成功
-
消息序列号:通过消息序列号将请求与响应关联进行关联,提供异步处理能力,也可以通过消息序列号做链路追踪
-
消息长度:用于标识消息内容的长度,判断消息是否是完整的数据包,该标志位可以解决粘包拆包问题
-
消息内容:主要传递的消息内容,包含服务名称,版本号,方法名称,参数类型以及参数值。
实现编解码
编解码实现类为:SharableRpcMessageCodec.java,该类继承自io.netty.handler.codec.MessageToMessageCodec,MessageToMessageCodec这个类中有两个方法encode()与decode(),encode() 用于将输入的 RpcMessage 编码成 ByteBuf ,decode() 用于将 ByteBuf 解码成 RpcMessage,编码为出站操作,解码为入站操作。
java
public class SharableRpcMessageCodec extends MessageToMessageCodec<ByteBuf, RpcMessage> {
// 编码器为出站处理,将 RpcMessage 编码为 ByteBuf 对象
@Override
protected void encode(ChannelHandlerContext ctx, RpcMessage msg, List<Object> out) throws Exception {
ByteBuf buf = ctx.alloc().buffer();
MessageHeader header = msg.getHeader();
// 4字节 魔数
buf.writeBytes(header.getMagicNum());
// 1字节 版本号
buf.writeByte(header.getVersion());
// 1字节 序列化算法
buf.writeByte(header.getSerializerType());
// 1字节 消息类型
buf.writeByte(header.getMessageType());
// 1字节 消息状态
buf.writeByte(header.getMessageStatus());
// 4字节 消息序列号
buf.writeInt(header.getSequenceId());
// 取出消息体
Object body = msg.getBody();
// 获取序列化算法
Serialization serialization = SerializationFactory
.getSerialization(SerializationType.parseByType(header.getSerializerType()));
// 进行序列化
byte[] bytes = serialization.serialize(body);
// 设置消息体长度
header.setLength(bytes.length);
// 4字节 消息内容长度
buf.writeInt(header.getLength());
// 不固定字节 消息内容字节数组
buf.writeBytes(bytes);
// 传递到下一个出站处理器
out.add(buf);
}
// 解码器为入站处理,将 ByteBuf 对象解码成 RpcMessage 对象
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
// 4字节 魔数
int len = ProtocolConstants.MAGIC_NUM.length;
byte[] magicNum = new byte[len];
msg.readBytes(magicNum, 0, len);
// 判断魔数是否正确,不正确表示非协议请求,不进行处理
for (int i = 0; i < len; i++) {
if (magicNum[i] != ProtocolConstants.MAGIC_NUM[i]) {
throw new IllegalArgumentException("Unknown magic code: " + Arrays.toString(magicNum));
}
}
// 1字节 版本号
byte version = msg.readByte();
// 检查版本号是否一致
if (version != ProtocolConstants.VERSION) {
throw new IllegalArgumentException("The version isn't compatible " + version);
}
// 1字节 序列化算法
byte serializeType = msg.readByte();
// 1字节 消息类型
byte messageType = msg.readByte();
// 1字节 消息状态
byte messageStatus = msg.readByte();
// 4字节 消息序列号
int sequenceId = msg.readInt();
// 4字节 长度
int length = msg.readInt();
byte[] bytes = new byte[length];
msg.readBytes(bytes, 0, length);
// 构建协议头部信息
MessageHeader header = MessageHeader.builder()
.magicNum(magicNum)
.version(version)
.serializerType(serializeType)
.messageType(messageType)
.sequenceId(sequenceId)
.messageStatus(messageStatus)
.length(length).build();
// 获取反序列化算法
Serialization serialization = SerializationFactory
.getSerialization(SerializationType.parseByType(serializeType));
// 获取消息枚举类型
MessageType type = MessageType.parseByType(messageType);
RpcMessage protocol = new RpcMessage();
protocol.setHeader(header);
if (type == MessageType.REQUEST) {
// 进行反序列化
RpcRequest request = serialization.deserialize(RpcRequest.class, bytes);
protocol.setBody(request);
} else if (type == MessageType.RESPONSE) {
// 进行反序列化
RpcResponse response = serialization.deserialize(RpcResponse.class, bytes);
protocol.setBody(response);
} else if (type == MessageType.HEARTBEAT_REQUEST || type == MessageType.HEARTBEAT_RESPONSE) {
String message = serialization.deserialize(String.class, bytes);
protocol.setBody(message);
}
// 传递到下一个处理器
out.add(protocol);
}
}
粘包拆包问题
之前在分析kafka源码(Kafka源码四)生产者发送消息到broker时已经讲解了kafka是如何处理粘包拆包问题的,Kafka的粘包和拆包问题主要是由于TCP协议的特性导致的。TCP协议是一种基于字节流传输数据的,它并不保证数据包的边界,因此可能会出现粘包和拆包的情况。
- 粘包:指的是多条消息被合并成一个数据包发送,导致消费者接收到的数据包中包含了多条消息。这种情况下,消费者需要正确地解析出多条消息,否则会导致数据解析错误。
- 拆包:指的是一条消息被拆分成多个数据包发送,导致消费者接收到的数据包中只包含了一条消息的部分内容。这种情况下,消费者需要能够正确地将多个数据包组合成一个完整的消息,否则会导致数据丢失。
粘包现象 :例如:发送方发送了两条消息分别为123和abc,接收方这边合并收到了一条消息123abc。
粘包原因 :
传输层:粘包和拆包问题本质上是TCP协议的特性导致的。TCP协议是一种基于字节流传输数据的,它并不保证数据包的边界,因此可能会出现粘包和拆包的情况。
Nagle 算法:TCP协议中的Nagle算法会尝试减少网络中的小数据包数量,通过合并多个小数据包成一个大数据包再发送,这可能导致粘包现象
滑动窗口:若发送方发送了1MB的完整报文,但由于接收方处理不及时且窗口大小足够大,这 1MB的数据就会缓冲在接收方的滑动窗口中,当缓存多个消息时就会出现粘包
应用层:接收方 ByteBuf 设置太大,Netty 默认 1024
拆包现象 :发送方发送了一条消息123abc,接收方收到两条消息123a和bc,接收到的消息不完整
拆包原因:
MTU(最大传输单元)限制:网络中的每条链路都有一个MTU值,表示该链路上能够传输的最大数据包大小。当发送的数据包大小超过MTU时,数据包会被拆分成多个小数据包进行传输。
滑动窗口:假设接收方的窗口只剩了 128 bytes,发送方的报文大小是 256 bytes,这时放不下了,只能先发送前 128 bytes,等待 ack 后才能发送剩余部分,这就造成了半包
应用层:接收方 ByteBuf 小于实际发送数据量
原因:Kafka的粘包和拆包问题主要是由于TCP协议的特性导致的。TCP协议是一种基于字节流传输数据的,它并不保证数据包的边界,因此可能会出现粘包和拆包的情况。
粘包拆包问题本质是因为 TCP 是流式协议,不保证消息的边界
解决方案
短连接:在发一次数据包之前建立短连接,发送完断开连接,每次连接建立到断开之间就是一个消息边界,缺点是效率很低;
固定长度的消息头:生产者在发送消息时可以在消息头部添加一个固定长度的字段,用于表示消息的长度,消费者在接收到消息时,根据消息头中的长度字段来判断消息的边界,从而避免粘包和拆包,这种方式要求发送的消息长度是固定的,缺少灵活性。
使用分隔符:生产者在发送消息时可以在消息与消息之间添加一个特殊的分隔符,消费者在接收到消息时,可以根据分隔符来判断消息的边界,从而避免粘包和拆包的问题。但是这种方法所使用的分隔符不能出现在消息本身中,否则会导致边界的判断失误。
使用自定义协议:可以自定义一个协议,消息分为消息头和消息体两部分,消息头包含有消息体的长度,以便消费者能够正确地解析消息(是最常用的解决方案之一)。
在本项目中采用自定义协议解决粘包拆包问题,在发送消息前,通信双方约定好消息的最大长度,消息长度字段在消息头中的偏移量,以及消息长度字段的大小。粘包拆包编码器RpcFrameDecoder继承自LengthFieldBasedFrameDecoder。
java
public class RpcFrameDecoder extends LengthFieldBasedFrameDecoder {
/**
* 得到当前约定协议的帧解码器,
*/
public RpcFrameDecoder() {
this(1024, 12, 4);
}
/**
* 构造方法
*
* @param maxFrameLength 数据帧的最大长度
* @param lengthFieldOffset 长度域的偏移字节数
* @param lengthFieldLength 长度域所占的字节数
*/
public RpcFrameDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength) {
super(maxFrameLength, lengthFieldOffset, lengthFieldLength);
}
}
动态代理
如何让 RPC 框架能够像调用本地方法一样调用远程服务呢?这需要依靠动态代理来完成。首先,我们需要创建一个代理对象,该对象负责处理数据报文的编码,然后发起请求将数据发送给服务提供者,从而隐藏 RPC 框架的具体调用过程,由于代理类是在程序运行过程中生成的,因此代理类的生成速度和生成的字节码大小都会对 RPC 框架的整体性能和资源消耗产生影响,所以选择合适的动态代理实现方案至关重要。目前比较流行的动态代理实现方案包括:JDK 动态代理、Cglib、Javassist、ASM 和 Byte Buddy。接下来,我们将对这些方案进行简要的比较和介绍。
- JDK动态代理机制允许在程序运行时动态生成代理类。然而,它的功能有所限制,因为代理对象必须实现一个接口,否则会引发异常。这是因为代理类需要继承自Proxy 类,而 Java 不支持多重继承,只能通过接口实现多态。所以,JDK动态代理生成的代理类实际上是接口的一个实现,无法代理接口中未定义的方法。此外,JDK动态代理是通过反射机制来调用代理类中的方法,相较于直接调用,其性能相对较慢。
- Cglib 动态代理机制是基于 ASM 字节码生成框架实现的,它利用字节码技术来创建代理类,因此代理类的类型不受任何限制。此外,Cglib生成的代理类是通过继承目标类来实现的,这使得它能够提供更为灵活的功能。在代理方法的处理上,Cglib 具有一定的优势。它采用FastClass 机制,分别为代理类和被代理类创建一个 Class 对象,这些 Class 对象会为各自的方法分配一个 index索引。FastClass 可以通过这个 index 索引直接找到并调用相应的方法,这是一种以空间换取时间的优化策略。
- Javassist 和 ASM 均为 Java 字节码操作框架,它们的使用相对复杂,要求开发者熟悉 Class 文件的结构以及 Java虚拟机的原理。尽管如此,它们的性能都优于反射机制。Byte Buddy 同样是一个用于生成和操作字节码的库,它具备强大的功能。与Javassist 和 ASM 相比,Byte Buddy 提供了更为简洁的 API,使得创建和修改 Java类变得更加容易,无需深入了解字节码的结构。此外,Byte Buddy 更加轻量级,性能也更出色。
本项目实现了 (JDK动态代理)和 (CGLIB 动态代理)。
代理工厂类
java
public class ClientStubProxyFactory {
/**
* 获取代理对象
*
* @param clazz 服务接口类型
* @param version 版本号
* @param <T> 代理对象的参数类型
* @return 对应版本的代理对象
*/
public <T> T getProxy(Class<T> clazz, String version) {
return (T) proxyMap.computeIfAbsent(ServiceUtil.serviceKey(clazz.getName(), version), serviceName -> {
// 如果目标类是一个接口或者 是 java.lang.reflect.Proxy 的子类 则默认使用 JDK 动态代理
if (clazz.isInterface() || Proxy.isProxyClass(clazz)) {
return Proxy.newProxyInstance(clazz.getClassLoader(),
new Class[]{clazz}, // 注意,这里的接口是 clazz 本身(即,要代理的实现类所实现的接口)
new ClientStubInvocationHandler(discovery, rpcClient, properties, serviceName));
} else { // 使用 CGLIB 动态代理
// 创建动态代理增加类
Enhancer enhancer = new Enhancer();
// 设置类加载器
enhancer.setClassLoader(clazz.getClassLoader());
// 设置被代理类
enhancer.setSuperclass(clazz);
// 设置方法拦截器
enhancer.setCallback(new ClientStubMethodInterceptor(discovery, rpcClient, properties, serviceName));
// 创建代理类
return enhancer.create();
}
});
}
}
JDK动态代理
java
public class ClientStubInvocationHandler implements InvocationHandler {
//......
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 远程方法调用
return RemoteMethodCall.remoteCall(serviceDiscovery, rpcClient, serviceName, properties, method, args);
}
}
Cglib动态代理
java
public class ClientStubMethodInterceptor implements MethodInterceptor {
//.....
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
// 远程方法调用
return RemoteMethodCall.remoteCall(serviceDiscovery, rpcClient, serviceName, properties, method, args);
}
}
远程方法调用
java
public class RemoteMethodCall {
/**
* 发起 rpc 远程方法调用的公共方法
*
* @param discovery 服务发现中心
* @param rpcClient rpc 客户端
* @param serviceName 服务名称
* @param properties 配置属性
* @param method 调用的具体方法
* @param args 方法参数
* @return 返回方法调用结果
*/
public static Object remoteCall(ServiceDiscovery discovery, RpcClient rpcClient, String serviceName,
RpcClientProperties properties, Method method, Object[] args) throws Exception {
// 构建请求头
MessageHeader header = MessageHeader.build(properties.getSerialization());
// 构建请求体
RpcRequest request = new RpcRequest();
request.setServiceName(serviceName);
request.setMethod(method.getName());
request.setParameterTypes(method.getParameterTypes());
request.setParameterValues(args);
// 进行服务发现
ServiceInfo serviceInfo = discovery.discover(request);
if (serviceInfo == null) {
throw new RpcException(String.format("The service [%s] was not found in the remote registry center.",
serviceName));
}
// 构建通信协议信息
RpcMessage rpcMessage = new RpcMessage();
rpcMessage.setHeader(header);
rpcMessage.setBody(request);
// 构建请求元数据
RequestMetadata metadata = RequestMetadata.builder()
.rpcMessage(rpcMessage)
.serverAddr(serviceInfo.getAddress())
.port(serviceInfo.getPort())
.timeout(properties.getTimeout()).build();
RpcMessage responseRpcMessage = null;
// 发送网络请求,获取结果,实现了失败重试机制
int count = 0;
int retryCount = 4;
do {
responseRpcMessage = rpcClient.sendRpcRequest(metadata);
++count;
} while (((RpcResponse) responseRpcMessage.getBody()).getReturnValue() == null && count <= retryCount);
// 故障转移机制
if (((RpcResponse) responseRpcMessage.getBody()).getReturnValue() == null) {
//进行故障转移
List<ServiceInfo> services = discovery.getServices(request.getServiceName());
//没有其他可用的服务实例
if (services.size() == 1) {
throw new RpcException("Remote procedure call timeout.");
} else {
Iterator<ServiceInfo> iterator = services.iterator();
while (iterator.hasNext()) {
if (iterator.next().equals(serviceInfo)) {
iterator.remove();
}
}
ServiceInfo next = services.iterator().next();
serviceInfo = next;
services.remove(next);
metadata.setServerAddr(serviceInfo.getAddress());
metadata.setPort(serviceInfo.getPort());
responseRpcMessage = rpcClient.sendRpcRequest(metadata);
}
}
if (((RpcResponse) responseRpcMessage.getBody()).getReturnValue() == null) {
throw new RpcException("Remote procedure call failed");
}
// 获取响应结果
RpcResponse response = (RpcResponse) responseRpcMessage.getBody();
// 如果远程调用发生错误
if (response.getExceptionValue() != null) {
throw new RpcException(response.getExceptionValue());
}
// 返回响应结果
return response.getReturnValue();
}
}
负载均衡算法
在分布式系统中,服务提供者和服务消费者通常都有多个节点。为了确保服务提供者的所有节点实现负载均衡,在发起调用之前,客户端需要感知有多少可用的服务端节点,并根据负载均衡算法从中选择一个进行调用,负载均衡策略对 RPC 框架的吞吐量有很大影响,接下来我们将介绍几种最常见的负载均衡策略。
- 轮询(Round-Robin):轮询是最简单且有效的负载均衡策略,它不考虑服务端节点的实际负载情况,而是按顺序依次访问各个服务端节点。
- 加权轮询(Weighted Round-Robin):通过对不同负载水平的服务端节点分配不同的权重系数,可以降低性能较差或配置较低的节点的流量。权重系数可以根据服务端负载水平实时调整,使集群达到相对平衡的状态。
- 最少连接数(Least Connections):客户端根据服务端节点当前的连接数进行负载均衡,选择连接数最少的服务器进行调用。最少连接数策略只是服务端的一个维度,我们还可以衍生出最少请求数、CPU利用率最低等其他维度的负载均衡方案。
- 一致性哈希(Consistent Hash:一致性哈希是目前主流推荐的负载均衡策略,它是一种特殊的哈希算法,在服务端节点扩容或下线时,尽量确保客户端请求仍然分配到同一台服务器节点。一致性哈希算法通过哈希环实现,利用哈希函数将对象和服务器节点放置在哈希环上。通常,服务器可以选择 IP + Port 进行哈希,然后为对象选择相应的服务器节点,在哈希环中顺时针查找距离对象哈希值最近的服务器节点。
此外,负载均衡算法可以有很多种形式,客户端可以记录如健康状态、连接数、内存、CPU、Load 等更多信息,根据多种因素做出更好的决策。
本项目实现了 Random、RoundRobin、ConsistentHash 三种负载均衡算法。
序列化算法
在客户端与服务端的通信过程中,发送方需要将调用的接口、方法、请求参数、调用属性等信息序列化为二进制字节流,然后传递给服务提供者,服务端收到数据后,需将二进制字节流反序列化为Java对象以获取调用信息,然后通过反射原理调用相应的方法,并将返回结果、返回码、异常信息等传回给客户端。序列化和反序列化分别指将Java对象转换为二进制流以及将二进制流还原为Java对象的过程。由于网络通信依赖于字节流,且请求信息不确定,因此通常会选择通用且高效的序列化算法。常见的序列化算法包括 FastJson、Kryo、Hessian、Protobuf 等,这些第三方序列化算法相较于 Java 原生序列化操作更为高效。Dubbo 支持多种序列化算法,并定义了 Serialization 接口规范,所有序列化算法扩展都必须实现该接口,其中默认采用的是 Hessian 序列化算法。
序列化对于远程调用的响应速度、吞吐量和网络带宽消耗等方面也具有重要作用,它是提高分布式系统性能的关键因素之一。
本项目实现了五种序列化算法,分别是:JDK、JSON、HESSIAN、KRYO 、PROTOSTUFF,其中JSON使用的是Gson实现,此外还可以使用FastJson、Jackson等实现JSON序列化。
序列化算法性性能对比
序列化性能比较
引入Netty心跳检测机制
解决了客户端每次发起请求时都需要重新与服务端建立 Netty 连接的问题,这一过程非常耗时,通过引入心跳检测机制来维持长连接,并实现 Channel 连接的复用。
优点:
长连接:避免每次调用时新建 TCP 连接,从而加快了响应速度。
Channel 连接复用:防止了重复连接服务器
多路复用:单个 TCP 连接能够交替地发送和接收多个请求和响应消息,这样降低了连接的空闲等待时间,进而减少了相同并发数情况下的网络连接数量,提升了系统的整体吞吐能力。
具体实现代码在com.czl.rpc.client.transport.netty.NettyRpcClient(通过心跳检查保持长连接),com.czl.rpc.client.transport.netty.ChannelProvider(channel复用) 和 com.czl.rpc.server.transport.netty.NettyRpcRequestHandler(多路复用)三个类中。
实现本地缓存
解决了每次请求都需要查询 ZooKeeper 进行服务发现的弊端,通过引入本地服务缓存机制并实时监控 ZooKeeper 服务节点的变化,来自动更新本地的服务缓存列表。
本地缓存实现核心代码
java
public class ZookeeperServiceDiscovery implements ServiceDiscovery {
// 构造方法,传入 zk 的连接地址,如:127.0.0.1:2181
public ZookeeperServiceDiscovery(String registryAddress, LoadBalance loadBalance) {
try {
this.loadBalance = loadBalance;
// 创建zk客户端示例
client = CuratorFrameworkFactory
.newClient(registryAddress, SESSION_TIMEOUT, CONNECT_TIMEOUT,
new ExponentialBackoffRetry(BASE_SLEEP_TIME, MAX_RETRY));
// 开启客户端通信
client.start();
// 构建 ServiceDiscovery 服务注册中心
serviceDiscovery = ServiceDiscoveryBuilder.builder(ServiceInfo.class)
.client(client)
.serializer(new JsonInstanceSerializer<>(ServiceInfo.class))
.basePath(BASE_PATH)
.build();
// 开启 服务发现
serviceDiscovery.start();
} catch (Exception e) {
log.error("An error occurred while starting the zookeeper discovery: ", e);
}
}
//本地缓存实现
public List<ServiceInfo> getServices(String serviceName) throws Exception {
if (!serviceMap.containsKey(serviceName)) {
// 构建本地服务缓存
ServiceCache<ServiceInfo> serviceCache = serviceDiscovery.serviceCacheBuilder()
.name(serviceName)
.build();
// 添加服务监听,当服务发生变化时主动更新本地缓存并通知
serviceCache.addListener(new ServiceCacheListener() {
@Override
public void cacheChanged() {
log.info("The service [{}] cache has changed. The current number of service samples is {}."
, serviceName, serviceCache.getInstances().size());
// 更新本地缓存的服务列表
serviceMap.put(serviceName, serviceCache.getInstances().stream()
.map(ServiceInstance::getPayload)
.collect(Collectors.toList()));
}
@Override
public void stateChanged(CuratorFramework client, ConnectionState newState) {
// 当连接状态发生改变时,只打印提示信息,保留本地缓存的服务列表
log.info("The client {} connection status has changed. The current status is: {}."
, client, newState);
}
});
// 开启服务缓存监听
serviceCache.start();
// 将服务缓存对象存入本地
serviceCacheMap.put(serviceName, serviceCache);
// 将服务列表缓存到本地
serviceMap.put(serviceName, serviceCacheMap.get(serviceName).getInstances()
.stream()
.map(ServiceInstance::getPayload)
.collect(Collectors.toList()));
}
return serviceMap.get(serviceName);
}
}
实现SPI机制以及依赖注入
参考了 Dubbo 的部分源代码,实现了自定义的服务提供者接口(SPI)机制,该机制可以通过接口类型加载指定配置文件中定义的所有扩展实现类,并且可以通过自定义的的key来获取对应的实现类,同时支持@Autowired以及Setter方法注入对象所依赖的bean。SPI实现核心类com.czl.rpc.core.extension.ExtensionLoader。配置文件目录为resource/META-INF/extensions
文件格式为
zk=com.czl.rpc.core.registry.zk.ZookeeperServiceRegistry
nacos=com.czl.rpc.core.registry.nacos.NacosServiceRegistry
环境搭建
-
操作系统:Windows
-
集成开发工具:IntelliJ IDEA
-
项目技术栈:SpringBoot 2.5.2 + JDK 1.8 + Netty 4.1.65.Final
-
项目依赖管理工具:Maven 4.0.0
-
注册中心:Zookeeeper 3.7.1
压力测试
JMH(Java Microbenchmark Harness)是一个由OpenJDK提供并维护的微基准测试工具,专门用于Java代码的微基准测试,测试结果可信度高。
官方也使用JMH对Dubbo进行性能测试,所以本项目也是用JMH压测rpc框架的性能
同时启动 10000 个线程访问 sayHello 接口,分别进行 3 轮预热与测试,测试结果如下:
java
Benchmark Mode Cnt Score Error Units
BenchmarkTest.testSayHello thrpt 3 31643.473 ± 20780.318 ops/s
BenchmarkTest.testSayHello avgt 3 0.532 ± 6.159 s/op
BenchmarkTest.testSayHello sample 395972 0.382 ± 0.002 s/op
BenchmarkTest.testSayHello:testSayHello·p0.00 sample 0.003 s/op
BenchmarkTest.testSayHello:testSayHello·p0.50 sample 0.318 s/op
BenchmarkTest.testSayHello:testSayHello·p0.90 sample 0.387 s/op
BenchmarkTest.testSayHello:testSayHello·p0.95 sample 0.840 s/op
BenchmarkTest.testSayHello:testSayHello·p0.99 sample 2.282 s/op
BenchmarkTest.testSayHello:testSayHello·p0.999 sample 2.470 s/op
BenchmarkTest.testSayHello:testSayHello·p0.9999 sample 2.496 s/op
BenchmarkTest.testSayHello:testSayHello·p1.00 sample 2.508 s/op
BenchmarkTest.testSayHello ss 3 0.118 ± 0.051 s/op
性能测试图
在同样的条件下,同时启动 5000个线程对 Dubbo2.7.14 发起 RPC 远程调用,得到的结果如下:
java
Benchmark Mode Cnt Score Error Units
StressTest.testSayHello thrpt 3 43197.366 ± 9703.455 ops/s
StressTest.testSayHello avgt 3 0.127 ± 0.034 s/op
StressTest.testSayHello sample 611821 0.125 ± 0.001 s/op
StressTest.testSayHello:testSayHello·p0.00 sample 0.042 s/op
StressTest.testSayHello:testSayHello·p0.50 sample 0.119 s/op
StressTest.testSayHello:testSayHello·p0.90 sample 0.129 s/op
StressTest.testSayHello:testSayHello·p0.95 sample 0.139 s/op
StressTest.testSayHello:testSayHello·p0.99 sample 0.195 s/op
StressTest.testSayHello:testSayHello·p0.999 sample 0.446 s/op
StressTest.testSayHello:testSayHello·p0.9999 sample 0.455 s/op
StressTest.testSayHello:testSayHello·p1.00 sample 0.456 s/op
StressTest.testSayHello ss 3 0.062 ± 0.135 s/op
测试结果如下
JMH与Jmeter、Apache-Benmark(ab)等压测工具相比,具有以下优点
- JMH旨在通过编写代码的方式进行压测,特别适用于微基准测试,精度可以精确到微秒级;
- JMH支持预热、并发测试,测试结果更贴近实际场景;
- JMH压测使用简单,只需要引入依赖,声明注解