学习RPC

46 阅读12分钟

项目的更新迭代

一开始用Socket作为Rpc的通信,发现Netty更优于Socket,更换成Netty进行远程调用。在真实的场景下,是会从注册中心注册服务和调用服务的为了简便我们引入ZooKeeper。在使用Socket的时候是使用Socket自带的编码器和解码器,以及序列化器,这种编码,解码,序列化器都是可扩展性很差的,所以我们使用Netty进行自定义的编码解码器。由客户端调用ZooKeeper注册中心的时候性能不高,我们加入了本地缓存,当服务端新增一个服务时,我们怎么动态更新缓存呢,这时使用event来进行动态的监听服务端以及通知客户端。之前客户端进行请求时,我们每次都返回的是列表中的第一个,这肯定是有问题的当流量过大时,我们要使用负载均衡来把请求均摊到各个服务节点。接下来可以进行调用了,但是又出现了问题,当用户调用服务时,如果网络出现波动导致请求失败,那我们应该如何让请求尽可能成功呢,这时我们引入了超时重试机制,但是并不是所有请求都能进行超时重试的,我们必须要确保重试的请求是幂等的(多次调用产生的结果是一样的),我们引入了白名单,将幂等的操作加入到白名单中,重试的时候先去白名单中看是否存在。假如要发布一个 RPC 服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,该如何保护这个节点,如果访问量过大超过了限流的阈值,那就让服务端抛回给客户端一个限流异常,这是站在服务端的角度来说。站在调用端的角度呢,如果A调用B,B又调用C,C出现问题响应超时,那B这个地方就会堆积大量的请求甚至也会响应超时,这个时候就需要使用服务熔断了。

项目中遇到的最大的难点

``

重试策略

无论出现什么异常都重试,出现error也重试,重试策略2秒,停止策略3次

channel的复用

  • 传入一个InetSocketAddress类型的address,把address字符串化作为key,先channelPool.containsKey(key)看是否有相应的channel,没有的话建立连接connect(address),并channelPool.put(key,channel)

技术思考

优化前使用的是多线程的BIO

  • 一个线程一个连接

优化后使用的是Netty的NIO(IO多路复用)

  • 一个线程处理多个连接
  • 避免了线程的上下切换产生的开销
  • 减少了内存占用(不需要多开线程),只需要一个selector就可以监控多个channel

nacos更偏向于微服务的一站式解决方案,Zookeeper偏向于底层的分布式协调

TestClient -> ClientProxy -> IOClient -> ThreadPoolRPCRPCServer -> TestService -> workThread -> ServiceProvider -> IOClient ->TestClient

ThreadPoolRPCRPCserver的threadPool

pipeline.addLast(
    new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE,0,4,0,4));

maxFrameLength:消息的最大长度

LengthFieldOffset:消息的起始位置偏移量,从0的位置开始计算

LengthFieldLength:消息体的长度字节数

LengthAdjustment:从LengthFieldLength后开始计算需要跳过的字节数

InitialBytesToStrip:从起始位置开始计算需要丢弃的字节数

lengthAdjustment(可为负) + 长度域的值 = 后续报文长度(后续报文可能为消息体,也可能为消息体+协议头)

WorkThread ==> 处理客户端传过来的请求,以及根据传过来的请求获取对应的服务实现类和方法并返回给客户端

引入Netty后 NettyRPCServer 监听端口 ==》NettyRPCServerInitializer设置好消息的格式【长度】【消息体】 ==> 调用NettyRPCServerHandler来实现数据的写入返回给客户端服务实现类及客户端要调用的方法

后面我们使用了自定义的编码/解码器那自定义的和LnegthFieldBasedFrameDecoder有什么区别:

  1. 自定义编码/解码器的可扩展性好,他不仅包含了消息的长度,消息体,还包含了消息的类型和消息序列化的方式,甚至后面还能根据业务的选择添加更多的消息头信息。
  2. 自定义的编码/解码器更适合于复杂的RPC场景。

客户端信息发送

TestClient ==> ClientProxy ==> NettyClient ==>(NettyClientInitializer ==> NettyClientHandler ==> ) ZKServiceCenter

服务端消息发送

TestServe ==> (ServiceProvider ==> ZKServiceRegister ==> NettyRPCRPCServer ==> NettyServerInitializer ==>) NettyRPCServerHandler

客户端获取服务地址的过程

客户端在发送RPC请求之前,需要先知道要调用的是哪个服务接口,然后通过这个服务接口名称去ZooKeeper中查找对应的服务地址。

具体流程是这样的:

  1. 客户端首先知道它要调用的是哪个服务接口(比如UserService
  2. 客户端通过动态代理机制拦截对这个接口的方法调用
  3. 在代理中,根据接口名称(interfaceName)去ZooKeeper查询可用的服务地址
  4. ZooKeeper返回注册在该接口名下的所有服务地址
  5. 客户端选择其中一个地址(目前是简单地选第一个,后续可以加入负载均衡)
  6. 然后客户端才与选定的服务地址建立连接并发送RPC请求

在您的代码中,这个过程主要发生在客户端的代理类和服务发现类中:

  1. 客户端代理类(ClientProxy)会获取接口名称:
RpcRequest request = RpcRequest.builder()
	.interfaceName(method.getDeclaringClass().getName())
	.methodName(method.getName())
	.params(args)
	.paramsType(method.getParameterTypes()).build();

2. 然后在NettyRpcClientsendRequest方法中,会根据接口名称查找服务地址:

InetSocketAddress address = serviceCenter.serviceDiscovery(request.getInterfaceName());

3. 最后才使用这个地址建立连接并发送请求

所以,客户端是通过"接口名称"这个信息去ZooKeeper中查找对应的服务地址的。这也是为什么在服务端注册服务时,要使用接口名称作为服务名。

version2 - ObjectSerialize

什么意思?

switch的case是选择0还是1的问题?

客户端--》服务端

客户端:序列化

服务端:反序列化 且 case 0

服务端--》客户端

服务端:序列化

客户端:反序列化 且 case 1

Hessian:

序列化 :

new 创建一个HessianOutPut对象,传入一个OutPutStream

调用writeObject将要序列化的对象作为参数传入

返回OutPutStream中的二进制数据 .toByteArray()

反序列化:

new 创建一个HessianInPut对象,传入一个InPutStream

调用readObject从InPutStream中读取并反序列化对象

自定义的编码器

  • MyEncoder 用于将java对象转换成字节流同时解决TCP粘包问题
    • 写入消息类型
    • 写入序列化方式
    • 得到序列化数组
    • 写入数组长度
    • 写入序列化数组
    • [消息类型(2字节)][序列化类型(2字节)][消息长度(4字节)][消息内容(N字节)]
  • MyDecoder
    • 读取消息类型
    • 读取序列化方式&类型
    • 读取序列化数组的长度
    • 读取序列化数组

Hessian序列化和反序列化

  • 序列化
    • 创建个ByteArrayOutPutStream
    • 创建个HessianOutPutStream并与ByteArrayOutPutStream连接( HessianOutput hessianOutput = new HessianOutput(byteArrayOutputStream), Object也是用了跟这个类似的方法只是把HessianOutput换成ObjectOutPut)
    • 返回ByteArrayOutPutStream.toByteArray()
  • 反序列化
    • 创建个ByteArrayInPutStream
    • 创建个HessianInPutStream并连接连接
    • 返回hessianInput.readObject()

ProtoStuff序列化和反序列化

  • 序列化
    • 通过RuntimeScheme.getScheme(obj.class)获取Schema,定义了对象的结构信息,包括字段名称、类型、顺序等
    • 使用LinkedBuffer.allocate()创建个缓冲区
    • 使用ProtostuffIOUtil.toByteArray(obj, schema, buffer);执行序列化
      • obj:要序列化的对象
      • scheme:能够知道obj的信息从而知道怎么进行序列化
      • buffer:暂时存储序列化完的数据
    • return bytes
  • 反序列化
    • 确定目标类型
    • 获取Schema
    • 创建空对象实例obj = clazz.getDeclaredConstructor().newInstance();
    • 反序列化ProtostuffIOUtil.mergeFrom(bytes, obj, schema);填充对象数据
    • 将bytes中的数据schema的规则映射到obj这个空对象里
    • 返回obj

JSON序列化和反序列化 -- 拿出传输的数据与期待的数据类型进行对比并进行转换

  • 序列化
    • toJSONBytes()一步直接实现 相当于 先toJSON变成JSON字符串 再toBytes变成字节数组
  • 反序列化
    • request
      • 先解析
      • 创建相应长度的数组
      • 比对期待类型与实际类型,不一样就转换
      • 将转换好的数据放回request
      • 返回反序列化结果
    • response
      • 先解析
      • 如果type为空返回fail信息
      • 取出预期类型
      • 比对实际与预期
      • 转换数据
      • 返回反序列化结果

负载均衡

  • 一致性哈希

    • 传进来的节点进行哈希,通过shards.tailMap(hash)得到key大于等于hash值的映射集合
    • 如果集合为空那就将当前节点归到最后一个虚拟节点上,不为空就归到tailMap里的第一个map上通过firstkey得到相应的key,再通过key得到相应的虚拟节点,对虚拟节点进行切割就可以得到真实节点对应的地址,再对String地址进行解析就可得到InetSocketAddress就完成了服务发现。
    • 初始化虚拟节点,添加和删除节点的流程一样
      • for循环传入的节点列表
      • 真实节点列表先把传入的列表节点put或remove
      • 进行for循环循环个数为虚拟节点个数
      • 先得出虚拟节点的名称
      • 对虚拟节点进行哈希
      • shards进行put和remove操作
    • 使用FNV-1a,实现简单
      • 创建个质数p
      • 创建个偏移基数hash
      • 对输入的字符串的每个字符先进行和hash的按位异或再进行 * p的操作
      • 再进行hash左移相加和右移亦或的操作
      • 返回hash的绝对值
  • 随机

    • int choose = random.nextInt(addressList.size());生成一个[0,addressList.size())范围内的数字
    • return addressList.get(choose);  // 获取对应索引下的服务器地址
  • 轮询

    • 获取当前索引并更新为下一个索引
    • int currentChoose = choose.getAndUpdate(i -> (i + 1) % addressList.size());
    • return addressList.get(currentChoose);  // 获取对应索引下的服务器地址

超时重试

  • Guava Retry:要确保被调用的服务的业务逻辑是幂等的,这样才能进行重试机制
    • 幂等性的判断,用HashSet存储是幂等性的操作例如 "get" "query" 等再将这些去比对方法名的前缀(项目中没实现)
    • 我们要把幂等性的操作放入一个白名单中,客户端在请求服务前会去白名单中查看是否存在,如果有,那就使用重试框架进行调用,白名单放在zookeeper中。如果没有就进行一次调用。
    • 使用重试构建器。
    Retryer<RpcResponse> retryer = RetryerBuilder.<RpcResponse>newBuilder()
                        //无论出现什么异常,都进行重试
                        .retryIfException()
                        //返回结果为 error时进行重试
                        .retryIfResult(response -> Objects.equals(response.getCode(), 500))
                        //重试等待策略:等待 2s 后再进行重试
                        .withWaitStrategy(WaitStrategies.fixedWait(2, TimeUnit.SECONDS))
                        //重试停止策略:重试达到 3 次
                        .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                        .withRetryListener(new RetryListener() {
                            @Override
                            public <V> void onRetry(Attempt<V> attempt) {
                                System.out.println("RetryListener: 第" + attempt.getAttemptNumber() + "次调用");
                            }
                        })
                        .build();
    
    • 白名单使用了懒加载 + 本地缓存当使用白名单时会去ZooKeeper相应路径下获取所有可以重试的方法列表并加入到缓存中

服务自我保护

  • 服务端进行限流

    • 如果桶中还有令牌那就curCapcity -- ,直接返回true;
    • 当前时间 - 上次的请求时间只要 >= RATE就说明生成了至少一个令牌
    • 如果相差的时间 / RATE >= 2,那就将curCapcity = 相差的时间 / RATE - 1(当前操作要消耗掉一个令牌)
    • 否则当前令牌容量不变(当前操作正好消耗掉了那个生成的令牌)
    • 如果curCapcity > Capcity,curCapcity=CAPACITY超过了上限将当前值设置为上限
    • 更改时间戳记录这次操作的请求时间timeStamp=current;
  • 限流降级(模拟实现)

    • 在serviceProvider类里实现个方法
      • 先获取限流器,再用令牌桶限流器里的getToken如果获取不了令牌,那就进行服务降级例如get和query的请求就返回null, list的请求就返回空集合
  • 调用端进行熔断(故障降级)

    • closed状态:后台对调用失败的次数进行累加,到一定阈值后开启熔断器
    • open状态:一旦服务失败的次数达到一定次数熔断器打开,同时需要设置一个固定的时间间隔,当处理请求达到这个时间间隔时会进入半熔断状态
    • half_open状态:在半开状态熔断器会放行部分请求,如果这些请求成功处理的数量达到一定比例,那就认为服务恢复正常,关闭熔断器,否则开启熔断器。
  • closed -失败太多次-> open 一段时间后 变为half_open ->对请求计数成功的多变为closed,失败的多变为open

使用Watcher来监听zookeeper的节点变化,根据event的事件类型动态的更新本地缓存

    event(Type type, ChildData childData, ChildData childData1) {
                    // 第一个参数:事件类型(枚举)
                    // 第二个参数:节点更新前的状态、数据
                    // 第三个参数:节点更新后的状态、数据
                    // 创建节点时:节点刚被创建,不存在 更新前节点 ,所以第二个参数为 null
                    // 删除节点时:节点被删除,不存在 更新后节点 ,所以第三个参数为 null
                    // 节点创建时没有赋予值 create /curator/app1 只创建节点,在这种情况下,更新前节点的 data 为 null,获取不到更新前节点的数据
  • 用/进行String.split(),所以""是pathList[0];

复用channel

  • 创建个channel池,每次建立连接前,先看channel池子里面有没有进行过的相同地址的连接

SPI

  • SpiLoader
    • loadSpi:加载指定接口的SPI实现类
      • 读取相对应文件
      • 一行一行读
      • 跳过空行和注释行
      • 用等号进行切割:key = Hessian className = common.serializer.myserializer.HessianSerializer
      • 使用class.name(className)动态加载实现类
      • 然后看加载出来的类是否实现了传进来的接口serviceInterface.isAssignableFrom(implClass)
        • 实现了那就存起来map.put(key, 类)
      • 最后将SPI实现类存入缓存
    • getInstance:根据接口和key获取SPI实现类实例
    • 获取接口名,获取SPI实现类的映射 String interfaceName = serviceInterface.getName();Map<String, Class<? extends Serializer>> keyClassMap = loadedSpiMap.get(interfaceName);
    • 看实现类的映射是否为空
    • 看map里key的对应值是否为空
    • 执行到这说明有值,我们去缓存中找,如果没有就创建一个实例
    • 返回实例return (T) instanceCache.get(implClassName);
      • 序列化器(Serializer)通常是无状态的工具类,不需要每次使用时都创建新实例
      • 通过缓存机制确保每个序列化器实现类只会被实例化一次