
JaneRoad
2022/12/04阅读:65主题:全栈蓝
《RPC实战与核心原理

概念
「RPC 是解决分布式系统通信问题的一大利器。」
分布式系统中的网络通信一般都会采用四层的 TCP 协议或七层的 HTTP 协议。前者占大多数,这主要得益于 TCP 协议的稳定性和高效性。搭建一个复杂的分布式系统过程中,如果开发人员在编码时要对每个涉及到网络通信的逻辑都进行一系列的复杂编码,这将是件多么恐怖的事儿。所以说,网络通信是搭建分布式系统的一个大难题。RPC 对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠。
什么是RPC
名称
RPC 的全称是 Remote Procedure Call,即远程过程调用。
作用
帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地,开发者不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。
底层网络协议
RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输。
序列化
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程叫做“序列化”。
请求协议
把数据格式的约定内容叫做“协议”。
大多数的协议会分成两部分,分别是数据头和消息体。
数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息;
消息体主要是请求的业务参数信息和扩展属性等。
反序列化
根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象。这个过程叫作“反序列化”。
处理过程
服务提供方再根据反序列化出来的请求对象找到对应的实现类,完成真正的方法调用,然后把执行结果序列化后,回写到对应的 TCP 通道里面。调用方获取到应答的数据包后,再反序列化成应答对象,这样调用方就完成了一次 RPC 调用。
简化 API
由服务提供者给出业务接口声明,在调用方的程序里面,RPC 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。
该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。
怎么实现请求跟响应关联
调用端在收到响应消息之后,从响应消息中读取到一个标识,告诉调用端,这是哪条请求消息的响应消息。
私有协议都会有消息 ID,这个消息 ID 的作用就是起到请求跟响应关联的作用。
调用端为每一个消息生成一个唯一的消息 ID,它收到服务端发送回来的响应消息如果是同一消息 ID,那么调用端就可以认为,这条响应消息是之前那条请求消息的响应消息。
通信流程

RPC 在架构中的位置
「RPC 对应的是整个分布式应用系统,就像是“经络”一样的存在。」
RPC 框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远程方法。利用 RPC 我们不仅可以很方便地将应用架构从“单体”演进成“微服务化”,而且还能解决实际开发过程中的效率低下、系统耦合等问题,这样可以使得我们的系统架构整体清晰、健壮,应用可运维度增强。
不仅可以用来解决通信问题,它还被用在了很多其他场景,比如:发 MQ、分布式缓存、数据库等。

在上图这个应用中,使用了 MQ 来处理异步流程、Redis 缓存热点数据、MySQL 持久化数据,还有就是在系统中调用另外一个业务系统的接口,对应用来说这些都是属于 RPC 调用,而 MQ、MySQL 持久化的数据也会存在于一个分布式文件系统中,他们之间的调用也是需要用 RPC 来完成数据交互的。
协议的作用
只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。
但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包。怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。对于服务提供方应用来说,他会从 TCP 通道里面收到很多的二进制数据,服务提供方就需要从TCP通道中识别出来哪些是第一次请求的。就好比读一篇没有标点符号的文章加上标点,完成断句就好了。
同理在 RPC 传输数据的时候,为了能准确地“断句”,也必须在应用发送请求的数据包里面加入“句号”。
这个数据包里面的句号就是消息的边界,用于标示请求数据的结束位置。
举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到 ABCDEF 或者 ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。
这个边界语义的表达,就是所说的协议。
如何设计协议
为什么RPC要设计私有协议
有了现成的 HTTP 协议,为啥不直接用,还要为 RPC 设计私有协议呢
1、相对于 HTTP 的用处,RPC 更多的是负责应用间的通信,所以性能要求相对更高。
2、HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;
3、HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。
「因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。」
定长协议头
RPC 每次发请求发的大小都是不固定的,协议必须能让接收方正确地读出不定长的内容。
可以先固定一个长度(比如 4 个字节)用来保存整个请求数据大小,这样收到数据的时候,先读取固定长度的位置里面的值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议可以设计成这样

这种协议,只实现了正确的断句效果,对于服务提供方来说,不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。因此需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。
在协议头里面,除了会放协议长度、序列化方式,还会放一些像协议标示、消息 ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。
这样一个完整的 RPC 协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的

可扩展的协议
定长协议头如果加参数就会导致线上兼容问题。
假设你设计了一个 88Bit 的协议头,其中协议长度占用 32bit,然后你为了加入新功能,在协议头里面加了 2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照 88bit 读取协议头,新加的 2 个 bit 会当作协议体前 2 个 bit 数据读出来,但原本的协议体最后 2 个 bit 会被丢弃了,这样就会导致协议体的数据是错的。
为了保证能平滑地升级改造前后的协议,有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分还是可以统称为“协议头”

总结
设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可“升级”的协议。不仅要让我们在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。
序列化
什么是序列化与反序列化
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程叫做“序列化”。
这时,服务提供方就可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程称之为“反序列化”。
总结来说,序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程。

为什么需要序列化
网络传输的数据必须是二进制数据,所以在 RPC 调用中,对入参对象与返回值对象进行序列化与反序列化是一个必须的过程。

常用的序列化
JDK 原生序列化
JDK 自带的序列化机制对使用者而言是非常简单的。序列化具体的实现是由 ObjectOutputStream 完成的,而反序列化的具体实现是由 ObjectInputStream 完成的。

import java.io.*;
public class Student implements Serializable {
//学号
private int no;
//姓名
private String name;
public int getNo() {
return no;
}
public void setNo(int no) {
this.no = no;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "Student{" +
"no=" + no +
", name='" + name + '\'' +
'}';
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
String home = System.getProperty("user.home");
String basePath = home + "/Desktop";
FileOutputStream fos = new FileOutputStream(basePath + "student.dat");
Student student = new Student();
student.setNo(100);
student.setName("TEST_STUDENT");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(student);
oos.flush();
oos.close();
FileInputStream fis = new FileInputStream(basePath + "student.dat");
ObjectInputStream ois = new ObjectInputStream(fis);
Student deStudent = (Student) ois.readObject();
ois.close();
System.out.println(deStudent);
}
}
JSON
JSON 是典型的 Key-Value 方式
-
JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销; -
JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。
如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。
Hessian
动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。
官方版本对 Java 里面一些常见对象的类型不支持
-
Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通过扩展 CollectionDeserializer 类修复; -
Locale 类,可以通过扩展 ContextSerializerFactory 类修复; -
Byte/Short 反序列化的时候变成 Integer。
Student student = new Student();
student.setNo(101);
student.setName("HESSIAN");
//把student对象转化为byte数组
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output output = new Hessian2Output(bos);
output.writeObject(student);
output.flushBuffer();
byte[] data = bos.toByteArray();
bos.close();
//把刚才序列化出来的byte数组转化为student对象
ByteArrayInputStream bis = new ByteArrayInputStream(data);
Hessian2Input input = new Hessian2Input(bis);
Student deStudent = (Student) input.readObject();
input.close();
System.out.println(deStudent);
Protobuf
支持 Java、Python、C++、Go 等语言。
Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类,它的优点是:
-
序列化后体积相比 JSON、Hessian 小很多; -
IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器; -
序列化反序列化速度很快,不需要通过反射获取类型; -
消息格式升级和兼容性不错,可以做到向后兼容。 -
不支持 null -
ProtoStuff 不支持单纯的 Map、List 集合对象,需要包在对象里面。
/**
*
* // IDl 文件格式
* synax = "proto3";
* option java_package = "com.test";
* option java_outer_classname = "StudentProtobuf";
*
* message StudentMsg {
* //序号
* int32 no = 1;
* //姓名
* string name = 2;
* }
*
*/
StudentProtobuf.StudentMsg.Builder builder = StudentProtobuf.StudentMsg.newBuilder();
builder.setNo(103);
builder.setName("protobuf");
//把student对象转化为byte数组
StudentProtobuf.StudentMsg msg = builder.build();
byte[] data = msg.toByteArray();
//把刚才序列化出来的byte数组转化为student对象
StudentProtobuf.StudentMsg deStudent = StudentProtobuf.StudentMsg.parseFrom(data);
System.out.println(deStudent);
如何选择序列化
「1、性能和效率」
序列化与反序列化的性能和效率势必将直接关系到 RPC 框架整体的性能和效率
「2、空间开销」
序列化之后的二进制数据的体积大小。序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,由于 RPC 是远程调用,那么网络传输的速度将直接关系到请求响应的耗时。
「3、通用性和兼容性」
比如某个类型为集合类的入参服务调用者不能解析了,服务提供方将入参类加一个属性之后服务调用方不能正常调用,升级了 RPC 版本后发起调用时报序列化异常了…
「4、安全性」
以 JDK 原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵。
与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。

在使用序列化时要注意哪些问题
1、对象构造得过于复杂
属性很多,并且存在多层的嵌套,比如 A 对象关联 B 对象,B 对象又聚合 C 对象,C 对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
2、对象过于庞大
我经常遇到业务过来咨询,为啥他们的 RPC 请求经常超时,排查后发现他们的入参对象非常得大,比如为一个大 List 或者大 Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
3、使用序列化框架不支持的类作为入参类
比如 Hessian 框架,他天然是不支持 LinkedHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList。
4、对象有复杂的继承关系
大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题 1 一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。
网络IO模型
一次 RPC 调用本质就是服务消费者与服务提供者间的一次网络信息交换的过程,所以网络通信是整个 RPC 调用流程的基础。
常见的网络 IO 模型
-
同步阻塞 IO——BIO
「排队等号和上菜我们都是等着的,不做任何事情」
IO 操作分为两个阶段——等待数据和拷贝数据在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态,如果是基于 Java 多线程开发,那么每一个 IO 操作都要占用线程,直至 IO 操作结束。
1、应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。
2、内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中
3、整个 IO 处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑。
-
同步非阻塞——NIO
我们先问一下有没有号,如果没有我们可以先去逛街等会再来问下,直到某次刚好有号,我们就可以在餐馆等上菜了
-
IO多路复用
「指的是多人同时就餐,有一个人专门去不断的问有没有号,一旦有一个或多个号,就可以让对应有号的人去餐馆等上菜了」
「多路」就是指多个通道,也就是多个网络连接的 IO
「复用」就是指多个通道复用在一个复用器上。
多个网络连接的 IO 可以注册到一个复用器(select)上,当用户进程调用了 select,那么整个进程会被阻塞。同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。
当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,也更浪费性能。
但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
-
异步非阻塞——AIO
占到位了,我们可以先去逛街,等菜都上好了回来吃
为什么阻塞 IO 和 IO 多路复用最为常用
在网络 IO 的应用上,需要的是系统内核的支持以及编程语言的支持。
在系统内核的支持上,现在大多数系统内核都会支持阻塞 IO、非阻塞 IO 和 IO 多路复用,但像信号驱动 IO、异步 IO,只有高版本的 Linux 系统内核才会支持。
在编程语言上,无论 C++ 还是 Java,在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO 多路复用的。
当然,在非高并发场景下,同步阻塞 IO 是最为常见的。
RPC 框架在网络通信上倾向选择哪种网络 IO 模型
RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,会选择 IO 多路复用的方式。
开发语言的网络通信框架的选型上,最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架,并且在 Linux 环境下,也要开启 epoll 来提升系统性能
什么是零拷贝
https://player.bilibili.com/player.html?bvid=BV1gq4y1S7Xt
系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。
等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;
拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换,由用户进程切换到系统内核,或由系统内核切换到用户进程,这样很浪费 CPU 和性能。
零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
就是一块物理内存,用户态虚拟地址和内核态虚拟地址都作了页表映射
使用虚拟内存,让用户空间与内核空间都将数据写到一个地方,就不需要拷贝了。
零拷贝有两种解决方式,分别是 mmap
+write
方式和 sendfile
方式,mmap
+write
方式的核心原理就是通过虚拟内存来解决的。

Netty 中的零拷贝
Netty 的零拷贝则不大一样,他完全站在了用户空间上,也就是 JVM 上,它的零拷贝主要是偏向于数据操作的优化上。
RPC对数据包的分割和合并, 在用户空间完成。因为对数据包的处理工作都是由应用程序来处理的,这个过程的拷贝是用户空间内部内存中的拷贝处理操作。
Netty 的零拷贝就是为了解决这个问题,在用户空间对数据操作进行优化。
1、Netty 提供了 CompositeByteBuf
类,它可以将多个 ByteBuf
合并为一个逻辑上的
ByteBuf
,避免了各个 ByteBuf
之间的拷贝。
2、ByteBuf
支持 slice
操作,因此可以将 ByteBuf
分解为多个共享同一个存储区域的 ByteBuf
,避免了内存的拷贝。
3、通过 wrap
操作,可以将 byte[]
数组、ByteBuf
、ByteBuffer
等包装成一个
Netty ByteBuf
对象, 进而避免拷贝操作。
Netty 框架中很多内部的 ChannelHandler
实现类,都是通过 CompositeByteBuf
、slice
、 wrap
操作来处理 TCP 传输中的拆包与粘包问题的。
Netty 的 ByteBuffer
可以采用 Direct Buffers
,使用堆外直接内存进行 Socket
的读写操作,解决用户空间与内核空间之间的数据拷贝问题,和虚拟内存的实现是一样的。
Netty 还提供 FileRegion
中包装 NIO 的 FileChannel.transferTo()
方法实现了零拷贝,这与 Linux 中的 sendfile
方式在原理上也是一样的。
RPC中的动态代理
RPC使用「动态代理」来实现在项目中只需要引入别的服务的依赖,然后调用服务提供方的接口就可以在我们的项目代码中直接调用别的服务的方法。
RPC 会自动给接口生成一个代理类,当注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。

❝如果没有动态代理完成方法调用拦截怎么完成 RPC 调用
❞
gRPC 框架中就没有使用动态代理,它是通过代码生成的方式生成 「Service 存根」,当然这个 Service 存根起到的作用和 RPC 框架中的动态代理是一样的。
gRPC 框架用代码生成的 Service 存根来代替动态代理主要是「为了实现多语言的客户端,因为有些语言是不支持动态代理的,比如 C++、go 等」,但缺点也是显而易见的。如果你使用过 gRPC,你会发现这种代码生成 Service 存根的方式与动态代理相比还是很麻烦的,并不如动态代理的方式使用起来方便、透明。
gRPC
源码地址:https://github.com/grpc/grpc-java
gRPC是谷歌开源的高性能、跨语言的 RPC 框架
通信协议是基于标准的 HTTP/2 设计的,序列化支持 PB(Protocol Buffer)和 JSON

gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。
HTTP/2 相对于常用的 HTTP/1.X 来说,它最大的特点就是多路复用、双向流,好比我们生活中的单行道和双行道,HTTP/1.X 就是单行道,HTTP/2 就是双行道。
❝在 gRPC 调用的时候,把对象转成可传输的二进制,但是在 gRPC 里面,并没有直接转成二进制数组,而是返回一个 InputStream,好处是什么
❞
RPC 调用在底层传输过程中也是需要使用 Stream 的,直接返回一个 InputStream 而不是二进制数组,可以避免数据的拷贝。
RPC架构
协议模块
传输模块
RPC 本质上就是一个远程调用,那肯定就需要通过网络来传输数据。
虽然传输协议可以有多种选择,但考虑到可靠性的话,我们一般默认采用 TCP 协议。
为了屏蔽网络传输的复杂性,我们需要封装一个单独的数据传输模块用来收发二进制数据,这个单独模块我们可以叫做传输模块。
协议封装
用户请求的时候是基于方法调用,方法出入参数都是对象数据,对象是肯定没法直接在网络中传输的,我们需要提前把它转成可传输的二进制,这就是我们说的序列化过程。但只是把方法调用参数的二进制数据传输到服务提供方是不够的,我们需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,在两个“断句”符号中间放的内容就是我们请求的二进制数据,这个过程我们叫做协议封装。
虽然这是两个不同的过程,但其目的都是一样的,都是为了保证数据在网络中可以正确传输,可以把这两个处理过程放在架构中的同一个模块,统称为协议模块。
在协议模块中加入压缩功能,这是因为压缩过程也是对传输的二进制数据进行操作。在实际的网络传输过程中,我们的请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题,我们可以在 RPC 调用的时候这样操作:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,我们可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原。
Bootstrap 模块
让这两个模块同时工作的话这些代码对我们使用 RPC 的研发人员来说是没有意义的,而且属于一个重复的工作,会导致使用过程的体验非常不友好。
需要我们在 RPC 里面把这些细节对研发人员进行屏蔽,让他们感觉不到本地调用和远程调用的区别。假设有用到 Spring 的话,我们希望 RPC 能让我们把一个 RPC 接口定义成一个 Spring Bean,并且这个 Bean 也会统一被 Spring Bean Factory 管理,可以在项目中通过 Spring 依赖注入到方式引用。这是 RPC 调用的入口,我们一般叫做 Bootstrap 模块。
服务发现
RPC需要集群能力集群能力
就是针对同一个接口有着多个服务提供者,但这多个服务提供者对于我们的调用方来说是透明的,所以在 RPC 里面我们还需要给调用方找到所有的服务提供方,并需要在 RPC 里面维护好接口跟服务提供者地址的关系,这样调用方在发起请求的时候才能快速地找到对应的接收地址,这就是服务发现。
服务治理
服务发现只是解决了接口和服务提供方地址映射关系的查找问题。
对于 RPC,TCP 连接状态是瞬息万变的,所以 RPC 框架里面要有连接管理器去维护 TCP 连接的状态。
有了集群之后就需要管理好这些服务,RPC 就需要内置一些服务治理的功能,比如服务提供方权重的设置、调用授权等一些常规治理手段。而服务调用方需要额外做哪些事情呢?每次调用前,我们都需要根据服务提供方设置的规则,从集群中选择可用的连接用于发送请求。

可扩展的架构
在 RPC 框架里面需要支持插件化架构,因为架构是需要不断迭代升级的
可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。在 Java 里面,JDK 有自带的 SPI服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
加上了插件功能之后,我们的 RPC 框架就包含了两大核心体系——核心功能体系与插件体系

这时,整个架构就变成了一个微内核架构,我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。
服务发现
对于服务调用方和服务提供方来说,接口是契约,相当于“通信录”中的姓名。
服务节点就是提供该契约的一个具体实例。
服务 IP 集合作为“通信录”中的地址,从而可以通过接口获取服务 IP 的集合来完成服务的发现。

服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心之中,注册中心将这个服务节点的 IP 和接口保存下来。
服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓存到本地,并用于后续的远程调用。
基于DNS的服务发现
❝为什么使用注册中心而不是用DNS?
❞
回想下服务发现的本质,就是完成了接口跟服务提供者 IP 的映射。那我们能不能把服务提供者 IP 统一换成一个域名啊,利用已经成熟的 DNS 机制来实现?
DNS的流程

DNS存在以下问题
-
如果这个 IP 端口下线了,服务调用者不能及时摘除服务节点 -
如果在之前已经上线了一部分服务节点,这时突然对这个服务进行扩容,那么新上线的服务节点不能及时接收到流量
因为为了提升性能和减少 DNS 服务的压力,DNS 采取了多级缓存机制,一般配置的缓存时间较长,特别是 JVM 的默认缓存是永久有效的,所以说服务调用者不能及时感知到服务节点的变化。
❝是不是可以加一个负载均衡设备?将域名绑定到这台负载均衡设备上,通过 DNS 拿到负载均衡的 IP。这样服务调用的时候,服务调用方就可以直接跟 VIP 建立连接,然后由 VIP 机器完成 TCP 转发
❞

能解决一些问题,但是在 RPC 场景里面也并不是很合适,原因有以下几点:
-
搭建负载均衡设备或 TCP/IP 四层代理,需求额外成本; -
请求流量都经过负载均衡设备,多经过一次网络传输,会额外浪费些性能; -
负载均衡添加节点和摘除节点,一般都要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟; -
在服务治理的时候,需要更灵活的负载均衡策略,目前的负载均衡设备的算法还满足不了灵活的需求。
由此可见,DNS 或者 VIP 方案虽然可以充当服务发现的角色,但在 RPC 场景里面直接用还是很难的。
基于ZK的服务发现
回到服务发现的本质,就是完成接口跟服务提供者 IP 之间的映射。这个映射就是一种命名服务。
此外,注册中心需要实时变更推送,ZooKeeper就能实现
1、搭建一个 ZooKeeper 集群作为注册中心集群
2、服务注册的时候只需要服务节点向 ZooKeeper 节点写入注册信息
3、利用 ZooKeeper 的 Watcher 机制完成服务订阅与服务下发功能

1、服务平台管理端先在 ZooKeeper 中创建一个服务根路径,可以根据接口名命名(例如:/service/com.demo.xxService),在这个路径再创建服务提供方目录与服务调用方目录(例如:provider、consumer),分别用来存储服务提供方的节点信息和服务调用方的节点信息。
2、当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
3、当服务调用方发起订阅时,则在服务调用方目录中创建一个临时节点,节点中存储该服务调用方的信息,同时服务调用方 watch 该服务的服务提供方目录(/service/com.demo.xxService/provider)中所有的服务节点数据。
4、当服务提供方目录下有节点数据发生变更时,ZooKeeper 就会通知给发起订阅的服务调用方。
问题:
ZK 的一大特点就是强一致性,ZK 集群的每个节点的数据每次发生更新操作,都会通知其它 节点同时执行更新。这也就直接导致了 ZK 集群性能上的下降。
这就好比几个人在玩传递东西的游戏,必须这一轮每个人都拿到东西之后,所有的人才能开始下一轮,而不是说我只要获得到东西之后,就可以直接进行下一轮了。
1、ZK 的节点数量特别多,对 ZK 读写特别频繁
2、ZK 存储的目录达到一定数量的时候, 将不再稳定,CPU 持续升高,最终宕机。
3、宕机之后,由于各业务的节点还在持续发送读写请求,刚一启动,ZK 就因无法承受瞬间的读写压力,马上宕机。
「ZK 集群性能服务集群规模扩大了以后,需要重新考虑服务发现方案。」
基于消息总线的最终一致性的注册中心
RPC 框架的服务发现,在服务节点刚上线时,服务调用方是可以容忍在一段时间之后(比如几秒钟之后)发现这个新上线的节点的。
毕竟服务节点刚上线之后的几秒内,甚至更长的一段时间内没有接收到请求流量,对整个服务集群是没有什么影响的,所以可以牺牲掉 CP(强制一致性),而选择 AP(最终一致),来换取整个注册中心集群的性能和稳定性。
因为要求最终一致性,可以考虑采用「消息总线机制」。
1、注册数据可以全量缓存在每个注册中心内存中,通过消息总线来同步数据。
2、当有一个注册中心节点接收到服务节点注册时,会产生一个消息推送给消息总线,再通过消息总线通知给其它注册中心节点更新数据并进行服务下发,从而达到注册中心间数据最终一致性

-
当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本。 -
消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。 -
消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。 -
采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并。
为了性能,这里采用了两级缓存,注册中心和消费者的内存缓存,通过异步推拉模式来确保最终一致性。
❝服务调用方拿到的服务节点不是最新的,所以目标节点存在已经下线或不提供指定接口服务的情况,这个时候有没有问题?
❞
这个问题放到了 RPC 框架里面去处理,在服务调用方发送请求到目标节点后,目标节点会进行合法性验证,如果指定接口服务不存在或正在下线,则会拒绝该请求。服务调用方收到拒绝异常后,会安全重试到其它节点。
通过消息总线的方式,就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。
在 RPC 领域精耕细作后,会发现,服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性。最终一致性才是分布式系统设计中更为常用的策略。
总结
通常我们可以使用 ZooKeeper、etcd 或者分布式缓存(如 Hazelcast)来解决事件通知问题,但当集群达到一定规模之后,依赖的 ZooKeeper 集群、etcd 集群可能就不稳定了,无法满足我们的需求。
在超大规模的服务集群下,注册中心所面临的挑战就是超大批量服务节点同时上下线,注册中心集群接受到大量服务变更请求,集群间各节点间需要同步大量服务节点数据,最终导致如下问题:
-
注册中心负载过高; -
各节点数据不一致; -
服务下发不及时或下发错误的服务节点列表。
RPC 框架依赖的注册中心的服务数据的一致性其实并不需要满足 CP,只要满足 AP 即可。
可以采用“消息总线”的通知机制,来保证注册中心数据的最终一致性,来解决这些问题的。
很多服务发现的知识点,例如服务节点数据的推送采用增量更新的方式,这种方式提高了注册中心“服务下发”的效率,而这种方式还可以利用在其它地方,比如统一配置中心,用此方式可以提升统一配置中心下发配置的效率。
❝想把某些服务提供者实例的流量切走,除了下线实例,有没有想到其它更便捷的办法
❞
1、改变服务提供者实例的权重
2、权重调整为 0
3、通过路由的方式
4、动态分组实现流量隔离,在管理平台动态地自由调整,就可以实现动态地流量切换了
健康监测
调用方跟服务集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏等情况。终极的解决方案是「让调用方实时感知到节点的状态变化」。
健康监测的逻辑
当服务方下线,很多情况下 TCP 连接没有断开,但应用可能已经“僵死了”,所以不一定会收到连接断开的通知事件。
业内常用的检测方法就是用心跳机制。心跳机制说起来也不复杂,其实就是服务调用方每隔一段时间就获取服务提供方的状态。
-
健康状态:建立连接成功,并且心跳探活也一直成功; -
亚健康状态:建立连接成功,但是心跳请求连续失败; -
死亡状态:建立连接失败。
使用「可用率」,增加判断节点状态的维度可以避免心跳机制中误判的问题,又避免每个接口调用频次不同的问题。
可用率的计算公式是,某一个时间窗口内接口调用成功次数的百分比(成功次数 / 总调用次数)。
当可用率低于某个比例就认为这个节点存在问题,把它挪到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。
路由策略
路由策略的作用
每次上线应用就涉及到变更,只要变更就可能导致原本正常运行的程序出现异常,尤其是发生重大变动的时候。
为了减少这种风险,一般会选择灰度发布应用实例,比如先发布少量实例观察是否有异常,后续再根据观察的情况,选择发布更多实例还是回滚已经上线的实例。这种方式不好的一点就是,线上一旦出现问题,影响范围还是挺大的。尤其是像一些基础服务的调用方会更复杂,一旦刚上线的实例有问题了,那将会导致所有的调用方业务都会受损。
路由策略就可以减少上线变更导致的风险。
路由策略就是从服务提供方节点集合里面选择一个合适的节点,在选择节点前加上筛选逻辑,把符合我们要求的节点筛选出来。
IP路由
新上线的节点只允许某个 IP 可以调用,注册中心会把这条规则下发到服务调用方。
在调用方收到规则后,在选择具体要发请求的节点前,会先通过筛选规则过滤节点集合。
按照这个例子的逻辑,最后会过滤出一个节点,这个节点就是新上线的节点。
通过这样的改造,RPC 调用流程就变成了这样:

这个筛选过程就是路由策略,例子里面的路由策略是 IP 路由策略,用于限制可以调用服务提供方的 IP。

参数路由
在有些场景下,还需要比IP路由更细粒度的路由方式。
比如在升级改造应用的时候,为了保证调用方平滑过渡到新应用逻辑,常用的方式是让新老应用并行运行一段时间,然后通过切流量百分比的方式,慢慢增大新应用承接的流量,直到新应用承担了 100% 且运行一段时间后才能去下线老应用。
在流量切换的过程中,为了保证整个流程的完整性,必须保证某个主题对象的所有请求都使用同一种应用来承接。
IP 路由并不能满足这种场景,因为 IP 路由只是限制调用方来源,并不会根据请求参数请求到我们预设的服务提供方节点上去。
参数路由的方案是,给所有的服务提供方节点都打上标签,用来区分新老应用节点。请求的时候可以拿到请求参数,例如商品ID,根据注册中心下发的规则来判断当前商品 ID 的请求是过滤掉新应用还是老应用的节点
相比 IP 路由,参数路由支持的灰度粒度更小,他为服务提供方应用提供了另外一个服务治理的手段。灰度发布功能是 RPC 路由功能的一个典型应用场景,通过 RPC 路由策略的组合使用可以让服务提供方更加灵活地管理、调用自己的流量,进一步降低上线可能导致的风险。

负载均衡
RPC 的负载均衡完全由 RPC 框架自身实现,RPC 的服务调用者会与注册中心下发的所有服务节点建立长连接,在每次发起 RPC 调用时,服务调用者都会通过配置的负载均衡插件,自主选择一个服务节点,发起 RPC 调用请求。

当一个服务节点负载过高或响应过慢时,就少给它发送请求,反之则多给它发送请求。
可以采用一种打分的策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,如服务节点的负载指标、CPU 核数、内存大小、请求处理的耗时指标、服务节点的状态指标(如正常、亚健康)。通过这些指标,计算出一个分数,配合随机权重的负载均衡策略去控制,通过最终的指标分数修改服务节点最终的权重

1、添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
2、运行时状态指标收集器收集服务节点 CPU 核数、CPU 负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取。
3、请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999 等。
4、可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
5、通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。
异常重试
❝为什么需要异常重试?
❞
网络抖动等问题导致请求失败后的重试机制
RPC 框架的重试机制
当调用端发起的请求失败时,RPC 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。

捕获异常后,RPC框架在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。如果单纯是业务异常,就不会触发。
但是重试有可能导致重复请求,所以在使用 RPC 框架的时候,要确保被调用的服务的业务逻辑是幂等的,这样才能考虑根据事件情况开启 RPC 框架的异常重试功能。
当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。这样可以避免连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。
因为调用可能出现重复请求负载过大无法处理请求的节点,所以需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。
如果需要业务场景的重试机制,可以加个重试异常的白名单,用户可以将允许重试的异常加入到这个白名单中。当调用端发起调用,并且配置了异常重试策略,捕获到异常之后,就可以采用这样的异常处理策略。如果这个异常是 RPC 框架允许重试的异常,或者这个异常类型存在于可重试异常的白名单中,就允许对这个请求进行重试。

❝RPC 调用的流程中,异常重试发生在哪个环节
❞
异常重试的操作应该发生在负载均衡之前,在发起重试的时候,会调用负载均衡插件来选择一个服务节点,在调用负载均衡插件时我们要告诉负载均衡需要刨除哪些有问题的服务节点。
在调用端发送请求消息之前还会经过过滤链,对请求消息进行层层的过滤处理,之后才会通过负载均衡选择服务节点,发送请求消息,而异常重试操作就发生在过滤链处理之后,调用负载均衡选择服务节点之前,这样的重试是可以减少很多重复操作的。
关闭流程
在服务重启的时候,对于调用方来说,这时候可能会存在以下几种情况:
-
调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时候调用方可以立马感知到,并且在其健康列表里面会把这个节点挪掉,自然也就不会被负载均衡选中。 -
调用方发请求的时候,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接也没断开,所以这个节点还会存在健康列表里面,因此该节点就有一定概率会被负载均衡选中。
当服务提供方关闭前,可以先通知注册中心进行下线,然后通过注册中心告诉调用方进行节点摘除

可以在关闭的时候,设置一个请求“挡板”,挡板的作用就是告诉调用方,我已经开始进入关闭流程了,我不能再处理你这个请求了。
基于这个思路,可以这么处理:当服务提供方正在关闭,如果这之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方。这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,并没有处理这个请求”,然后调用方收到这个异常响应后,RPC 框架把这个节点从健康列表挪出,并把请求自动重试到其他节点,因为这个请求是没有被服务提供方处理过,所以可以安全地重试到其他节点,这样就可以实现对业务无损。
如果只是靠等待被动调用,就会让这个关闭过程整体有点漫长。因为有的调用方那个时刻没有业务请求,就不能及时地通知调用方了,所以可以加上主动通知流程,这样既可以保证实时性,也可以避免通知失败的情况。
通过捕获操作系统的进程信号来获取关闭事件,在 Java ,对应的是 Runtime.addShutdownHook
方法,可以注册关闭的钩子。在 RPC 启动的时候,我们提前注册关闭钩子,并在里面添加了两个处理程序,一个负责开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时需要在我们调用链里面加上挡板处理器,当新的请求来的时候,会判断关闭标识,如果正在关闭,则抛出特定异常。

开启流程
启动预热
启动预热就是让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样
两种实现方案:
1、服务提供方在启动的时候,把自己启动的时间告诉注册中心
2、注册中心收到的服务提供方的请求注册时间。
调用方通过服务发现,除了可以拿到 IP 列表,还可以拿到对应的启动时间。需要把这个时间作用在负载均衡上

延迟暴露
可能存在服务提供方可能并没有启动完成的情况,如果这时候服务提供方没有启动完成的话,就会导致调用失败,从而使业务受损。
实现:
在服务提供方应用启动后,接口注册到注册中心前,预留一个 Hook 过程,让用户可以实现可扩展的 Hook 逻辑。用户可以在 Hook 里面模拟调用逻辑,从而「使 JVM 指令能够预热起来」,并且用户也可以在 Hook 里面事先预加载一些资源,只有「等所有的资源都加载完成后,最后才把接口注册到注册中心」。

❝当大批量重启服务提供方的时候,会导致请求大概率发到没有重启的机器上,这时服务提供方有可能扛不住
❞
1、在非流量高峰的时候重启服务,将影响降到最低;
2、分批次重启,控制好每批重启的服务节点的数量,当一批服务节点的权重与访问量都到正常水平时,再去重启下一批服务节点。
熔断限流
服务端
服务端的自我保护机制是限流。
1、实现限流时要考虑到应用和 IP 级别,方便我们在服务治理的时候,对部分访问量特别大的应用进行合理的限流;
2、服务端的限流阈值配置都是作用于单机的,而在有些场景下,例如对整个服务设置限流阈值,服务进行扩容时,限流的配置并不方便,可以在注册中心或配置中心下发限流阈值配置的时候,将总服务节点数也下发给服务节点,让 RPC 框架自己去计算限流阈值;
3、让 RPC 框架的限流模块依赖一个专门的限流服务,对服务设置限流阈值进行精准地控制,但是这种方式依赖了限流服务,相比单机的限流方式,在性能和耗时上有劣势。
调用端
调用端的自我保护机制是熔断。
通过熔断机制进行自我保护,防止调用下游服务出现异常,或者耗时过长影响调用端的业务逻辑。
熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的;当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。
RPC 框架可以在动态代理的逻辑中去整合熔断器,实现 RPC 框架的熔断功能。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。

其他保护机制
通过服务治理,降低一个服务节点的权重来减轻某一方服务节点的请求压力,达到保护这个服务节点的目的。
隔离流量
1、通过分组的方式人为地给不同的调用方划分出不同的小集群,从而实现调用方流量隔离的效果,保障核心业务不受非核心业务的干扰。
2、在考虑问题的时候,不能顾此失彼,不能因为新加一个的功能而影响到原有系统的稳定性。
3、不仅可以通过分组把服务提供方划分成不同规模的小集群,我们还可以利用分组完成一个接口多种实现的功能。
❝在我们的实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。不知道面对这种情况,你有什么好办法吗?
❞
可以考虑配置不同的注册中心,开发人员将自己的服务注册到注册中心 A 上,而测试人员可以将自己的服务注册到测试专属的注册中心 B 上,这样测试人员在验证功能的时候,调用端会从注册中心 B 上拉取服务节点,开发人员重启自己的服务是影响不到测试人员的。
异步RPC
影响到 RPC 调用的吞吐量的主要原因就是服务端的业务逻辑比较耗时,并且 CPU 大部分时间都在等待而没有去计算,导致 CPU 利用率不够。
「提升单机吞吐量的最好办法就是使用异步 RPC。」
RPC 框架的异步策略主要是调用端异步与服务端异步。
「调用端的异步就是通过 Future 方式实现异步」,调用端发起一次异步请求并且从请求上下文中拿到一个 Future,之后通过 Future 的 get 方法获取结果,如果业务逻辑中同时调用多个其它的服务,则可以通过 Future 的方式减少业务逻辑的耗时,提升吞吐量。
「服务端异步则需要一种回调方式,让业务逻辑可以异步处理」,之后调用 RPC 框架提供的回调接口,将最终结果异步通知给调用端。
可以通过对 CompletableFuture 的支持,实现 RPC 调用在调用端与服务端之间的完全异步,同时提升两端的单机吞吐量。
RPC 框架也可以有其它的异步策略,比如集成 RxJava,再比如 gRPC 的 StreamObserver 入参对象,但 CompletableFuture 是 Java8 原生提供的,无代码入侵性,并且在使用上更加方便。如果是 Java 开发,让 RPC 框架支持 CompletableFuture 可以说是最佳的异步解决方案。
安全体系
1、建立授权平台
调用方可以在授权平台上申请自己应用里面要调用的接口,而服务提供方则可以在授权平台上进行审批,只有服务提供方审批后调用方才能调用。
调用方每次发起业务请求的时候先去发一条认证请求到授权平台上。

2、使用加密算法
3、把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其它应用也能发布这个接口伪装成提供方造成安全事故。
分布式环境下如何快速定位问题
-
确认清楚问题的现象或本质 -
如果时间允许可以复现下问题,对问题理解更直观 -
依赖全链路追踪系统 查看日志,确认报错的异常信息 -
查看代码确认业务逻辑 -
根据日志和代码业务逻辑基本就可以确认报错的点了
流量回放
流量回放可以先把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里重新请求一遍,最后比对一下改造前后的响应结果是否一致,这就间接达到了使用线上流量测试的效果。
有了线上的请求参数和响应结果后,再结合持续集成过程,就可以让我们改动后的代码随时用线上流量进行验证,这就跟我录制球赛视频一样,只要我想看,我随时都可以拿出来重新看一遍。
常见的方案有很多,比如像 TcpCopy、Nginx 等。

作者介绍
