RPC原理学习

RPC原理解析

简介:

RPC 的全称是 Remote Procedure Call,即远程过程调用

具有以下作用:

  1. 屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;
  2. 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。

TIPs:

  1. 使用rpc的场景是否合适,
  2. 什么是否需要开启压缩,根据配置,根据部署机器配置,根据网络环境,根据传输数据大小
  3. 调用过程超时处理,以及失败重试机制,例如dubbo的failfast,failover等
  4. 服务集群注意点
    1. 服务注册,发现,服务注册中心
    2. 服务治理,服务分组,服务别名,服务限流,服务降级,服务调用链,链路跟踪
    3. 服务监控,调用链监控,方法监控,数据指标监控(TPS,调用量,可用率,调用返回时间,服务网络响应时间)
    4. 服务日志,聚合查询,整理,告警
    5. 服务集群化,分组化的在线配置中心。支持日志等级控制,服务控制

RPC通信流程:

步骤如下:

  1. RPC是远程调用,需要网络传输数据,并且由于常用于业务系统之间进行远程调用,所以需要使用TCP来进行传输

  2. 网络传输的数据必须是二进制数据,但是调用方请求的出入参数都是对象,所以需要使用可逆的算法,来将对象转化为二进制数据,这一步叫做序列化

  3. 调用方持续的将请求序列化为二进制数据,经过TCP后传输给了服务提供方。服务提供方如何知道请求的数据的大小,以及请求的是哪个接口类型;因此需要约定数据包的格式,这个步骤就是协议的约定

  4. 根据协议格式,服务提供者可以正确的从二进制数据中分割出不同的请求,同事根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这一步就叫反序列化

  5. 服务提供方根据反序列化出来的请求对象,找到对象的实现类,完成方法调用

  6. 将执行结果序列化后,回写到TCP通道中。调用方获取到应答数据后,再进行反序列化得到Reponse数据,完成RPC调用

  7. 简化调用链,利用反射或者其他方法让调用方在调用远程方法时,能够像调用本地接口一样

    image-20210522164901880

RPC协议

RPC协议简介

  • RPC请求在发送到网络中之前,需要将请求转为二进制数据,基于TCP连接和服务方通信,TCP链接会根据系统配置和TCP窗口大小,在同一个TCP链接中,对数据包进行拆分,合并。服务方需要正确处理TCP通道中的二进制数据。

  • RPC协议是一种应用层协议,主要负责应用间的通信,相对于HTTP协议,需要的性能更高,并且RPC是有状态的协议,请求和响应一一对应。RPC一般会设计更加紧凑的私有协议

RPC协议的设计

  • 消息边界语义:利用一个定长数据来保存整个请求协议体的大小;先读取固定长度的位置里面的值,得到协议体长度,再去读取整个协议体的数据

    image-20210522162421147

  • 协议数据序列化方法信息:利用定长的位置存储协议数据的序列化方式

  • 将整个协议分为协议头和协议体,得到定长协议头,该协议头是不可扩展的

    image-20210522162430292

  • 可扩展协议,将协议头改为可扩展的。将协议分为三部分:固定部分,协议头内容,协议体内容;前两部分统称为协议头

    image-20210522162827231

  • RPC为了吞吐量,都是异步并发发送的请求,等待服务应答,因此需要消息ID,来判断应答对应哪个请求


### RPC网络通信

**常见的网络IO模型**

- 同步阻塞 IO(BIO)
    - 在 Linux 中,默认情况下所有的 socket 都是 blocking 的
    - 应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。
    - ![image-20210522185621450](https://euraxluo.github.io/images/picgo/image-20210522185621450.png)
    - 系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态,如果是基于 Java 多线程开发,那么每一个 IO 操作都要占用线程,直至 IO 操作结束。
    - 阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低。
- 同步非阻塞 IO(NIO)
- 同步IO 多路复用(select,poll,epoll)
    - 多路复用 IO 是在高并发场景中使用最为广泛的一种 IO 模型
    - linux总的多个网络连接的 IO 可以注册到一个复用器(select)上,当用户进程调用了 select,那么整个进程会被阻塞。同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。
    - 优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
    - IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求。
- 异步非阻塞 IO(AIO)

**RPC网络io模型**

RPC 调用在大多数的情况下,是一个高并发调用的场景

- 在 RPC 框架的实现中,在网络通信的处理上,我们会选择 IO 多路复用的方式。

- 选择基于 Reactor 模式实现的io框架来实现IO多路复用

- 在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)。

**网络io中的零拷贝**

系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。

- 等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中
- 拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。一次写操作数据要拷贝两次才能通过网卡发送出去

![image-20210522192234064](https://euraxluo.github.io/images/picgo/image-20210522192234064.png)

- 零拷贝技术
    - 零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
- 零拷贝实现
    - mmap+write 方式,核心原理是通过虚拟内存来解决的
    - sendfile 方式
- Netty零拷贝实现:
    - 用户空间数据操作零拷贝优化
        - 收到数据包后,在对数据包进行处理时,需要根据协议,处理数据包,在进行处理时,免不了需要进行在用户空间内部内存中进行拷贝处理,Netty就是在用户空间中对数据操作进行优化
        - Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的  ByteBuf,避免了各个 ByteBuf 之间的拷贝。
        - ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。
        - 通过 wrap 操作,我们可以将 byte[] 数组、ByteBuf、ByteBuffer  等包装成一个 Netty ByteBuf 对象, 进而避免拷贝操作。
    - 用户空间与内核空间之间零拷贝优化
        - Netty  的  ByteBuffer 可以采用 Direct Buffers,使用堆外直接内存进行 Socket 的读写操作,效果和虚拟内存所实现的效果是一样的。
        - Netty  还提供  FileRegion  中包装  NIO  的  FileChannel.transferTo()  方法实现了零拷贝,这与 Linux  中的  sendfile  方式在原理上也是一样的。

### RPC框架设计:

#### 屏蔽处理流程

- java使用动态代理屏蔽实现细节
- golang使用反射等,来实现的

#### RPC架构

##### 网络传输模块

用于收发二进制数据

##### 协议模块

保证数据在网络中正确传输,包括序列化和反序列化功能,数据压缩功能,以及通信协议约定

##### Bootstrap模块

用于屏蔽RPC细节,利用反射或者代理让远程调用大大简化

##### 服务治理模块

赋予RPC服务集群能力,包括服务注册和发现,负载均衡,连接管理,路由,容错和配置管理

架构图如下:

![](https://euraxluo.github.io/images/picgo/30f52b433aa5f103114a8420c6f829fb.jpg)

##### 利用微内核架构,将组件插件化

将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。

提升了RPC框架的可扩展性,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。

![](https://euraxluo.github.io/images/picgo/a3688580dccd3053fac8c0178cef4ba6.jpg)



### 服务注册与发现:

#### 概述:

服务发现(Service Discoery)要解决的是分布式系统中最常见的问题之一,即在同一个分布式系统中的进程如何才能找到对方并建立连接。

##### 服务发现组件的需求

服务发现组件需要以下一些功能:

- 怎么标识一个服务

- 根据服务名的得到服务可用列表
- 服务注册功能,因此高组件是一个独立的,简单的第三方存储,并且存储极简化。
- 同时服务发现组件还需要有服务探活功能,应当提供很多的服务探活选项

##### 服务代理

服务发现组件的从需求上看是服务代理,常见对的服务代理有:

1. 网络代理
    - 如果是使用http通信,那么可以使用nginx作为反向代理,转到各个服务
    - 如果是RPC服务则可以使用LVS或者ESB之内的网络代理服务地址
    - 缺点:当服务增多是,需要维护超多的网络代理,最后将陷入到运维灾难中
2. DNS方式
    - 为服务A配置域名,然后通过配置两个分别指向服务A的实例,客户端只需要使用配置A的域名就可以
    - 问题:DNS是IP级别,无法处理端口信息,DNS携带的数据较少,节点权重,序列化信息等数据无法传递。

服务代理无法满足服务发现组件对的所有需求,所以需要找另外的组件。

#### zookeeper做为服务发现的问题

Zookeeper旨在解决大规模分布式应用场景下的服务协调同步问题;他可以为同在一个分布式系统中的其他服务提供:统一命名服务,配置管理服务,分布式锁服务,集群管理服务等。

##### CAP(C-数据一致性;A-服务可用性;P-服务对网络分区故障的容错性)

zk是一个CP的,即咋子任何时候对于ZK的访问请求都能得到一致的是数据结果,同时系统对于网络分割具备容错性。

##### ZK解决的问题

Zk是一个分布式协调服务,他被设计用于保证数据在其管辖下,在所有服务之前保持同步,一致,因此ZK被设计为CP的。

##### ZK作为服务发现服务的问题

由于zk不能保证每次服务的可用性:

1. 因为对于服务发现服务来说,宁可返回某个包含了不实信息的结果也比什么都不返回的好。
2. 宁可返回某服务5分钟之前在在某几台服务器上可用的信息,也不能因为暂时的网络故障找不到可用的服务器。
3. ZK中,若某网络分区中的节点数小于ZK选取leader节点的法定人数,那么这些节点将会断开,就无法正确提供服务了
4. ZK的特点是强一致性,所有导致ZK集群的每个节点数据在发生更新时,需要通知其他ZK节点同时执行更新,所以当大量服务节点上线时,可能会导致ZK集群无法承载

##### 局限性:

1. 网络化分后,强一致性导致服务注册机制会失效

    ZAB协议保证数据一致性,当发生网络分割时,会破坏服务的整体联通性

2. 持久化存储和事务日志

    为了保证数据一致性,zk使用的事务日志,当集群半数节点写入成功时,该事务有效。同时事务写使用的2PC提交的方式

    但是注册中心只关心实时的健康服务列表,因为调用方不关心历史服务和状态

3. 服务探活

    ZK注册镇中心通常利用session活性心跳和临时节点机制进行服务探活

    将服务的健康检查检测绑定在了ZK对于Session的健康监测上。然后其实应该由服务方决定探活方式

4. 服务容灾

    服务调用链路弱依赖注册中心,同时ZK客户端并无客户端缓存机制

##### 改良

1. 加上服务可用性。使用客户端缓存,当部分节点与zk断开时,每个节点依然能从本地缓存中获取到数据,但是ZK不能保证所有节点任何时刻都能缓存所有的服务注册信息。
2. 将ZK的强一致性改为Ap并保证最终一致性:当我们需要最终一致性时,可以使用消息总线机制。注册数据可以全量缓存在每个注册中心内存中。通过消息总线同步数据。当有一个节点接收到服务节点注册时,会产生一个消息推送到消息总线,最后再通过消息总线通知给其他的注册中心节点更新数据,并进行服务下发,从而达到注册中心数据的最终一致性。

#### Eureka:专为服务发现设计对的开源组件

Eureka由Eureka服务器和Eureka客户端组成,Eureka服务器作为服务注册服务器,Eureka客户端是一个java客户端,用于简化与服务器的操作,作为轮询负载均衡器,并提供服务的故障切换支持。

##### Eureka Server:注册中心服务端

注册中心服务端主要提供了三个功能

**服务注册**

服务提供者启动后,会通过Eureka Client 向Eureka Server注册信息,Eureka Server会存储该服务的信息,Eureka Server内部有二层缓存机制来维护整个注册表

**提供注册表**

服务消费者在调用服务时,如果Eureka Client 没有缓存注册表的话,会从Eureka Server 获取最新的注册表

**同步状态**

Eureka Client 通过注册,心跳机制和Eureka Server 同步当前客户端的状态

##### Eureka Client:注册中心客户端

Eureka Client是一个java客户端,用于简化与Eureka Server的交互,Eureka Client会拉取,更新和缓存Eureka Server中的信息。因此当所有的Eureka Server节点都宕掉,服务消费者依然可以使用缓存中的信息找到服务提供者,但是当服务更改时会出现信息不一致

**Registry:服务注册**

服务的提供者,将自身注册到注册中心,服务提供者也是一个Eureka Client。当Eureka Client向Eureka Server注册时,它提供自身的元数据。

**Renew:服务续约**

Eureka Client会每间隔30s发送一次心跳进行续约。如果续约来告知Eureka Server该Eureka Client运行正常,没有正常问题。默认情况下,如果Eureka Server在90s内没有收到Eureka Client的续约,Server端就会将实例从注册表中删除。

**Eviction:服务剔除**

当Eureka Client和Eureka Server不在有心跳时,Eureka Server会从该服务实例从服务注册列表中删除,即服务剔除

**Cancel:服务下线**

Eureka Client在程序关闭时向Eureka Server发送取消请求。发送请求后,该客户端实例信息将从Eureka Server的实力注册表中删除。该下线请求不会自动完成,需要调用特殊的方法

**GetRegistry:获取注册列表信息**

Eureka Client从服务器获取注册表信息,并将其缓存在本地。客户端会使用该信息查找其他服务,从而进行远程调用。该注册列表信息会定期清理一次。重新拉取新的注册表信息

**Remote Call:远程调用**

当Eureka Client从注册中心获取到1服务提供者信息后,就可以通过Http请求调用对应的服务了;服务提供者有多个时,Eureka Client客户端会通过Ribbon进行自动负载均衡。


##### 高可用:

在Eureka平台中,若某台服务器宕机,Eureka服务不会有类似Zookeeper选举的过程,客户端会自动切换到新的Eureka节点;当宕机的服务器重新恢复后,Eureka会再次将其纳入到服务器集群管理中;因此不用担心会有服务器从集群中剔除的风险

##### 应对网络分割故障

当网络分割故障出现时,每个Eureka节点会持续的对外服务,接收新的服务注册请求同时将他们提供给下游的服务发现请求。这样在一个子网中,新发布的服务依然可以被发现与访问

##### 节点自我保护

Eureka内置了心跳服务,用于淘汰一些假死的服务器;如果在Eureka中注册的服务,心跳变的迟缓,Eureka会将其整个剔除出管理范围。这个功能在发生网路分割故障时会很危险。因为可能服务器是正常的,只不过是因为网络问题到了一个子网中

Netflix考虑添加了自我保护机制,如果Eureka服务节点在短时间内丢失了大量心跳连接,那么该服务节点会进入自我保护状态,这些节点的服务注册信息将不会过期,即便是假死状态,以防还有客户端会向该假死节点发起请求。同时当Eureka节点恢复后,会退出自我保护模式

##### 客户端缓存

Eureka最后还有客户端缓存的功能。当所有的Eureka集群节点都失效,或者发生网络分割故障导致客户端不能访问任何一台Eureka服务器。Eureka消费者依然可以通过客户端缓存找到现有对的服务注册信息



#### etcd 工作原理

etcd是CoreOS团队于2013年发起的开源项目,目标是构建一个高可用的分布式键值数据库,etcd内部采用raft协议作为一致性算法,并给予Golang语言实现

##### 架构

- 网络层:提供网络数据读写功能,监听服务端口,完成集群节点之间数据通信,收发客户端数据。
- Raft模块: Raft强一致性算法的具体实现。
- 存储模块:涉及KV存储、WAL文件、Snapshot管理等,用于处理etcd支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等,是etcd对用户提供的大多数API功能的具体实现。
- 复制状态机:这是一个抽象的模块,状态机的数据维护在内存中,定期持久化到磁盘,每次写请求都会持久化到WAL文件,并根据写请求的内容修改状态机数据。除了在内存中存有所有数据的状态以及节点的索引之外,etcd还通过WAL进行持久化存储。基于WAL的存储系统其特点就是所有的数据在提交之前都会事先记录日志。Snapshot是为了防止数据过多而进行的状态快照。

##### 基本功能

1. KV存储

    可以用于存储数据,也就是可以存储配置数据

2. watch功能

    可以发现配置的异动,在配置变化时可以起到通知作用

3. key TTL功能

    有key TTL功能,etcdv3可以通过lease租约,设置服务的生存周期,然后通过KeepAlive定期续租,避免过期

##### 服务注册原理

在etcd中服务的注册使用租约(lease)来实现,设置租约的时候按需求设置租约的时间(ttl),类似redis中的EXPIRE key,再把服务自身的节点元数据写入对应的key中,定时去调用KeepAliveOnce来保持租约,如果在期间KeepAliveOnce的消息丢失,或者延迟大于这个租约的ttl则etcd中即将会把这个节点的信息删除,恢复正常时重新发起租约流程

##### 存储结构

etcd3没有树的概念,因此我们需要将平铺展开来的键值对抽象为树的概念,和其他类型的注册中心保持一致

例如:接口子目录,这些都是不过期的节点

```bash
/root/interface/providers
/root/interface/consumers
/root/interface/routers
/root/interface/configurators
```

临时节点存储:

```bash
/root/interface/providers/protocol:ip:port/service?xxx=xxx
/root/interface/consumers/protocol:ip:port/service?xxx=xxx
/root/interface/routers/action:ip:port/service?xxx=xxx
/root/interface/configurators/protocol:ip:port/service?xxx=xxx
```

##### 优点

1. etcd 使用增量快照 , 可以避免在创建快照时暂停 。
2. etcd 使用堆外存储 , 没有垃圾收集暂停功能 。
3. etcd 己经在微服务 Kubernates 领域中有大量生产实践 , 其稳定性经得起考验 。
4. 基于 etcd 实现服务发现时 , 不需要每次感知服务进行全量拉取 , 降低了网络冲击 。
5. etcd 具备更简单的运维和使用特性 , 基于 Go 开发更轻量 。
6. etcd 的 watch 可以一直存在 。
7. ZooKeeper 会丢失一些旧的事件 , etcd 设计了一个滑动窗口来保存一段时间内的事件 , 客户端重新连接上就不会丢失事件了 。
8. etcd支持多语言客户端

##### 临时节点的创建

注意点:

- 防御性容错,允许失败充实,默认策略最多重试一次,每次重试休眠1秒

流程

1. 检查client是否正确初始化,只有正确初始化才会触发后续
2. 创建租约并进行保活,将用户配置的session作为keep-alive时间,默认30s
3. 创建key-value并绑定租约,通过两个线程,一个定期刷新TTL保活,第二个定期检测本地是否过期

##### 获取子节点

注意点

- 因为etcd是平铺的key-value。这里为了避免每次单个provider上线都会触发所有客户端进行拉取,所以使用元数据作为key,并且使用特定的前缀进行区分

流程

​		若我们将获取服务为xxx的providers,这里path对应`xxx/providers`,我们需要将初始索引移动到path的最后一个`/`字符,也就是xxx后第一个字符。通过这个机制来获取我们需要的节点key

##### 删除子节点

流程

- 通过kvClient直接删除对应的path即可

##### 接收watch事件变更

流程

1. 获取服务端事件推送

    从gRPC响应中获取响应事件

2. 根据event.type做不同的处理

    先判断是否为当前服务的path

3. PUT

    新服务上线,动态配置,或者动态路由的下发,直接将服务的元数据保存在URL中

4. DELETE

    服务下线或者动态配置,动态路由的删除

    直接将元数据从URL中删除

##### watch请求监听

注意点

- 幂等,对一个path多次watch时,会取消之前的watch
- watch的丢失和取消,以及自动重试,应该保证可用性,否则会影响服务订阅

流程:

1. 幂等处理
2. 创建gRPC远程本地调用的代理
3. 创建watch对象,并关联回调函数
4. 发起gRPC watch调用
5. 首次监听时,先手动拉取全部的节点数据


##### 保证一致性:

etcd 使用raft协议来维护集群内各个节点状态的一致性。etcd集群是一个分布式系统,由多个节点相互通信构成一个整体对外服务,每个节点都存储了完整的数据,并且通过Rat协议保证每个节点维护的数据是一致的。

每个etcd节点都维护了一个状态机,并且任意时刻至多存在一个有效的主节点,主节点处理所有来自客户端的写操作,通过raft协议保证写操作对状态机的改动会可靠的同步到其他节点

##### 高性能

单实例支持每秒一千次以上的写从操作,极限写性能可达10kQPS

##### 安全

支持TLS客户端安全认证



### 健康监测

健康监测的目标是为了让调用方可以感知到节点的状态变化

##### 心跳机制

服务调用方,每隔一段时间就询问服务提供方,节点的状态

状态:

- 健康状态:建立连接成功,并且心跳探活成功
- 亚健康状态:建立连接成功,但是心跳请求连续失败
- 死亡状态:建立连接失败

##### 业务优化

健康监测最终目的还是希望可以让某些不健康的节点不要影响我们的业务。因此可以再健康监测时,加入业务相关的因素,例如可用率`时间窗内接口调用成功次数/时间窗内总调用次数`。对于该类低于预期的节点,可以加入到亚健康状态中。

##### 其他考虑

- 健康监测程序所在机器和节点所在机器的网络依然可能故障,也就会出现误判。这时可以将检测程序部署再不同的机器和机房中
- 可以将服务的返回状态保存在MQ中,然后使用专门的消费者进行消费,如果失败率大于阈值,就调用注册中心,进行下线。



### 路由策略

路由策略的目标是希望使用合理的路由策略,1.:选出合适的服务节点子集;2.:让服务的变更平滑过渡。

在一般的集群中,如果采用灰度发布,但是服务如果出问题,影响范围依然不可控,特别是基础服务,影响范围会变得很大。

路由策略就是通过改变调用请求的路由方向,细粒度的控制服务的影响范围。

##### 路由策略位置

调用方发起RPC调用流程:

1. 首先服务发现会返回可用的服务列表
2. 在可用服务列表中选择合适的节点发起请求

我们就可以在第二部,**在可用的服务列表中选择合适的节点**这个流程,加上合适的节点筛选规则,这个规则就是路由策略。

最后,流程图如下:

![](https://euraxluo.github.io/images/picgo/b78964a2db3adc8080364e9cfc79ca68.jpg)

##### IP路由

IP路由策略可以限制调用服务方的IP,使用了ip路由策略后,对于服务变更,我们就可以将服务调用方的ip做限制,让变更的节点,只被少数调用方使用。

##### 参数路由

首先,将每次变更的节点打上tag,例如version等,用于区分不同批次的节点。然后我们需要一个参数配置中心。用于配置我们的参数路由策略。

然后当我们进行请求时,就可以根据请求参数,根据参数规则过滤响应的节点,然后就实现了我们的参数路由策略。

##### 路由功能的用途

- 灰度发布
- 定点调用
- 黑白名单
- ab_test
- 并行开发时,隔离出不同的环境

#### dubbo的路由功能

dubbo主要是服务路由,服务路由包含一条路由规则,路由规则决定了服务消费者的调用目标,即规定了服务消费者可以调用哪些服务提供者

##### buddo的路由策略

- 条件路由ConditionRouter

    将条件规则配置成kv对,然后对条件规则配置进行解析,路由时,将路由到符合条件host中

- 脚本路由ScriptRouter

    将脚本作为字符串传入脚本路由解析器中,通过脚本的类型,调用对应的脚本解析器,然后将数据传入至脚本中,运行结束后得到应该路由的host

- 标签路由TagRouter

    对于服务配置tag标签对,例如tag_name1=>host1,加载tag配置后,解析为标签路由配置项,会将tag路由到对应的host中

##### dubbo的路由创建时机

每次url发生变更后,都会触发路由信息重建

### 负载均衡

负载均衡SLB是一种对流量进行按需分发的服务,通过将流量分发到不同的后端服务来扩展应用系统的吞吐能力,并且可以消除系统中的单调故障,提升系统的可用性

负载均衡主要分为应用型负载均衡和传统型负载均衡

应用型负载均衡主要面向七层,基于负载均衡应用

传统型负载均衡主要面向四层,基于物理机架构

##### RPC的负载均衡

RPC的负载均衡完全由RPC框架自身实现,RPC的服务调用在每次发起RPC调用时,服务调用者都会根据负载均衡插件

RPC负载均衡策略一般是包括权重,随机权重,一致性Hash,轮询,随机。

##### 轮询法

- 获取地址列表,并维护一个地址指针,每次循环取指针指向的地址,当指针大于地址列表长度时,重置为0
- 访问次数%地址列表长度,注意访问次数使用原子类计数器实现

##### 权重法

例如: 

address1 weight 1

address2 weight 2

地址列表:address1 address2 address3

- 对地址列表中的地址,根据权重,重复,例如address2,权重为2,则重复两次。将这样的结果作为地址列表,再进行轮询
- **随机权重**,在得到根据权重修改的列表后,根据随机法获取地址

##### 一致性hash:相同的参数总是落在一个节点上

- hash(参数)%地址列表长度,得到这样的index,然后从地址列表中获取对应的数据

##### 最少活跃调用数

- 相同活跃数的随机,活跃数值得是调用前后技术差

##### 自适应的负载均衡

负载均衡插件需要得到每一个服务节点的处理请求的能力,然后根据处理能力来分配流量。

服务调用者在与服务节点进行长连接时,可以手机服务节点的各个指标,例如:CPU核数,请求处理的耗时指标情况(请求平均耗时,TP99),内存大小,服务节点的健康状态。然后根据很多指标,并根据每个指标的权重,得到一个总体的数据。

最后得到每个节点的分数后,根据最终的指标分数修改服务节点的最终权重,然后再使用随机权重法来进行流量调度

![](https://euraxluo.github.io/images/picgo/00065674063f30c98caaa58bb4cd7baf.jpg)

**步骤**

1. 添加服务指标收集器,并将其作为插件,可以在运行时收集状态指标,默认有健康状态收集,请求耗时收集等
2. 运行时状态指标收集器收集服务节点的基本数据和健康状态,在服务调用者和服务提供者的心跳数据中获取
3. 请求耗时指标收集器收集请求耗时指标,例如平均耗时,TP99等
4. 配置指标收集器的开启,并且可以设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分
5. 根据服务节点的综合打分和节点的权重,最终得到节点的最终权重,之后服务调用者再根据随机权重法选择服务节点

#### dubbo负载均衡

##### 多种级别

- 服务端级别
- 服务端方法级别
- 客户端服务级别
- 客户端方法级别

dubbo的多种配置是有覆盖关系的,配置优先级是

1. 客户端方法级别
2. 客户端接口级别
3. 服务端方法级别
4. 服务端接口级别

### 异常重试

RPC的重试机制:当服务调用端发起RPC调用时,会经过负载均衡,选择一个节点,之后会向该节点发起请求。当消息发送失败或者收到异常消息时,我们1就可以捕获异常,当异常符合条件,根据异常触发重试,重新通过负载均衡选一个新的节点请求消息,并且记录重试次数,当次数达到阈值,就返回给调用端一个失败异常。

##### 幂等

在使用RPC框架时,我们要确保被调用的服务的业务逻辑是幂等的,这样才能根据开启RPC的异常重试。

##### 注意点

- 当连续重试时,请求超时时间需要每次重试后都进行重置,否则会超出用户设置的超时时间
- 在发起重试,负载均衡选择节点时,应该去掉之前异常的节点,保证重试的成功率
- 异常重试白名单,当网络异常,连接异常等一些异常我们知道需要进行重试,但是由很多业务异常也是需要进行异常重试的。这时我们需要配置一个异常重试白名单。当捕获异常后,如果异常在白名单中,我们就需要对这个请求进行重试

#### dubbo集群容错

dubbo的集群容错功能由多个组件共同完成:包括Cluster,Cluster Invoker,Directory,Router,LoadBalance

![](https://euraxluo.github.io/images/picgo/830731-20200502203856324-835993574.jpg)

- Failover Cluster:失败自动恢复

    不断重试机制,会把请求过得节点保存进来,避免重复请求

- Failfast Cluster:快速失败

    只调用一次,异常则抛出,正常则返回结果

- Failsafe Cluster:失败安全

    只调用一次,异常时,忽略所有异常,返回默认值,正常则返回结果。

- Failback Cluster:失败自动恢复

    只调用一次,当调用失败后,会将失败的请求,放入到延迟队列中,等待一会之后,重试。

- Forking Cluster:并行调用多个服务提供者

    同时发起n个并发请求调用者,返回最先响应的结果,其他忽略。若都失败,则返回自定义异常

- Broadcast:-广播容错

    向所有invoker发起调用,全部成功才算成功

- mergeable:归并容错

    调用所有的invoker,最后merge所有的返回结果

### 优雅关闭

当服务提供方进入关闭流程时,很多对象会开始被销毁,当关闭后再收到的请求,可能无法正常处理。因此需要优雅关闭,保证所有的调用方都能安全切走流量,不再调用自己,从而做到对业务无损。

**设置挡板**:应该在关闭的时候,设置请求挡板,当服务提供方开始关闭时,会将之后收到的请求直接返回特定的异常给调用方。

当调用方收到该异常响应后,RPC框架会把该节点从健康节点移除,并把请求自动重试到其他节点。并且该请求并未被处理过,可以安全重试到其他节点,实现对业务的无损。

**主动通知**:当服务端关闭后,可以主动通知注册中心下线节点。起到即使通知的作用。

**关闭事件捕获**:通过捕获操作系统的进程信号实现。当服务启动时,主动注册关闭事件的hook。在hook中,一个开启关闭标识,该关闭标识用于调用链,当调用链的hook判断关闭标识生效,则返回特定的请求。另一个负责关闭服务对象。JDK中可以通过ShutdownHook进行捕捉,该函数在以下情况生效

- 程序正常退出
- System.exit()
- ORM异常
- kill PID

**安全结束**:关闭过程中已经接受的请求应该保证正确处理结束。可以通过请求计数器实现。每开始处理请求时,请求计数器加一,完成请求响应,请求计数器减一。可以通过该请求计数器判断是否有未完成的请求。当业务请求耗时太长时间,可以设置关闭超时时间,超时时间到达时,强制关闭。

**总结**:从外层到里层逐层关闭,先保证

#### dubbo优雅停机

**步骤:**

1. zk和注册中心相关的释放
    - 断开zk连接(当断开连接时,zk临时节点也会删除,此时provider就完成删除注册信息的功能)
    - consumer在zk的注册信息时持久化的,并没有删除,只删除了监听器,此时dubbo等待下一次注册上线时,重新设置监听器
2. protocl释放
    - 释放 invoker redistry 信息,dubbo根据invoker 注册 flag判断是否释放完所有的invoker registry信息
    - 服务协议关闭。该部分针对远程调用请求做了安全结束的处理,保证所有的以接入请求正常处理结束
    - 关闭所有的client,保证作为消费者不发送新的远程调用请求
    - 关闭所有的server,并且为server设置关闭 flag,保证作为provider不接受新请求

### 优雅启动

在java中,JVM使用了JIT技术,运行了一段时间的应用会因为由缓存变得更快。所以我们可以利用优雅启动来实现启动预热。

**启动预热**:

让刚启动的服务提供方应用不承担全部的流量,而是让服务被调用的次数随着时间慢慢增加。也即对于刚注册上线的应用进行降权,并且随着时间慢慢加权。

- 需要让调用方可以发现刚启动不久的应用,并且通过负载均衡,使得刚启动上线的应用被选择的概率随着时间慢慢变大。

- 当服务在注册中心注册上线时,告知注册时间。同时在负载均衡部分。设置一个定时任务,他会把那些未达到预设权重的机器,让他的权重随着时间慢慢变大,直到到达预设权重。

**延迟暴露**:

应用启动时,除了在RPC注册中心注册上线,还包括很多对象初始化工作,加入对象初始化没有完成就开始接收请求,便可能导致服务调用失败。

- 将注册上线时间推迟到对象初始化之后
- 添加注册前hook,使得用户可以预加载缓存,并且对应用进行预热

![](https://euraxluo.github.io/images/picgo/3c84f9cf6745f2d50e34bd8431c84abd.jpg)

**大批量重启启动tips**

当请求较多时,若设置了启动预热的降权功能,可能会将原来可以负载的请求,变得无法负载,因为请求都会打到原来预留的机器上

- 分批启动
- 减慢启动速度,降低重启的并行度
- 请求低峰时重启

### 服务保护:熔断,限流,降级

当RPC面临高并发的场景时,我们的服务器节点可能会因为访问量过大而引起一系列问题,比如业务处理耗时过长,CPU飘高,频繁Full GC以及服务进程直接宕机等。

我们需要对服务节点进行自我保护,保证在高访问量,高并发对的场景下,服务的稳定性和高可用。

##### 限流

- 在RPC框架中集成限流功能,配置中心或者注册中心配置总的限流阈值,并且配置下发时,将总节点数一起下发,然后由各个服务节点计算自己的限流阈值,当服务调用请求流量超过阈值,框架直接返回限流异常

- 服务端手动添加限流逻辑,当调用方发送请求时,服务端在业务逻辑前先执行限流逻辑,当访问量过大时,服务端抛出限流异常。

**限流算法**

- 计数器(固定窗口)

    计数器算法是限流算法中最简单的一种算法。

    假设对于某接口,1分钟内的访问次数不能超过100个。

    计数器算法如下:

    1. 初始化计数器
    2. 每当一个请求到达,计数器incr
    3. 当计数器值>阈值,且当前时间和初始化时间之差小于一分钟,说明需要限流,请求丢弃
    4. 当计数器>阈值,且当前时间和初始化时间之差大于一分钟,计数器重新初始化

    算法无论在单机还是分布式都很好实现,使用redis的incr+ttl即可,该算法通常用于QPS限流和访问量限流,但是该算法会有临界问题。

    也即当两个相邻周期的临界点前后分别涌入大量请求,虽然都在各自周期的阈值内,但是在临界点前后的访问量已经超过了阈值

- 滑动窗口

    滑动窗口算法是将时间周期分割为N个小周期,分别记录每个小周期内的访问次数,并且根据时间滑动删除过期的小周期

    假设对于某接口,1分钟内的访问次数不能超过100个。

    算法如下:

    1. 根据需要将一分钟划分为多个小周期,比如划分为30s一个周期
    2. 初始化计数器,维护一个小周期队列
    3. 当请求到达时,计数器incr
    4. 当计数器初始化时间和当前时间之差大于小周期时间30s,将该周期计数值放入队列中。并初始化下一个小周期的计数器,以及删除掉队列中过期的时间周期
    5. 当计数值加上队列中的上几个周期的计数值,大于阈值,说明当前窗口达到阈值,需要限流。直到本小周期结束。

    算法依然可以使用redis实现,其中队列的维护可以使用流水线事务或者lua,实现原子性

- 漏斗算法(漏桶算法)

    漏斗算法是访问请求到达时直接放入漏桶中,如当前容量已经达到上限(缓存的上限)。则进行丢弃。漏桶以固定的速率通过请求。直到漏桶为空。

    算法过程:

    1. 到达的数据包,被放置于队列中
    2. 队列中最多可以缓存x个字节的数据,当该内存满了,数据包应该被丢弃
    3. 数据包从队列中取出,并且以固定的速率注入网络,因此**平滑了突发流量**

    相对于计数器算法和滑动窗口算法,漏桶算法通过一个桶,能够装入一定量的请求,当发生突发流量时,在不超过桶容量的前提下,依然可将突发流量全部平滑的处理完。

- 令牌桶算法

    令牌桶算法是程序以r(r=时间周期/限流值)的速度向令牌桶增加令牌,直到令牌桶满,请求到达时向令牌桶请求令牌,如获取到令牌则通过请求,否则触发限流,请求丢弃。

    令牌桶为了处理流量速率多变的流量做了优化,令牌桶算法主要由三部分构成:

    - 令牌流:流通令牌的管道,用于生成的令牌的流通,放入令牌桶中
    - 数据流:进入系统的数据流量
    - 令牌桶:保存令牌的区域,可以理解为一个缓存区,令牌保存在这里用作使用

    算法原理

    1. 按照固定的速率生成令牌并放入令牌桶(队列)中
    2. 数据访问系统时,需要从令牌桶(队列)中获取令牌,无令牌的需要被抛弃,有令牌对的可以进入。

    当访问量小时,可以留存令牌,当有突发流量时,系统也能提高负载去解决突发流量,当流量持续性的大量流入时,最后会退化为漏斗算法的固定流量速率。

##### 熔断

**限流**是为了保护服务端,通过限制流量请求保护自身节点不受影响。

**熔断**是为了对调用端进行保护。防止调用端收到被调用服务的影响。

**熔断机制**

状态转移:

- 关闭
- 打开
- 半开

在正常状态下,熔断器为**关闭**状态;当调用端调用下游服务出现异常时,熔断器收集异常指标信息进行计算,当达到熔断条件时,**打开**熔断器,此时,调用端的请求会被熔断器拦截,并快速执行失败逻辑;当熔断器打开一段时间后,会转为**半开**,这时熔断器允许调用端发送一个请求给服务端,若该请求成功,则状态置为**关闭**,否则设置为**打开**

**熔断模块**

- 熔断请求判断算法:使用无锁循环队列计数,每个熔断器默认维护10个bucket,每秒一个bucket,每个bucket记录请求的成功,失败,超时,拒绝的状态。默认当错误超过50%且10s内超过20个请求时进行中断拦截
- 熔断恢复机制:对于被熔断的请求,每5s允许部分请求通过,若请求都是健康的,则恢复为关闭状态
- 熔断报警:对于被熔断的请求打日志,并且报警。

**RPC框架整合熔断器**

在RPC调用流程中,首先是动态代理,然后是编解码,然后是网络传输。因此应该添加到动态代理处

##### 服务降级

服务降级是在服务器压力陡增的情况下,利用有限资源,根据当前业务情况,关闭某些服务接口或者页面,一次释放服务器资源以保证核心任务的正常运行。服务降级本质上是为了降低部分服务的处理能力,增强另一部分服务处理能力。

**服务降级方案**

- 服务接口拒绝服务:当出现api降级时,前端提示服务器繁忙
- 服务级别降级,当某个页面服务降级时,该页面可以直接提示服务其繁忙。
- 延迟持久化,页面访问正常,但是当服务涉及CUD时,异步处理,将该部分请求放到延迟队列中,服务恢复后执行。
**服务降级需要考虑的问题**

- 核心服务、非核心服务
- 是否支持降级,降级策略
- 业务放通场景、策略

##### 降级和熔断的对比
**共性**

- 目的: 目的一致,都是从系统的可用性、可靠性着想。放了防止系统的整体缓慢甚至奔溃而采用的技术手段。
- 最终表现: 表现类似,最终都是给用户一种当前服务不可用或者不可达的感觉
- 粒度: 大多都是在服务级别,当然也有一些在持久层层面的应用
- 自治: 基本都是靠系统达到某一临界条件时,实现自动的降级与熔断,人工降级并不是那么稳妥。

**区别**

- 触发原因: 服务熔断一般指某个服务的下游服务出现问题时采用的手段,而服务降级一般是从整体层面考虑的。
- 管理目标层次: 熔断是一种框架级的处理,每一个微服务都需要。而降级一般需要对业务有层级之分,降级一般都是从外围服务开始的。
- 实现方式: 代码级别实现有差异



### 业务分组和流量隔离

##### 业务分组

例如两个城市的业务接口,例如北京的流量突然激增,北京的调用方负载变高,如果影响到上海的调用方,就会导致整个系统的可用率降低。

**RPC集成分组**

当调用方获取服务节点时,是通过接口名去注册中心获取所有的已注册节点,当我们添加分组功能后,我们在进行获取服务节点这一步时,除了使用接口名,那么还需要分组参数,相应的,服务节点在进行注册时也需要带上分组参数。

这样我们的RPC就可以把注册的服务提供方的所有实例分为若干组,每一个分组就可以给单个或者多个不同的调用方调用。

![](https://euraxluo.github.io/images/picgo/128923fefc27a36d056393f9e9f25f69.jpg)

**分组逻辑**

- 非核心应用不要和核心应用一组
- 核心应用之间应该做好隔离

**调用方分组调用高可用**

当调用方在自己组内可调用节点全部done掉时,依然需要保证他能拿到**其他分组**的**部分服务**节点。

解决办法:

- 允许调用方配置多个分组
- 每个分组区分主次分组,或者是优先级
- 当主分组上的节点都不可用时才选择次分组,并且也只能选择次分组某些节点
- 当主分组节点恢复正常,就必须将所有的流量都切换到主节点上

##### 动态分组

动态修改分组数据,从而可以使每一个分组都能拥有动态扩容缩容的效果,在注册中心或者配置中心中修改分组信息,最后通过和服务之前的心跳数据进行交换,让服务提供方得到真实的分组信息。

###  异步RPC

RPC调用的吞吐量的主要原因就是服务端的业务逻辑比较耗时,并且CPU大部分都在等待而不是计算,导致CPU利用率不够。

##### 调用端异步

调用端异步就是通过Future实现异步,调用端发起一次异步请求,并且从请求上下文中获取到一个Future,之后通过Future的get方法获取结果,如果业务逻辑中同时调用多个其他的服务,则可以通过Future的方式减少业务逻辑的耗时,提升吞吐量。

**服务端异步**

- 服务调用方发起RPC调用,直接拿到异步对象,该对象应该有一个方法,能够等待并获取到异步执行结果
- 服务端在收到请求后,在执行业务逻辑前,先个构造异步对象,之后业务逻辑可以在异步处理,处理完成再进行异步通知
- 调用端在收到服务端发送回的响应后,自动将收到的异步对象的异步通知的值,设置到调用端获取的异步对象的通知函数中,这样,就异步通知了调用端。

### 接口安全

RPC一般用于解决内部应用之间的通信,并且这些内部应用一般都是搭建在局域网中。相对于公网环境,局域网内的隔离性更好,也就相对更加安全,因此RPC安全很少考虑数据包篡改,请求伪造等恶意行为。

##### 请求身份安全

服务提供方在收到请求时,不知道这次请求是哪个调用方发起,没法判断该请求是否是该服务确定能为其提供服务的调用方发起的。

解决办法

- 授权平台

- 调用方需要在授权平台申请自己的应用要登记调用的接口,服务提供方可以在授权平台进行审批。

- 授权检验:

    方法一:调用方每次发起业务请求时,先去授权平台上发送认证请求,当授权平台返回可以调用时,调用方才将请求发送至服务提供方

    方法二:调用方启动初始化时,将授权平台颁发的身份信息去服务提供进行认证,当认证通过时认为该接口可以调用。

    认证过程:服务提供方在应用中放一个私钥,这个私钥在授权平台中可以自动为申请调用,并且申请通过的应用进行签名,该签名标识了调用方的唯一身份。当服务提供方收到调用方的授权请求后,只需要验证下这个签名和调用方应用信息是否对应即可。

##### 服务方身份安全

服务调用方可能会在注册中心获取到不可信的。因为RPC接口没有和某个应用发布者进行绑定。

解决办法:

- 授权平台,可以增加接口绑定应用的功能,将接口和某个私钥绑定在一起。

- 注册中心处理
- 当服务提供方启动时,需要把接口实例在注册中心进行注册登记。注册中心可以在收到服务提供方注册请求时,验证请求过来的应用是否和接口绑定的应用一样,当相同时才允许注册,否则就返回错误信息,注册失败。

### 异常定位

##### 异常信息封装

由于RPC系统之前是分布式的,各个子应用,子服务之前拥有复杂的依赖关系,所以通过日志难以定位问题。

所以我们可以将异常信息进行标准化,封装化,例如:

1. 异常码
2. 异常原因
3. 接口名
4. 服务分组
5. 服务名
6. 服务端ip
7. 客户端ip

**日志聚合**

为每个请求都设置一个唯一id

通过请求Id,将该请求经过的日志都记录下来

##### 分布式链路追踪

某分布式应用场景,服务依赖关系为A->B->C->D.

那么整个调用链Trace为ABCD,Span为AB,BC,CD。

**链路追踪原理**

- 每个Trance都由一个唯一标识,TraceID,分布式追踪系统中,通过TraceId来区分每个Trance,通常每个业务请求都对应一个TranceID。

- 每个Span也有自己的唯一ID,多个Span存在父子关系,多个Span组成某个Trance,每个Span描述一个子系统的处理细节。
- 在进行定义时,以span为一个单位进行定义和数据填充。当需要传播span时,可以通过context,http头部,一起其他通信协议的扩展字段部分进行传播。
- 一般而言,客户端程序在使用并定义span后,会将其传入分布式链路追踪的客户端中,由客户端收集并发送到服务端,在服务端进行存储和聚合计算,最后根据span的tag和服务名得到完整的调用链。

### 时钟轮

**RPC中定时任务的解决方案**

RPC中,很多场景都会使用到定时任务,我们有以下几种方案来实现定时任务:

- **sleep**,当需要定时任务时,我们就创建一线程,之后sleep需要的秒数,实现定时的需求。**缺点**:当需要的定时任务较多时,我们需要创建的线程数也会变得很多。
- **轮询**,使用一个线程来处理所有的定时任务,每隔一段时间就扫描所有的定时任务,发现即将执行的定时任务,就进行执行。这里可以使用优先级队列来进行优化,只需要扫描优先级队首的周期睡眠时间内的定时任务。**缺点**:需要短轮询不断判断第一个元素是否过期,造成CPU空耗

- **时钟轮**,是一种优化定时任务的一种算法,可以减少轮询最近即将执行的定时任务的个数,并且还能减少轮询次数。

##### 时钟轮算法

时钟轮是一种环形数据结构,分为多个格子。每个格子代表一段时间,时间越短,精度越高。每个格子上用一个链表保存在该格过期的任务。指针随着时间一格一格转动,并执行对应格子的到期任务。

**名词解释**

- 时间格:环形数据结构,用于存放延迟任务的区块
- 指针:指向当前操作的时间格,代表当前时间
- 格数:时间轮中时间格的个数
- 间隔:每个时间格之间的间隔,代表时间轮能达到的精度
- 总间隔:当前时间轮总间隔,等于格数*间隔,代表时间轮能表达的时间范围。

##### 单表时间轮

![img](https://euraxluo.github.io/images/picgo/20180619115045d49ed7cf-54e2-47e0-bde2-61ba30259daa.jpg)

以上图为例,假设一个格子是1秒,则整个时间轮能表示的时间段为8s, 如果当前指针指向2,此时需要调度一个3s后执行的任务,需要放到第5个格子(2+3)中,指针再转3次就可以执行了。

**单表时间轮存在的问题**
格子的数量有限,所能代表的时间有限,当要存放一个10s后到期的任务怎么办?这会引起时间轮溢出。
有个办法是把轮次信息也保存到时间格链表的任务上。

![img](https://euraxluo.github.io/images/picgo/201806191150588df84d64-ffd2-4170-b5f8-bdefe5e56384.jpg)



如果任务要在10s后执行,算出轮次10/8 round等1,格子10%8等于2,所以放入第二格。
检查过期任务时应当只执行**round为0**的任务,链表中其他任务的**round减1**。

**带轮次单表时间轮存在的问题**
如果任务的时间跨度很大,数量很大,单层时间轮会造成任务的round很大,单个格子的链表很长,每次检查的量很大,会做很多无效的检查

##### 分层时间轮

![img](https://nos.netease.com/cloud-website-bucket/201806191151122ea9ea70-02be-4b55-966e-60ddb8b90afa.jpg)

过期任务一定是在底层轮中被执行的,其他时间轮中的任务在接近过期时会不断的降级进入低一层的时间轮中。
分层时间轮中每个轮都有自己的格数和间隔设置,当最低层的时间轮转一轮时,高一层的时间轮就转一个格子。
分层时间轮大大增加了可表示的时间范围,同时减少了空间占用。

**举个例子:**
上图的分层时间轮可表达8 *8* 8=512s的时间范围,如果用单表时间轮可能需要512个格子, 而分层时间轮只要8+8+8=24个格子,如果要设计一个时间范围是1天的分层时间轮,三个轮的格子分别用24、60、60即可。

**工作原理:**
时间轮指针转动有两种方式:

- 根据自己的间隔转动(秒钟轮1秒转1格;分钟轮1分钟转1格;时钟轮1小时转1格)
- 通过下层时间轮推动(秒钟轮转1圈,分钟轮转1格;分钟轮转1圈,时钟轮转1格)

指针转到特定格子时有两种处理方式:

- 如果是底层轮,指针指向格子中链表上的元素均表示过期
- 如果是其他轮,将格子上的任务移动到精度细一级的时间轮上,比如时钟轮的任务移动到分钟轮上

**举个例子:**

- 添加1个5s后执行的任务
    1. 算出任务应该放在秒钟轮的第5个格子
    2. 在秒钟轮指针进行5次转动后任务会被执行
- 添加一个50s后执行的任务
    1. 算出该任务的延迟时间已经溢出秒钟轮
    2. 50/8=6,所以该任务会被保存在分钟轮的第6个格子
    3. 在秒钟轮走了6圈(6*8s=48s)之后,分钟轮的指针指向第6个格子
    4. 此时该格子中的任务会被降级到秒钟轮,并根据50%8=2,任务会被移动到秒钟轮的第2个格子
    5. 在秒钟轮指针又进行2次转动后(50s)任务会被执行
- 添加一个250s后执行的任务
    1. 算出该任务的延迟时间已经溢出分钟轮
    2. 250/8/8=3,所以该任务会被保存在时钟轮的第3个格子
    3. 在分钟轮走了3圈(3*64s=192s)之后,时钟轮的指针指向第3个格子
    4. 此时该格子中的任务会被降级到分钟轮,并根据(250-192)/8=7,任务会被移动到分钟轮的第7个格子
    5. 在秒钟轮走了7圈(7*8s=56s)之后,分钟轮的指针指向第7个格子
    6. 此时该格子中的任务会被降级到秒钟轮,并根据(250-192-56)=2,任务会被移动到秒钟轮的第2个格子
    7. 在秒钟轮指针又进行2次转动后任务会被执行

**优点:**

- 高性能(插入任务、删除任务的时间复杂度均为O(1)

**缺点:**

- 数据是保存在内存,需要自己实现持久化
- 不具备分布式能力,需要自己实现高可用
- 延迟任务过期时间受时间轮总间隔限制

**缓冲区解决溢出问题**

对于超出范围的任务可放在一个缓冲区中(可用队列、redis或数据库实现),等最高时间轮转到下一格子就从缓冲中取出符合范围的任务落到时间轮中。

**举例:**

- 添加一个600s后执行的任务A
    1. 算出该任务的延迟时间已经溢出时间轮
    2. 所以任务被保存到缓冲队列中
    3. 在时钟轮走了1格之后,会从缓冲队列中取满足范围的任务落到时间轮中
    4. 缓冲队列中的所有任务延迟时间均需减去64s,任务A减去64s后是536s,依然大于时间轮范围,所以不会被移出队列
    5. 在时钟轮又走了1格之后,任务A减去64s是536-64=472s,在时间轮范围内,会被落入时钟轮

### 流量收集和回放

随着接口越来越多,变更也越来越频繁,传统额通过编写脚本的方式来测试接口已经不能满足需求。所以出现了线上流量回放这个测试方法

**基于线上流量回放的思路**

本质上是一种白盒测试,通过mock程序对外依赖中有可能产生变化的内容,使测试更关注接口的代码逻辑。

**RPC实现流量回放**

录制:由于RPC框架中,所有的请求和响应都会经过RPC,我们只要拿到请求的出入参数,并且将这些出入参数录制下来,异步存储,即可实现流量回放的录制功能。

回放:在RPC中,我们把能够接收请求的应用叫做服务提供方,那我们只需要模拟一个调用方,并且把录制的请求参数重新发送回要进行测试的应用中,这样就能实现回放功能。

更多功能:通过RPC框架中集成流量回放功能。我们可以实现在线启停,方法级别录制等功能。

##### 流量回放功能

- 流量几率,流量复制
- 流量循环播放

- 流量速率放大缩小
- 流量频率,持续高压,以及间隔高压
- 流量过滤,使用正则等过滤流量
- 流量集成和写入到其他中间件
- 执行链路记录,能够得到请求的链路
- 链路聚类,对于不同的执行路径进行等价类划分,通过链路路径进行有选择流量回放,较少重复case

### 泛化调用

通常的RPC调用一般是基于接口的,也就是需要依赖服务提供提供的接口API,因为一般而言调用方需要通过该接口API生成动态代理,或者是代码生成服务类。

另一种调用方法就是泛化调用

**原理**

调用方将服务端需要知道的信息,接口名,业务分组名,方法名,参数信息等封装为请求消息发送给服务端,服务端就能解析这条消息

![](https://euraxluo.github.io/images/picgo/a3c5ddba4960645b77d73e503da34b89.jpg)

因此我们使用一个统一的接口GenericService接口类生成动态代理,实现无接口的情况下进行RPC调用。

该接口类具有两个方法:invoke以及asyncInvoke,分别对应同步和异步调用两种方式

##### 序列化问题

泛化调用在没有接口的定义,也即没有接口的传参的情况下,怎么对入参和出参进行正常的序列化呢。

**解决办法**

- 首先我们需要为序列化部分增加一个专为泛化调用提供的专属序列化工具
- 入参和返回参数的类型使用Map来进行定义,然后通过专属的序列化工具进行序列化和反序列化
- 在进行序列化内容传输时,需要明确指出该序列应使用泛化调用的方式进行处理

### 多种RPC协议的兼容

多协议兼容对的关键之处就是要使用一种协议无关的的对象。

-  二进制数据解析:在不同协议切换时,我们需要能根据协议二进制数据的magic number去找到对应协议,并且使用对应协议的数据格式来解析二进制数据包。
- 协议解析:协议解析的本质就是把二进制数据,解析为RPC内部对象,但是该对象一般都是和协议相关的。我们如果需要支持多协议,首先就需要将该协议相关的对象转为和协议无关的对象。
- 内部处理:当我们的协议解析部分返回给我们的是协议无关的对象时,我们就可以在内部逻辑中完全做到和协议脱离。保证协议转换的功能就有对应的协议解析类来完成。

**流程**:

![](https://euraxluo.github.io/images/picgo/43451aea86fef673c3928230191fac37.jpg)

如图:协议1和协议2和RPC内部逻辑进行完全脱离,使用协议无关的对象进行交流。

CC BY-NC 4.0

车辆路径问题
net/rpc学习

Comments