整体架构流程
什么是RPC?
如果用一句话来形容,那就是:“把拦截到的方法参数转换成可以在网络中传输的二进制数据,并保证服务提供者能正确地还原出语义,最终实现像调用本地一样调用远程的效果”
1.既然是远程调用,肯定需要通过网络来传输数据;传输协议有多种选择,考虑到可靠性,我们一般默认采用TCP协议
2.用户请求的时候是基于方法调用,方法出入参数都是对象,我们需要提前把对象转换成二进制数据,才能在网络中传输,这就是“序列化”;
那么,当服务提供方接收到二进制数据时,它应该如何去识别呢?怎么把二进制数据转换成可理解的内容?就像给你一篇没有标点符号的文章,怎么去读?
-->断句,这时候就需要我们封装协议,根据协议还原出正确的语义,实现“断句”的效果
3.序列化保证数据在网络中能够传输,协议保证传输后可以正确地还原出传输之前的语义;总整合起来,这两个过程可以保证“数据在网络中可以正确地传输”,实现了一个最基本的功能
4.但是,还缺一点东西,因为目前对于研发人员来说,还需要掌握太多的RPC底层细节:手动构造请求、调用序列化、进行网络调用,这是非常不友好的┭┮﹏┭┮
有什么办法来简化API,屏蔽RPC细节,像调用本地一样来调用远程呢?
那就是动态代理啦,RPC框架根据调用的服务接口提前生成动态代理实现类,并注入到相关业务逻辑里面。代理实现类会拦截所有的方法调用,在处理逻辑中完成一整套的远程调用,把远程调用结果返回给调用方,这样就实现了像调用本地一样去调用远程接口的体验了~~
5.走到这里,大概就完成了一个单机版本的RPC框架,那如何让RPC具有集群能力呢?
所谓集群能力,就是:针对同一个接口由多个服务提供者,多个服务提供者对于调用者来说是透明的
所以在RPC里我们还需要维护好接口和服务提供者地址的关系,这就是常说的“服务发现”;
一个请求到底要发送到服务提供者的哪一个实例,这就是“负载均衡”;
假如在远程调用的过程中,出现了网络故障问题,导致请求失败,为了实现服务稳定性治理,确保服务的高可用性,此时就需要采取一些容错手段:超时重试机制、服务限流、服务熔断等
最终,整体框架是这样的:
一、序列化
1.JDK自带的序列化机制
使用起来非常简单,序列化具体是由ObjectOutputStream完成的,而反序列化则由ObjectInputStream完成
- 头部数据用来声明序列化协议、序列化版本
- 对象数据主要包括类名、签名、属性名、属性类型、属性值,其他都是为了反序列化用的元数据
2.Json
典型的key-value方式,是一种文本型的序列化框架,也是我们最熟悉的一种序列化格式
但是它的空间开销比较大,而且Json没有类型,像java这种强类型语言,需要通过反射统一解决,性能不太友好
3.Hessian
是一种二进制、紧凑的、可跨语言移植的序列化框架,空间开销更小,性能上要比JDK、JSON序列化高效很多,而且生成的字节数也更小
但Hessian本身也有问题,官方版本对Java里面一些常见对象的类型不支持,比如:Byte/Short反序列化的时候变成Integer
4.Protobuf
是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化;序列化后的体积比json、Hessian小很多,序列化反序列化速度很快,不需要通过反射获取类型,兼容性也比较好
当然,它也是有缺点的:比如:不支持null;ProtoStuff不支持单纯的Map、List集合对象,需要包在对象里面
如何选择?
- 第一个要考虑的因素必然是安全性,以JDK原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵
- 序列化与反序列化过程是RPC调用的一个必须的过程,它的性能和效率势必将直接关系到RPC框架整体的性能和效率,所以时间开销也是一个指标
- 序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,所以空间开销也要考虑在内
- 另外,我们还需要看重序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,没错,就是序列化协议的兼容性
综合上面,我们首选的是Hessian与Protobuf,因为他们在时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求;
其中Hessian在使用上更加方便,在对象的兼容性上更好;
Protobuf则更加高效,通用性上更有优势
二、消息协议
@Data
@AllArgsConstructor
public class Header implements Serializable {
private short magic; // 魔数 2个字节
private byte serialType; //序列化类型 1个字节
private byte reqType; // 消息类型 1个字节
private long requestId; // 请求ID 8个字节
private int length; //消息体长度 4个字节
}
@Data
public class RpcRequest implements Serializable {
private String className; // 类名
private String methodName; //请求目标方法名
private Object[] params; // 请求参数
private Class<?>[] paramsTypes; // 参数类型
}
@Data
public class RpcResponse implements Serializable {
private Object data;
private String msg;
}
@Data
public class RpcProtocol<T> implements Serializable {
private Header header;
private T content;
}
三、编解码器
由于自定义了消息协议,所以 需要自己实现编码和解码:
编码器
我们需要实现MessageToByteEncoder
类,然后重写encode
方法
设置请求头的相关信息,根据请求头的序列化类型,选择相应的方式进行序列化,最后传输数据
public class RpcEncoder extends MessageToByteEncoder<RpcProtocol<Object>> {
@Override
protected void encode(ChannelHandlerContext ctx, RpcProtocol<Object> msg, ByteBuf out) throws Exception {
System.out.println("============begin RpcEncoder=========");
Header header = msg.getHeader();
out.writeShort(header.getMagic());
out.writeByte(header.getSerialType());
out.writeByte(header.getReqType());
out.writeLong(header.getRequestId());
// 序列化内容
ISerializer serializer = SerializerManager.getSerializer(header.getSerialType());
byte[] data = serializer.serializer(msg.getContent());
out.writeInt(data.length);
out.writeBytes(data);
}
}
解码器
实现ByteToMessageDecoder
类,然后重写decode
方法
- 如果接收的数据长度<头部的总长度,直接返回
- 读取魔数,判断是否相等
- 按照消息协议中定义好的顺序逐步读取序列化类型、请求参数、消息体的内容
- 根据请求类型和序列化类型对数据进行反序列化,然后返回
public class RpcDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println("========begin RpcDecoder==========");
if (in.readableBytes() < RpcConstant.HEAD_TOTAL_LEN) {
return;
}
in.markReaderIndex(); //标记读取开始索引
short maci = in.readShort(); //读取magic
if (maci != RpcConstant.MAGIC) {
throw new IllegalArgumentException("Illegal request parameter 'magic'," + maci);
}
byte serialType = in.readByte(); //读取一个字节的序列化类型
byte reqType = in.readByte(); //读取一个字节的消息类型
long requestId = in.readLong(); //读取请求id
int dataLength = in.readInt(); //读取数据报文长度
if (in.readableBytes() < dataLength) {
in.resetReaderIndex(); // 还原数据
return;
}
//读取消息体的内容
byte[] content = new byte[dataLength];
in.readBytes(content);
Header header = new Header(maci, serialType, reqType, requestId, dataLength);
ISerializer serializer = SerializerManager.getSerializer(serialType);//获得序列化类型
ReqType rt = ReqType.findByCode(reqType);//获得请求类型
switch (rt) {
case REQUEST:
// 将内容反序列化
RpcRequest request = serializer.deserializer(content, RpcRequest.class);
// 最好的返回体
RpcProtocol<RpcRequest> reqProtocol = new RpcProtocol<>();
reqProtocol.setHeader(header);
reqProtocol.setContent(request);
// 传递
out.add(reqProtocol);
break;
case RESPONSE:
RpcResponse response = serializer.deserializer(content, RpcResponse.class);
RpcProtocol<RpcResponse> resProtocol = new RpcProtocol<>();
resProtocol.setHeader(header);
resProtocol.setContent(response);
out.add(resProtocol);
break;
case HEARTBEAT:
//TODO
break;
default:
break;
}
}
}
四、动态代理
RPC 会自动给接口生成一个代理类,当我们注入接口时,运行过程中实际绑定的是这个接口的代理类
在接口方法被调用的时候,它实际上被代理类拦截到了,我们可以在代理类里面加入远程调用逻辑
因此屏蔽了远程调用的细节,达到了调用远程与调用本地没啥区别的体验感
在我的项目中,我就是这么操作的(最终达到绿色框的效果):
1.我创建了HelloService接口的代理类helloService
2.在代理类中添加了远程调用的逻辑:消费者想要调用服务提供者的哪个类中的哪个方法?
参数类型是什么?传入的参数值是什么?
3.运行过程中绑定的就是接口的代理类,然后当我们使用代理对象调用某个方法的时候,最终都会被转发到 invoke() 方法
所以在调用sayHello()方法时,就会进入到invoke()中执行远程调用的逻辑
五、注册中心
这里涉及到了数据存储、事件监听机制、心跳机制等多个复杂的工作,而市面上切好有满足这些特性的开源组件,再考虑到项目整体的进度,最终选择了开源的解决方案
Zookeeper(CP)
- zookeper提供watcher机制,可以监听相应的节点路径,一旦路径上的数据发生了变化,我们便向其他订阅该服务的服务发送数据变更消息
- 当master节点出现故障时,就会在剩余节点中重新进行leader选举,在选举期间导致整个zookeper集群不可用,所以zookeper选择了一致性,是CP
- zookeper提供心跳检测功能,定时地向各个服务提供者发送心跳请求;如果某个服务一直未响应,则说明服务挂了,就把该节点删除
Nacos(CP/AP)
- 服务提供者启动时,会向nacos注册服务信息并建立心跳机制
- 服务消费者启动时,会从nacos中读取服务的实例列表,缓存到本地;并开启定时任务,每隔10s轮询一次服务列表并更新
- nacos采用map存储实例信息,当配置持久化后,该信息会被保存到数据库中
- 服务健康检查:nacos提供了 [客户端上报] 与 [服务端主动检测] 两种模式
- nacos支持AP和CP两种架构,根据ephemeral配置决定
ephemeral = true,则为AP
ephemeral = false,则为CP
Eureka(AP)
- 服务提供者启动时,会向eureka注册服务信息
- 服务消费者会从eureka中定时地以全量或增量的方式获取服务提供者的信息,并缓存到本地
- 每隔30s,每个服务都会向eureka发送一次心跳请求,确保当前服务正常运行;若90s内,eureka没有收到心跳请求,就会把对应的服务节点剔除掉
- eureka为去中心化结构,没有主从节点之分,只要还有一个eureka节点存活,就能保证服务可用。但是可能会出现数据不一致的情况,所以eureka符合AP
Consul(CP)
- 服务提供者启动时,会向consul发送一个post请求,注册服务信息
- 服务消费者发起调用时,会向consul发送一个get请求,获取对应服务的全部节点信息
- consul每隔10s会向服务提供者发送健康检查的请求,确保服务存活,并更新服务节点列表信息
- 遵循一致性原则,即CP
选型
- 对于consul,它的底层语言是Go,更支持容器化场景,而当前RPC框架采用的是java语言,所以淘汰
- 对于eureka,它很适合作为注册中心,但是目前国内使用人数较少,维护更新频率较低,所以先不使用了~
- 对于nacos,它是目前国内非常主流的一种注册中心,而且由 Alibaba 开源
- 最后我还是选择了Zookeeper:
1.虽然 Zookeeper 追求一致性导致其不太适合于注册中心场景,但是国内 Dubbo 框架选用了 Zookeeper 作为注册中心,能从 Dubbo 框架中参考到许多优秀的实现技巧
2.通过操作 Zookeeper 节点,从更加底层的角度感受如何实现注册服务
六、负载均衡
在项目中,我实现了五种负载均衡算法:
加权随机、简单轮询、加权轮询、平滑加权轮询、最小活跃数算法
1.加权随机
假设有一组服务器servers=[A,B,C],对应的权重weights=[5,3,2],权重总和为10
(1)现在把这些权重平铺在一维坐标上,那么服务器A在[0,5]区间内,服务器B在[5,8]区间内,服务器C在[8,10]区间内
(2)接下来在[0,totalWeight]内生成一个随机数,该随机数落在哪个区间上,就选择哪个区间上的服务器
步骤:
先计算出总权重,判断所有权重是否相等
若不相等:则在[0,totalWeight]内生成一个随机数random
若相等:随机
pubic static String getServer(){
int totalWeight = 0;//总权重
boolean sameWeight = true;//判断所有权重是否相同
Object[] weights = ServerIps.WEIGHT_LIST.values().toArray();
//1.计算总权重,并判断所有权重是否相等
for(int i=0;i<weights.length;i++){
Integer weight = (Integer)weights[i];
totalWeight += weight;
if(sameWeight && i>0 && !weight.equals(weights[i-1])){
sameWeight = false;
}
}
//2.在[0,totalWeight]区间内生成一个随机数random
Random random = new Random();
int randomPos = random.nextInt(totalWeight);
//如果权重不等:
if(!sameWeight){
for(String ip : ServerIps.WEIGHT_LIST.keySet()){
Integer weight = ServerIps.WEIGHT_LIST.get(ip);
if(randomPos < weight){
return ip;//假如random=7,服务A的权重是5,7<5,false,random就变成7-5=2
}else{ //再进入下一波循环,遍历到服务B,权重是3,random=2<3,true,此时选用服务器B
//所以,random在哪个区间就选择哪个区间上的服务器
randomPos = randomPos - weight;
}
}
}
//如果所有权重都相等:随机
randomPos = random.nextInt(ServerIps.WEIGHT_LIST.size());
String ip = ServerIps.WEIGHT_LIST.keySet().toArray()[randomPos];
return ip;
}
2.简单轮询
这个就很简单了,依次调用,非常公平
但是我们想让性能比较好的服务器处理更多的请求,能者多劳,所以就有了下面的加权轮询算法
3.加权轮询
计算出服务器的请求次数
offset = 请求次数%总权重
其实和加权随机的逻辑差不多,只不过加权随机的offset是[0,totalWeight]生成的随机数
比如一组服务器servers=[A,B,C],对应的权重weights=[5,3,2],权重总和为10
[0,5]区间属于服务器A,[5,8]区间属于服务器B,[8,10]区间属于服务器C
如果是第6次请求,应该选择服务器B吧,是怎么选的呢?
--offset = 6%10 = 6,遍历到服务器A时,6<5,false,所以offset=6-5=1,再遍历到服务器B,offset=1<服务器B权重3,true
但是按顺序访问,调用序列就是AAAAABBBCC
这种算法有一个缺点:一台服务器的权重特别大的时候,他需要连续的处理请求,好像就失去了负载均衡的意义呜呜┭┮﹏┭┮
所以,就引出了下面的平滑加权轮询
4.平滑加权轮询
给服务器设置两个权重,weight和currentWeight
weight是固定权重
currentWeight是可以动态调整的,初始值为0
遍历服务器列表,让currentWeight = currentWeight + weight
找到那个currentWeight最大的服务器
让最大的currentWeight减去总权重
经过平滑加权处理后,调用顺序AABACAA,相比刚才普通加权轮询的情况,分布性更均衡一些~
初始情况currentWeight=[0,0,0] ,在第7个请求处理完后,currentWeight再次变回[0,0,0]
你会惊讶的发现在第8次的时候,当前currentWeight数组又变回了[5,1,1] !!!
5.最小活跃数算法
每个服务提供者对应一个活跃数,初始情况下,活跃数为0
当服务提供者收到请求时,活跃数+1,处理完毕后,活跃数-1
性能好的服务器处理请求速度快,因此活跃数下降的也越快,更空闲,此时这样的服务提供者能优先获得新的请求,这就是最小活跃数算法的基本思想
除了活跃数,这种算法还引入了权重值,
如果多个服务器有相同的最小活跃数,处理请求都很快 不分伯仲,那么就会根据权重分配请求:
权重值大的服务器获得新请求的概率就越大
如果权重相等,则随机选择
七、服务容错
重试机制
假如在远程调用时,网络突然抖动了一下导致请求超时,如果此时发起了重试,业务逻辑是否会被执行呢?会的
那如果这个业务逻辑不是幂等的,比如插入数据操作,触发重试就会引发问题,这一点要格外注意;我们要确保被调用的服务的业务逻辑是幂等的,才能根据事件情况考虑开启异常重试功能
当调用端发起RPC请求时,如果发送请求异常并触发了重试机制,我们可以先判定下这个请求是否超时,如果超时就直接返回超时异常,否则就重置这个请求的超时时间,之后再发起重试
再往下考虑,在RPC调用时通过负载均衡选择节点,将请求发送到了这个节点,如果这个节点因为负载压力较大导致请求处理失败,调用端发起重试,假如再次负载均衡选择的恰好还是这个节点,重试的结果就会受到影响。所以,我们需要在所有发起重试、负载均衡选择节点时,去掉重试之前出现过问题的那个节点,把它剔除掉,来保证重试的成功率
限流-服务端的自我保护
如果我们要发布一个RPC服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,我们该如何保护这个节点?
既然负载压力高,那就不让它再接收太多的请求就好了,限流
我们可以在服务端添加限流的逻辑,当调用者发送请求时,服务端在执行业务逻辑前先执行限流的逻辑,如果发现访问量过大并且超出了限流的与之,就直接抛出一个限流异常,否则就正常地执行业务逻辑
实现限流的方式有很多,比如最简单的计数器,还有可以平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等,我是用的令牌桶哦~
熔断-服务端的自我保护
服务A调用服务B,服务B又需要调用服务C;如果此时服务C超时了,导致B的业务逻辑一直等待,而这个时候服务A在频繁地调用服务B,服务B就可能会因为堆积大量的请求而导致服务宕机
在整个调用链中,只要中间有一个服务出现问题,就有可能引起上游的所有服务出现一系列问题,甚至引起整个调用链的服务都宕机,产生级联故障,这是非常恐怖的
所以,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断
熔断机制:
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换
在正常情况下,熔断器是关闭的;
当调用端调用下游服务出现异常时,熔断器会收集异常指标信息,然后进行计算,当达到熔断条件时熔断器打开,这时调用端再发起的请求直接就被熔断器拦截,执行失败逻辑;
当熔断器打开一段时间后,会转换成半打开状态,这时熔断器允许调用端发送一个请求给服务端,(先试探一下),如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开