手写RPC框架2


整体架构流程

        什么是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集合对象,需要包在对象里面

如何选择?

  1. 第一个要考虑的因素必然是安全性,以JDK原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵
  2. 序列化与反序列化过程是RPC调用的一个必须的过程,它的性能和效率势必将直接关系到RPC框架整体的性能和效率,所以时间开销也是一个指标
  3. 序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,所以空间开销也要考虑在内
  4. 另外,我们还需要看重序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,没错,就是序列化协议的兼容性

        综合上面,我们首选的是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方法

  1. 如果接收的数据长度<头部的总长度,直接返回
  2. 读取魔数,判断是否相等
  3. 按照消息协议中定义好的顺序逐步读取序列化类型、请求参数、消息体的内容
  4. 根据请求类型和序列化类型对数据进行反序列化,然后返回
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就可能会因为堆积大量的请求而导致服务宕机

        在整个调用链中,只要中间有一个服务出现问题,就有可能引起上游的所有服务出现一系列问题,甚至引起整个调用链的服务都宕机,产生级联故障,这是非常恐怖的

        所以,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断

熔断机制:

        熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换

        在正常情况下,熔断器是关闭的;

        当调用端调用下游服务出现异常时,熔断器会收集异常指标信息,然后进行计算,当达到熔断条件时熔断器打开,这时调用端再发起的请求直接就被熔断器拦截,执行失败逻辑;

        当熔断器打开一段时间后,会转换成半打开状态,这时熔断器允许调用端发送一个请求给服务端,(先试探一下),如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开

  • 42
    点赞
  • 50
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值