《RPC实战与核心原理》

开篇

为什么要学习RPC?

RPC是解决分布式系统通信问题的一大利器。 RPC对网络通信的整个过程做了完整包装,在搭建分布式系统时,它会使网络通信逻辑的开发变得更加简单,同时也会让网络通信变得更加安全可靠。

如何学习RPC?

学习是一个通过不断解决问题来提升能力的过程,学习RPC可以采取“逐步深入”的方法: 1. 摆脱现有封装好的框架,了解RPC基本原理以及关键的网络通信过程。 2. 了解RPC框架中的治理功能以及集群管理功能。 3. 对RPC活学活用,学习如何提升RPC性能以及在分布式环境下如何定位解决问题。

01|核心原理:能否画张图解释下RPC的通信流程

这一讲的标题就是一道很好的面试题呀。

我们平时工作中,可以熟练使用各种框架,但是,我们也需要掌握框架背后的基本原理。

什么是RPC?

RPC的全称是Remote Procedure Call,即远程过程调用。它帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地方法一样的体验,我们不需要因为这个方法是远程调用而需要编写很多与业务无关的代码。

RPC框架一般采用TCP作为数据传输协议。

RPC中的网络传输,会涉及到数据的序列化和反序列化。

RPC如何实现调用远程方法像调用本地方法一样的体验?

服务提供者给出业务接口声明,在调用方的程序里面,RPC框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到了声明了该接口的相关业务逻辑里面。该代理实现类会拦截所有方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候,就获得了像调用本地接口一样的体验。

RPC通信基本流程图

image-20240520100006538

RPC在现代应用架构中的位置是怎样的?

RPC是解决应用间通信的一种方式,无论是在一个大型的分布式应用系统中,还是在中小型系统中,应用架构都会沿着单体->微服务化的方向演进,这样,整个应用会被拆分成多个不同功能的应用或者服务,这些细粒度的应用或者服务可能会部署到多台服务器中,它们之间的通信,就会通过RPC进行,所以,我们可以说,RPC对应的是整个分布式应用系统,就像是“经络”一样的存在。

02 | 协议:怎么设计可扩展且向后兼容的协议?

我们为什么需要使用协议?

在网络通信的过程中,为了避免语义不一致的情况,我们需要在发送请求时设定一个边界,然后再收到请求时按照设定的边界对请求数据进行分割。这里的边界语义的表达,就是我们所说的协议。

为什么我们在RPC中不使用HTTP作为主要协议?

RPC负责应用间通信,对性能要求高,但HTTP协议的数据包大小相对请求数据来说,要大很多,包含了很多额外字符,例如换行、回车等。
HTTP协议属于无状态协议,客户端无法对请求和响应进行关联,不能很好地进行异步处理。

我们在设计协议时,一般会把协议分成两部分:

  1. 协议头,由一堆固定长度的参数组成。
  2. 协议体,根据请求接口和参数构造,长度可变。

什么是定长协议?

定长协议是指协议头长度固定。协议头示意图如下。

image-20240520100618073

定长协议有什么缺点?

定长协议的协议头不能添加新参数,否则就会产生兼容性问题。例如我们设计了一个 88Bit 的协议头,其中协议长度占用32bit,然后为了加入新功能,在协议头里面加了2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照88bit 读取协议头,新加的2个bit会当作协议体前2个bit数据读出来,但原本的协议体最后2个bit会被丢弃了,这样就会导致协议体的数据是错的。

如何设计一个可扩展的协议?

我们需要让协议头支持可扩展,扩展后协议头的长度就不能定长了。这样整个协议变成了三部分:固定部分、协议头内容、协议体内容。
可扩展协议的示意图如下。

image-20240520100638612

设计一个简单的RPC协议不难,难的地方在于怎么设计一个可“升级”的协议,不仅要让我们在扩展新特性的时候能够做到向下兼容,而且要尽可能地减少资源损耗。

协议头的具体内容是什么?

  • Bit Offset:标识协议的起始位置。
  • 魔术位:标识是什么协议。
  • 整体长度:标识整个协议有多长,它减去头长度就是协议体长度
  • 头长度:协议头的长度,因为协议头长度不固定,所以需要标识。
  • 协议版本:标识协议的版本,主要用于兼容性控制。
  • 消息类型:标识消息的类型,可能是文本、XML、JSON等。
  • 序列化方式:标识用来做序列化和反序列化的方式。
  • 消息ID:用于标识请求和响应的关系。
  • 协议头扩展字段:用于扩展协议头,这样使得协议具有可扩展性,更加灵活。
  • 协议体:协议的具体内容,二进制格式。

03 | 序列化:对象怎么在网络中传输?

什么是“序列化”和“反序列化”?

网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输,所以我们需要提前把它转变成可传输的二进制,并且要求转换算法是可逆的,这个过程称为“序列化”。
根据请求类型和序列化类型,服务提供方将请求传来的二进制数据还原成请求对象,这个过程称为“反序列化”。

序列化就是将对象转换成二进制数据的过程,而反序列化就是把二进制数据转换成对象的过程。

有哪些常用的序列化方式?

  • JDK原生序列化
  • JSON
  • Hessian
  • Protobuf
  • Protostuff
  • Thrift
  • Avro

任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个对象,来完成反序列化。

JSON进行序列化存在哪些问题?

  1. JSON额外空间开销比较大,对于大数据量服务意味着巨大的内存和磁盘开销。
  2. JSON没有类型,但Java属于强类型语言,这需要通过反射来解决类型问题,所以会影响性能,这样就要求服务提供者和调用者之间传输的数据量要小。

Hessian有什么缺点?

Hessian不支持Java一些常见类型,例如:

  1. Linked系列
  2. Locale类
  3. Byte/Short在反序列化时会变成Integer

如何选择合适的序列化框架?

对于服务提供者来说,服务的可靠性要比性能更重要,因此,我们在选择序列化框架时,更关注协议在版本升级后的兼容性是否很好、是否支持更多的对象类型、是否跨平台、跨语言、是否有很多人已经用过并且踩过坑了,其次,我们才会考虑性能、效率和空间开销。
另外序列化协议的安全也是需要考虑的一个重要因素。
综合考虑,当我们选择序列化协议时,考虑因素如下所示:

image-20240520100802335

RPC框架在使用序列化协议时有哪些注意事项?

  1. 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚。
  2. 入参对象与返回值对象体积不要太大,更不要传太大的集合。
  3. 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类。
  4. 对象不要有复杂的继承关系,最好不要有父子类的情况。

04 | 网络通信:RPC框架在网络通信上更倾向于哪种网络IO模型?

网络通信在RPC调用中有什么作用?

RPC是解决进程间通信的一种方式,一次RPC调用,本质就是服务消费者与服务提供者之间的一次网络信息交换的过程。服务调用者通过网络IO发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次RPC调用便结束了。我们可以说,网络通信是整个RPC调用流程的基础。

有哪些常见的网络IO模型?

常见的网络IO模型分为四种:同步阻塞IO(BIO)、同步非阻塞IO(NIO)、IO多路复用和异步非阻塞IO(AIO)。其中AIO是异步IO,其他三种都是同步IO。
最常用的IO模型是同步阻塞IO和IO多路复用。

阻塞IO的工作流程是怎样的?

应用进程发起IO系统调用后,应用进程被阻塞,转到内核空间处理。内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个IO处理完毕后返回进程。最后应用进程解除阻塞状态,运行业务逻辑。
系统内核在处理IO操作时,主要分为两个阶段:等待数据和拷贝数据。在此期间,应用进程中进行IO操作的线程一直会处于阻塞状态。

什么是IO多路复用?

多路就是指多个通道,也就是多个网络连接的IO,复用指多个通道复用在一个复用器上。 多个网络连接的IO可以注册到一个复用器(select)上,当用户进程调用了select,整个进程会被阻塞,同时,内核会监视所有socket负责的socket,当任何一个socket中的数据准备好了,select就会返回。这时用户进程再调用read操作,将数据从内核中拷贝到用户进程。
IO多路复用的优势在于用户可以在一个线程内同时处理多个socket的IO请求,用户可以注册多个socket,然后不断地调用select读取被激活的socket,这样可以达到在同一个线程内同时处理多个IO请求的目的。

有哪些常见的框架或者工具会使用IO多路复用?

Java中的NIO、Redis、Nginx、Reactor模式等。

在高性能的网络编程框架的编写上,大多数都是基于Reactor模式做的,其中的典型就是Java的Netty框架。

RPC调用在大多数情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及IO模型本身的特点,在RPC框架的实现中,我们选择IO多路复用作为网络通信采用的IO模型。

什么是“零拷贝”?

在没有零拷贝之前,应用进程的每一次写操作,都会把数据写到用户空间的缓冲区内,再由CPU将数据拷贝到系统内核的缓冲区中,之后再由DMA将这份数据拷贝到网卡中,最后由网卡发送出去,这样一次写操作数据需要拷贝两次,而数据读操作也是类似的流程。 应用进程的一次完整的写操作,都需要在用户空间和内核空间中来回拷贝,并且每一次拷贝,都需要CPU进行一次上下文切换。
网络数据读写操作示意图如下。

image-20240520101053670

所谓零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,之后再通过DMA将内核中的数据拷贝到网卡,或者将网卡中的数据拷贝到内核。
零拷贝的示意图如下。

image-20240520101200527

零拷贝有哪些实现方式?

零拷贝有两种方式:

  1. mmmap+write

  2. sendfile

05 | 动态代理:面向接口编程,屏蔽RPC处理流程

在Java中,动态代理是一种设计模式,它允许开发者在运行时创造和使用代理对象。这个代理对象可以用来拦截对其他对象的方法调用,添加额外的处理,然后将调用转发给实际的对象。这种机制在很多场景下都非常有用,例如添加日志、权限检查、事务处理等。

RPC和动态代理有什么关系?

当我们使用RPC时,我们一般会先找到服务提供方要接口,然后将接口依赖配置到项目中,我们在编写业务逻辑时,当需要调用提供方接口时,我们只需要通过依赖注入的方式把接口注入到项目中,然后再代码里面直接调用接口的方法。 但是我们的代码中,接口并没有包含真实的业务逻辑,相关的业务逻辑代码是在服务提供方应用汇总,但是我们只是调用了接口方法,就正常执行了业务逻辑,这是就是动态代理帮助我们实现的。 总的来说,RPC会自动给接口生成一个代理类,当我们在项目中注入接口时,运行过程中实际绑定的是这个接口生成的代理类,这样接口方法被调用的时候,它实际上是被代理类拦截到了,这样我们就可以在代理类中,加入远程调用逻辑。

在Java中,有哪些技术可以实现动态代理?

  • JDK提供的InvocationHandler
  • Javassist
  • Byte Buddy

上述三个工具的区别在于通过什么方式生成代理类以及在生成的代理类中怎么完成方法调用。

动态代理技术选型需要考虑什么因素?

  • 生成代理类的速度、字节码大小。
  • 生成的代理类的执行效率。
  • 是否易用,API设计是否好理解、社区活跃度、依赖复杂度。

如果没有动态代理帮我们完成方法调用拦截,我们应该怎样完成RPC调用?

如果没有动态代理,那么我们需要使用静态代理来实现,需要对原始类中所有的方法都实现一遍,并且为每个方法附加相似的代码逻辑。

06 | RPC实战:剖析gRPC源码,动手实现一个完整的RPC

我们通过动态代理技术,屏蔽RPC调用的细节,从而让使用者能够面向接口编程。

什么是gRPC?

gRPC是由Google开发并且开源的一款高性能、跨语言的RPC框架,当前支持C、Java和Go语言,当前Java版本最新Release版是1.51.3。

什么是protobuf?

protocol buffers是一种语言无关、平台无关、可扩展的序列化结构数据方法,可用于通信协议、数据存储等。 我们可以定义数据结构,然后使用特殊生成的源代码在各种数据流中使用各种语言进行编写和读取数据结构,也可以更新数据结构。

protobuf的三大特点:

  1. 语言无关,平台无关

  2. 灵活、高效

  3. 扩展性、兼容性好

下面我们来看一下如何使用gRPC。

首先我们需要安装protobuf,如果你使用Mac电脑,那么可以运行下面的命令来安装protobuf。

1
brew install protobuf

执行成功后,可以运行下面的命令来查看相关版本信息。

1
2
3
protoc --version

libprotoc 3.21.9

然后我们来创建下面的proto文件hello.proto。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
syntax = "proto3";

option java_generic_services = true;
option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";

// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

我们可以使用下面的命令来自动生成Java代码文件。

1
protoc ./hello.proto --java_out=./

命令执行完成后,会在当前目录下,生成代码文件,对应的目录结构如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
tree .
.
├── hello.proto
└── io
└── grpc
└── examples
└── helloworld
├── Greeter.java
├── Hello.java
├── HelloReply.java
├── HelloReplyOrBuilder.java
├── HelloRequest.java
└── HelloRequestOrBuilder.java

上面就完成了proto文件转换的过程。

接下来,我们看怎么在Java工程中完整的使用gRPC。

我们创建一个空的Maven工程,在pom.xml中引用必要的依赖以及protobuf Maven插件, 完整的pom.xml内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>sample.grpc</groupId>
<artifactId>grpc-sample</artifactId>
<version>0.0.1-SNAPSHOT</version>

<properties>
<grpc.version>1.52.1</grpc.version>
<protobuf.version>3.21.9</protobuf.version>
</properties>


<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.29.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.29.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.29.0</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>annotations-api</artifactId>
<version>6.0.53</version>
<scope>provided</scope>
</dependency>

</dependencies>

<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.6.2</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.11.0:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.29.0:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

然后将上面的hello.proto文件复制到src/main/proto目录下面创建hello.proto目录下, 这个目录是固定的。

pom.xml文件准备好以后,我们来运行下面的工程编译命令。

1
maven compile

编译结束后,会在target/generated-sources目录下维护自动生成的代码,目录结构如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
tree target/generated-sources
target/generated-sources
└── protobuf
├── grpc-java
│ └── io
│ └── grpc
│ └── examples
│ └── helloworld
│ └── GreeterGrpc.java
└── java
└── io
└── grpc
└── examples
└── helloworld
├── Greeter.java
├── Hello.java
├── HelloReply.java
├── HelloReplyOrBuilder.java
├── HelloRequest.java
└── HelloRequestOrBuilder.java

11 directories, 7 files

接下来,我们来编写服务器端,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package io.grpc.examples.helloworld;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;

public class HelloWorldServer {


private Server server;

private void start() throws IOException {
/* The port on which the server should run */
int port = 50051;
server = ServerBuilder.forPort(port)
.addService(new GreeterImpl())
.build()
.start();
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
// Use stderr here since the logger may have been reset by its JVM shutdown hook.
System.err.println("*** shutting down gRPC server since JVM is shutting down");
try {
HelloWorldServer.this.stop();
} catch (InterruptedException e) {
e.printStackTrace(System.err);
}
System.err.println("*** server shut down");
}
});
}

private void stop() throws InterruptedException {
if (server != null) {
server.shutdown().awaitTermination(30, TimeUnit.SECONDS);
}
}

/**
* Await termination on the main thread since the grpc library uses daemon threads.
*/
private void blockUntilShutdown() throws InterruptedException {
if (server != null) {
server.awaitTermination();
}
}

/**
* Main launches the server from the command line.
*/
public static void main(String[] args) throws IOException, InterruptedException {
final HelloWorldServer server = new HelloWorldServer();
server.start();
server.blockUntilShutdown();
}

static class GreeterImpl extends GreeterGrpc.GreeterImplBase {

@Override
public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
System.out.println("=====server=====");
System.out.println("server: Hello " + req.getName());
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
}

编写完成后,直接运行,这样会启动一个Server,端口是50051。

最后,我们来编写客户端,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package io.grpc.examples.helloworld;

import java.util.concurrent.TimeUnit;

import io.grpc.Channel;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;

public class HelloWorldClient {

private final GreeterGrpc.GreeterBlockingStub blockingStub;

/** Construct client for accessing HelloWorld server using the existing channel. */
public HelloWorldClient(Channel channel) {
blockingStub = GreeterGrpc.newBlockingStub(channel);
}

/** Say hello to server. */
public void greet(String name) {
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
response = blockingStub.sayHello(request);
} catch (StatusRuntimeException e) {
return;
}
System.out.println("Greeting: " + response.getMessage());
}

public static void main(String[] args) throws Exception {
String user = "hahahahaha";
// Access a service running on the local machine on port 50051
String target = "localhost:50051";
// Allow passing in the user and target strings as command line arguments
if (args.length > 0) {
if ("--help".equals(args[0])) {
System.err.println("Usage: [name [target]]");
System.err.println("");
System.err.println(" name The name you wish to be greeted by. Defaults to " + user);
System.err.println(" target The server to connect to. Defaults to " + target);
System.exit(1);
}
user = args[0];
}
if (args.length > 1) {
target = args[1];
}

ManagedChannel channel = ManagedChannelBuilder.forTarget(target)
.usePlaintext()
.build();
try {
HelloWorldClient client = new HelloWorldClient(channel);
client.greet(user);
} finally {
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
}
}
}

当我们多次运行客户端程序后,我们可以在服务器端的控制台上,看到如下输出。

1
2
3
4
5
6
=====server=====
server: Hello hahahahah
=====server=====
server: Hello hahahahah
=====server=====
server: Hello hahahahah

07 | 架构设计:设计一个灵活的RPC框架

RPC就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证在服务提供方能正确地还原出语义,最终实现像调用本地一样地调用远程的目的。

RPC的本质是一个远程调用,需要通过网络来传输数据,我们一般采用TCP协议作为数据通信协议,为了屏蔽网络传输的复杂性,我们需要封装一个独立的数据传输模块来收发二进制数据,这个模块被称为传输模块

我们在调用方法时,方法的出入参数都是对象数据,我们需要将对象转换成二进制,即进行序列化操作,同时,我们还需要在方法调用参数的二进制数据后面增加“断句”符号来分隔出不同的请求,这个过程被称为协议封装

传输模块和协议封装都是为了保证数据在网络中可以正确传输,我们将这两个模块放在一起,称为协议模块

我们还可以在协议模块中添加压缩功能,在方法调用参数或者返回值的二进制数据大于某个阈值时,我们使用压缩框架对数据进行无损压缩,然后再另外一段使用同样的压缩机制进行解压处理,保证数据可以还原。

传输模块和协议模块是RPC中最基础的功能,它们使得对象可以正确的传输到服务提供方。但是为了让这两个模块可以同时工作,我们还需要手写一些粘合代码,这些代码对于RPC框架的使用者来说是没有意义的,属于重复工作,我们将这些事情进行封装处理,形成RPC调用的入口,一般称为Bootstrap模块

什么是”服务发现“?

针对同一个接口有多个服务提供者,但这多个服务提供者对于我们的调用方来说是透明的,所以在RPC里面我们还需要给调用方找到所有的服务提供方,并需要在RPC里面维护好借口和服务提供者地址间的关系,这样调用方在发起请求时才能快速找到对应的接收地址。

一个划分为四层的RPC基础架构的示意图如下。

上面的框架有什么问题吗?最主要的问题就是扩展性不高,当我们有需求变更时,很容易需要大范围调整框架。

我们可以尝试使用插件化架构的方法论来优化我们的RPC架构。

我们怎么在RPC框架里面支持插件化架构?

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

加入插件功能后,RPC框架就包含了两大核心体系:核心功能体系插件体系,示意图如下。

image-20240520101947432

08 | 服务发现:到底是要CP还是AP?

我们为什么需要“服务发现”?

从高可用的角度出发,在生产环境中,服务提供方通常会以集群的方式对外提供服务,集群中的IP地址随时可能发生变化,因此我们需要一本“通讯录”来及时获取对应的服务节点信息,维护“通讯录”以及或者节点信息的过程,我们称之为“服务发现”。

服务发现包括2个核心模块:

  • 服务注册:在服务提供方启动的时候,将对外暴露的接口注册到注册中心中,注册中心将这个服务节点的IP和接口保存下来。
  • 服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的IP,然后缓存到本地,并用于后续远程调用。

我们为什么不采用基于DNS的服务发现机制?

我们来看一下DNS查询流程。

image-20240520103411280

它存在的两个主要问题:

  1. 如果服务节点的IP端口下线了,服务调用者能否及时摘除服务节点?
  2. 如果之前已经上线了一部分服务节点,这时突然对这个服务进行扩容,那么新上线的服务节点能否及时接收到流程呢?

为什么VIP方案也不能用于服务发现? VIP方案如下所示。

image-20240520103437431

它主要有以下几个问题:

  • 搭建负载均衡设备或者TCP/IP四层代理,需要额外成本。
  • 请求流程都经过负载均衡设备,多经过一次网络传输,会额外浪费性能。
  • 负载均衡添加节点和摘除节点,一般都需要手动添加,当大批量扩容和下线时,会有大量的人工操作和生效延迟。
  • 不能支持更灵活的负载均衡策略。

基于ZooKeeper的服务发现机制的工作流程是怎样的? 基于ZooKeeper的服务发现结构图如下。

image-20240520103453805

它的工作流程如下:

  1. 服务平台管理端现在ZooKeeper中创建一个服务根路径,在这个路径下面再创建服务提供方目录和服务调用方目录。
  2. 当服务提供方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
  3. 当服务调用方发起注册时,会在服务提供方目录中创建一个临时节点,节点中存储该服务提供方的注册信息。
  4. 当服务提供方目录下有节点数据发生变更时,ZooKeeper就会通知给发起订阅的服务调用方。

基于ZooKeeper的服务发现有什么问题?

当有超大批量的服务节点在同时发起注册操作,ZooKeeper集群的CPU使用率会飙升,导致ZooKeeper集群无法工作。

这本身就是ZooKeeper的性能问题,当连接到ZooKeeper的节点数量特别多,对ZooKeeper的读写操作会特别频繁,而且当ZooKeeper存储的目录达到一定数量时,ZooKeeper就会变得不稳定,CPU使用率持续升高,直到宕机。

ZooKeeper的一大特点就是强一致性,集群中的每个节点的数据每次发生变更操作时,都会通知其他节点同时执行跟新,这样它就要求每个节点的数据能够实时的完全一致,从而导致了ZooKeeper集群性能的下降。

基于消息总线的服务发现机制的工作流程是怎样的?

基于消息总线的服务发现流程图如下:

image-20240520103501069

它的工作流程如下:

  1. 当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有一个整体递增版本。
  2. 消息总线会主动推送消息到各个注册中心,同时注册中心也会定期拉取消息。对于获取到消息的在消息回放模块里面回放只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。
  3. 消费者定于可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存中。
  4. 采取推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的混存数据进行合并。

通过消息总线的方式,我们就可以完成注册中心集群间数据变更的通知,保证数据的最终一致性,并能及时地触发注册中心的服务下发操作。

服务发现的特性是允许我们在设计超大规模集群服务发现系统的时候,舍弃一致性,更多的考虑系统的健壮性,因此,在实际工作中,最终一致性是更为常用的策略。

09 | 健康监测:这个节点挂了,为啥还要疯狂发请求?

服务调用方在每次调用服务提供方的服务时,RPC框架会根据路由和负载均衡算法选择一个具体的IP地址,为了保证请求成功,我们需要确保每次选择出来的IP对应的连接是健康的。

调用方和集群节点之间的网络状况是瞬息万变的,两者之间可能会出现闪断或者网络设备损坏的情况,为了解决这个问题,我们的终极解决方案就是让调用方实时感知到节点的变化。

业内经常用来检测服务节点是否可用的方法是用心跳机制。心跳机制就是服务调用方每隔一段时间就问一下服务提供方,“兄弟,你还好吗?”,然后服务提供方诚实地告诉调用方它目前的状态。

服务提供方的状态一般会有三种情况:

  1. 健康状态:建立连接成功,并且心跳探活也一直成功。
  2. 亚健康状态:建立连接成功,但是心跳请求连续失败。
  3. 死亡状态:建立连接失败。

上述三种状态是可以变转变的。

一个节点从健康状态过渡到亚健康状态的前提是连续“心跳失次数必须达到某个阈值。

只关心服务节点网络稳定,会有2个问题:

  1. 调用方每个接口的调用频次不一样,有的接口可能1秒内调用上百次,有的接口可能半个小时才会被调用一次,所以我们不能简单的把失败总次数当做判断条件。
  2. 服务的借口响应时间也不一样,有的接口可能1ms,有的可能是10s,我们不能使用TPS来作为谈判条件。

我们可以使用”可用率“,它的计算方式是某一个时间窗口内接口调用的成功次数的百分比。当可用率低于某个比例就认为这个节点存在问题,需要把它转移到亚健康列表,这样既考虑了高低频的调用接口,也兼顾了接口响应时间不同的问题。

我们在部署时,需要注意将检测程序部署到多个机器里面,分布在不同的机架,甚至不同的机房。

10 | 路由策略:怎么让请求按照设计的规则发到不同的节点上?

我们在真实的环境中,服务提供方是以集群的方式对外提供服务,这对于服务调用方来说,就是一个借口会有多个服务提供方同时提供服务,所以RCP在每次发起请求的时候,都需要从多个服务提供方节点里面选择一个用于发送请求。

当服务在线上运行时,我们如果有变更,会涉及到如何升级,升级过程中可能会影响服务接口,这样会导致系统变的不稳定,甚至系统崩溃,为了减少这种风向,我们一般会选择灰度发布的方式来升级我们的服务实例,例如我们可以发布少量实例来观察是否有异常,然后根据观察的结果,来决定是发布更多实例还是回滚到旧版本。

虽然我们的服务在上线前会有测试的过程,但是因为线上环境太复杂了,测试只能是降低出现风险的概率,想要彻底验证所有场景是不可能的。

我们可以考虑在上线完成后,先让一小部分调用方请求过来进行逻辑验证,待没有问题后再接入其他调用方,从而实现流量隔离的效果。

因为注册中心会维护所有服务提供方的信息,所以我们可以在注册中心中来做流量隔离吗?一般不采用这种方式,注册中心在RPC中的定位是用来存储数据并保证数据一致性,如果把复杂的请求隔离的计算逻辑放到注册中心里面,那么当集群节点变多时,会导致注册中心压力过大,而且大部分时候,我们采用开源软件来打架注册中心,要加入其他计算逻辑的话,还需要进行二次开发,所以从实际的角度出发,在注册中心中实现请求隔离是不划算的。

调用法发起RPC调用的整个流程中,在RPC发起真实请求的时候,有一个步骤就是从服务提供方节点集合里面选择一个合适的节点(也就是负载均衡),那么我们可以考虑在这个节点之前,加入一个“筛选逻辑”,把符合我们要求的节点筛选出来。

这个“筛选过程”,我们称之为“路由策略”。

我们可以设计不同得分路由策略:

  1. IP路由*,根据服务调用方的IP地址,来决定哪些服务提供方的实例可以处理相关请求。*
  2. 参数路由,根据服务调用方发送请求中的参数值,来决定哪些服务提供方的实例可以处理相关请求。

参数路由是比IP路由更灵活、粒度更细的路由规则,它为服务提供方应用提供了另外一个服务治理的手段。

灰度发布功能是RPC路由功能的一个典型应用场景,通过RPC的路由策略的组合使用可以让服务提供方更加灵活的管理、调用自己的流量,进一步降低可能导致的风险。

在RPC里面,不论是哪种路由策略,其核心思想是一样的,就是让请求按照我们设定的规则发送到目标节点上,从而实现流量隔离的效果。

11 | 负载均衡:节点负载差距这么大,为什么收到的流量还一样?

什么是负载均衡?

当我们的一个服务节点无法支撑现有的访问量时,我们会部署多个节点,组成一个集群,然后通过负载均衡,将请求分发给这个集群下的每个服务节点,从而达到多个服务节点共通分担请求压力的目的。

负载均衡有哪些类型?

负载均衡分为软负载和硬负载两种,软负载就是在一台或多台服务器上安装负载均衡软件,如LVS、Nginx等;硬负载就是通过硬件设备来实现负载均衡,例如F5服务器等。

有哪些常见的负载均衡算法? 常见的负载均衡算法包括:

  • 基于权重的随机算法
  • 基于最小活跃用数算法
  • 基于Hash一致性算法
  • 基于加权轮询算法

Dubbo默认采用基于权重的随机算法。

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

示意图如下。

image-20240520104220344

RPC的负载均衡策略一般包括随机权重、Hash、轮询等。

如何设计一个自适应的负载均衡?

所谓自适应的负载均衡,就是指负载均衡组件可以根据服务节点的可处理能力,动态调整服务节点的权重,将请求转发给合适的服务节点,从而保证整个系统的稳定性。

我们可以采用一种打分策略,服务调用者收集与之建立长连接的每个服务节点的指标数据,例如服务节点的负载指标、CPU核数、内存大小、请求处理的平均耗时、服务节点的健康状态等。我们可以为这些指标设置不同的权重,之后就可以计算每个服务节点动态分值。

在得到服务节点的动态分值后,我们把分值作为服务节点的权重,采用随机权重的负载均衡策略去分发请求,这样我们就可以完成一个自适应的负载均衡。

关键步骤如下:

  1. 添加服务指标收集器,并将其作为插件,默认有运行时状态指标收集器、请求耗时指标收集器。
  2. 运行时状态指标收集器收集服务节点CPU核数、CPU负载以及内存等指标,在服务调用者与服务提供者的心跳数据中获取。
  3. 请求耗时指标收集器收集请求耗时数据,如平均耗时、TP99、TP999等。
  4. 可以配置开启哪些指标收集器,并设置这些参考指标的指标权重,再根据指标数据和指标权重来综合打分。
  5. 通过服务节点的综合打分与节点的权重,最终计算出节点的最终权重,之后服务调用者会根据随机权重的策略,来选择服务节点。

12 | 异常重试:在约定的时间内安全可靠地重试

什么是RPC框架的重试机制?

当调用端发起的请求失败时,RPC框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数。 调用端发起RPC请求时,会经过负载均衡,选择一个节点,之后它会向这个节点发送请求信息。当消息发送失败或收到异常消息时,我们就可以捕获异常,根据异常触发重试,重新通过负载均衡选择一个节点发送请求信息,并且记录请求的重试次数,当重试次数达到用户配置的重试次数时,就返回给调用端动态代理一个失败异常。

如何在约定的时间内安全可靠的重试?

首先,服务的业务逻辑需要是幂等的,这是我们可以重试的前提。

其次,在每次重试后,都需要重置一下请求的超时时间,因为连续的异常重试可能会导致请求处理时间过长造成超时。

再次,当我们发起服务重试时,负载均衡选择节点时,需要去掉重试之前出现过问题的节点,这样可以提高重试的成功率。

最后,我们可以在RPC框架中配置业务异常相关的白名单,这样当白名单中的业务异常类型被触发时,也可以进行服务重试。

13 | 优雅关闭:如何避免服务停机带来的业务损失?

我们在RPC架构下,需要考虑当服务重启时,如何做到让调用方系统不出问题。

当服务提供方要上线时,一般是通过部署系统完成实例重启,在这个过程汇总,服务提供方不会事先告诉调用方哪些实例会被重启,从而让调用方切换流量。而对调用方来说,它也无法预测服务提供方哪些实例会重启,因此负载均衡还是有可能降正在重启的实例挑选出来,这样导致请求被分发到正在重启的服务实例中,造成调用方无法拿到正确的响应结果。

在服务重启的时候,对于调用方来说,有以下2种情况:

  1. 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时调用可以立刻感知到,并在其健康列表中将该实例删除,这样就不会被负载均衡选中。
  2. 调用方发请求时,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接没有断开,所以这个节点还会存在健康列表里面,有可能会被负载均衡选中。

我们可以通过服务发现来实时通知服务调用方关于服务提供方是否可用吗?

不可以。这样做的话,整个过程会依赖两次RPC调用:一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,服务发现只保证最终一致性,并不保证实时性,所以当注册中心收到服务提供方下线的时候,并不能保证把这次要下线的节点推送给所有调用方,这样,调用方还是有可能将请求发送给错误的服务提供方节点。

如何做到优雅关闭服务?

我们可以尝试让服务提供方来通知调用方,RPC里面调用方和提供方之间是长连接,我们可以在提供方应用内存中维护一份调用方连接集合,当服务关闭时,挨个通知调用方去下线相关实例,这样整个调用链路就变短了,对于每个调用方来说只一次RPC,可以确保调用的成功率很高。

但是上述方法不能彻底解决问题,因为有时出问题请求的时间点和收到提供方关闭通知的时间点很接近,再加上网络延迟,还是有可能在服务提供方关闭服务后再接收到新的请求。

解决办法是我们在关闭的时候,在服务提供方设置一个请求“挡板”,它的作用是告诉调用方,我已经进入关闭流程,不能再处理新的请求了。

当服务提供方正在关闭,如果在之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(ShutdownException),这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,没有处理这个请求”,然后调用方收到这个异常响应后,RPC框架就把这个节点从健康列表中挪出,并把请求自动重试到其他节点,因为这个请求没有被服务提供方处理过,所以可以安全的重试到其他节点,这样可以实现对业务无损。

我们还可以加上主动通知流程,让服务提供方给相关调用方发送关闭通知,这样既可以保证实时性,也可以避免通知失败的情况。

在Java语言中,我们可以使用Runtime.addShudownHook方法,来注册关闭的钩子,在RPC启动的时候,我们提前去注册关闭钩子,并在里面添加连个处理程序:一个复杂开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时我们需要再调用链里面加上挡板处理器,当新的请求进来时,会判断关闭标识,如果正在关闭,就抛出特定异常。

对于关闭过程中还在处理的请求,我们可以根据引用计数器,等待正在处理的请求全部结束后再真正关闭服务,同时还可以设置一个超时控制,当超过指定时间,请求还没有处理完,就强制退出应用。

总结一下,关于如何优雅关闭服务,包括以下步骤:

  1. 开启关闭挡板,拒绝新的请求
  2. 利用引用计数器确保正在执行的请求处理完
  3. 设置超时时间,保证服务可以正常关闭
  4. 执行关闭时,服务提供方通知服务调用方下线相关节点

服务优雅关闭的示意图如下。

image-20240520105158413

“优雅关闭”的概念除了在RPC里面,在其他很多框架中也很常见,例如Tomcat在关闭的时候,也是先从外层到里层逐层进行关闭,先保证不接收新的请求,然后再处理关闭前收到的请求。

14 | 优雅启动:如何避免流量达到没有启动完成的节点?

为什么Java程序运行一段时间会执行速度会变快?

这是因为在Java里面,在运行过程中,JVM虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到JVM缓存中,再次使用的时候就不会触发临时加载,这样就使得 “热点”代码的执行不用每次都通过解释,从而提升执行速度。

什么是启动预热?

启动预热就是让刚启动的服务提供方应用不承担全部的流量,而是让它被调用的次数随着时间的移动慢慢增加,最终让流量缓和地增加到跟已经运行一段时间后的水平一样。

服务调用方应用通过服务发现能够取得服务提供方的IP地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接,我们可以让负载均衡在选择连接的时候,区分一下是不是刚启动的应用,如果是刚启动的应用,我们可以调低它的权重值,这样它被选中的概率会很低,随着时间推移,我们逐渐增大它的权重值,从而实现一个动态增加流量的效果。

我们如何获取服务提供方应用的启动时间?有两种方法:

  1. 服务提供方在启动的时候,把自己启动的时间告诉注册中心。
  2. 注册中心收到的服务提供方请求注册的时间。

启动越热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常的水平,从而实现平滑上线。

从服务提供方的角度来说,有什么优化方案吗?服务提供方可以使用延迟暴露的方法来优化热启动过程。

问题:服务提供方应用在没有完成启动的时候,调用方的请求就过来了,而调用方请求过来的原因,在于服务提供方应用启动过程中把解析到的RPC服务注册到了注册中心,这就导致了后续加载没有完成的情况下,服务提供方地址就被服务调用方感知到了。

解决办法:我们在应用启动加载、解析Bean的时候,如果遇到了RPC服务的Bean,只先把这个Bean注册到Spring-BeanFactory里面,而不把这个Bean对应的接口注册到注册中心,只有等应用启动完成后,才被接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址。

我们还可以利用服务启动完成到注册到注册中心的那段时间,预留一个Hook,让用户可以扩展Hook逻辑,在Hook里面模拟业务调用逻辑,从而使得JVM指令能够预热起来,同时还可以在Hook中预先加载一些资源,只有等所有缓存和资源都加载完成后,才把接口注册到注册中心,这样也就完成了热启动整个流程。

如果我们有大批量的服务都需要重启,如何避免同时重启造成请求被分发到新启动的应用实例而造成超时错误?

我们可以采取一些措施:

  1. 分时分批启动,就像灰度发布一样。
  2. 根据重启比例来设置重启服务的权重。
  3. 在请求低峰重启应用。
  4. 在重启过程中,如有必要,对服务进行限流处理。

启动预热通常是指在应用启动时预先加载一些数据或执行一些操作,以提高后续使用时的性能。在很多系统或应用中,启动预热是一个常见的优化手段。

具体来说,启动预热可能包括以下一些操作:

  1. 预加载数据:应用在启动时从数据库或其他数据源加载一些常用数据到内存中。这样,当这些数据被使用时,应用可以直接从内存中读取,而不需要进行磁盘I/O或网络请求,提高了性能。
  2. 预建立连接:例如,数据库连接、网络连接等。这样,当需要使用这些连接时,就不需要等待连接建立的时间。
  3. 预编译代码:例如,一些解释型语言的虚拟机会在启动时预先编译一些经常使用的代码,以提高运行速度。
  4. 预热缓存:例如,将一些经常访问的数据预先加载到缓存中,以提高后续访问的速度。

需要注意的是,启动预热虽然可以提高后续使用的性能,但是也会使得应用的启动时间变长。因此,需要在启动时间和运行性能之间做好平衡。

15 | 熔断限流:业务如何实现自我保护?

为什么我们的服务需要自我保护?

RPC是解决分布式系统通信问题的一大利器,它会面临高并发的场景,这意味着我们提供服务的每个服务节点都有可能由于访问量过大而引起一系列的问题,例如业务处理耗时过长、CPU利用率过高、频繁Full GC以及服务进程直接宕机等,在生产环境中,我们要保证服务的稳定性和高可用性,这就需要业务进行自我保护,从而在高访问量、高并发的场景下,应用系统依然稳定,服务依然高可用。

使用RPC时,业务如何实现自我保护?

可以在服务提供方做限流操作,在服务调用方做熔断操作。

熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为,而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。

熔断和限流有什么区别?

熔断主要在服务调用方进行设置,限流主要在服务提供方进行设置。

服务端如何实现限流逻辑?

方式有很多,包括计数器、滑动窗口、漏斗算法和令牌桶算法等,其中令牌桶算法最常用。

我们发布服务后,提供给多个应用的调用方去调用,这时有一个应用的调用方发送过来的请求流量要比其他的应用大很多,这时我们就应该对这个应用下的调用端发送过来的请求流量进行限流。所以我们在限流时,需要考虑应用级别的维度,甚至是IP级别的维度,这样做不仅可以让我们对一个应用下的调用端发送过来的请求流量做限流,还可以对一个IP发送过来的请求流量做限流。

在服务端实现限流,配置的限流阈值是作用在每个服务节点上的。

我们还可以提供一个专门的限流服务,让每个节点都依赖一个限流服务,当请求流量打过来时,服务节点触发限流逻辑,调用这个限流服务来判断是否到达了限流阈值。我们甚至可以将限流逻辑放在调用端,调用端在发出请求时先触发限流逻辑,调用限流服务,如果请求量已经到达了限流阈值,请求都不需要发出去,直接返回给动态代理一个限流异常即可。

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

熔断器的工作原理是怎样的?

熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。在正常情况下,熔断器是关闭的,当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑,当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。

熔断器放在哪里比较合适?

建议放在调用方的动态代理模块,因为这时RPC调用的第一个关口,在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。

16 | 业务分组:如何隔离流量?

关于为什么要对请求流量进行分组,作者举了一个非常合适的例子:

在没有汽车的年代,我们的道路很简单,就一条,行人、洋车都在上边走。随着汽车的普及以及猛增,我们的道路越来越宽,慢慢地有了高速、辅路、人行道等。很显然,交通网的建设和完善不仅提高了我们的出行效率,而且还更好的保障了我们行人的安全。

对服务进行分组,并没有一个明确的可衡量的标准,但是一般建议非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。

通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其他调用方的可用率。

服务分组隔离后,单个调用方在发RPC请求时可以选择的服务节点数相比之前是减少了,那么对于单个调用方来说,出错的概率就增加了。

要解决这个问题,我们还需要把配置的分组区分主次分组,只有在主分组上的节点都不可用的情况下才去选择次分组节点,只要主分组里面的节点恢复正常,我们就必须把流量都切换到主节点上。整个切换过程对于应用层完全透明,从而在一定程度上保证了服务调用方应用高可用。

我们不仅可以通过分组把服务提供方划分成不同规模的小集群,我们还可以利用分组完成一个接口多种实现的功能。正常情况下,为了方便我们自己管理服务,一般会建议每个接口完成的功能尽量保证唯一。但在有些特殊场景下,两个接口也会完全一样,只是具体实现上有所差别,那么我们就可以在服务提供方应用里面同时暴露两个相同接口,但是接口分组不一样。

在实际工作中,测试人员和开发人员的工作一般都是并行的,这就导致一个问题经常出现:开发人员在开发过程中可能需要启动自身的应用,而测试人员为了能验证功能,会在测试环境中部署同样的应用。如果开发人员和测试人员用的接口分组名刚好一样,在这种情况下,就可能会干扰其它正在联调的调用方进行功能验证,进而影响整体的工作效率。有什么解决办法?

解决这个问题,有几种思路:

  1. 不同团队使用不同的服务注册中心来管理服务节点。
  2. 可以采取类似于K8S中的命名空间的方法来隔离服务节点。
  3. 可以尝试流量染色,具体可以参考有关于流量染色的一些实践

17 | 异步RPC:压榨单机吞吐量

为什么异步可以提升吞吐量

吞吐量是一个衡量系统处理能力的重要指标,通常用于描述在单位时间内,一个系统能处理的任务数量或数据量。

异步编程是一种编程模式,它可以提高系统的吞吐量和效率。在同步编程模式中,操作是按照顺序一个接一个执行的,每个操作必须完成后,才能开始下一个操作。如果一个操作需要等待外部资源(如网络请求、文件I/O等),则整个系统需要等待这个操作完成,这样就浪费了宝贵的计算资源。

相比之下,异步编程允许操作在等待外部资源时,释放计算资源给其他的操作使用。例如,当发起一个网络请求后,系统不需要等待网络请求的回应,而可以立即开始执行其他的操作。当网络请求回应到来时,系统再回来处理这个回应。这样就使得计算资源得到了充分利用,从而提高了系统的吞吐量。

此外,异步编程还可以提高系统的响应性。在同步系统中,如果一个操作需要花费很长时间,那么用户可能需要等待这个操作完成后,才能看到响应。但是在异步系统中,这个长时间的操作可以在后台进行,而系统可以立即给用户一个响应,告诉用户操作正在进行。这样就提高了系统的响应性,提升了用户体验。

所以,异步编程可以提高系统的吞吐量和响应性,但是它也带来了更复杂的编程模型,需要更多的处理错误和状态管理的代码。因此,是否使用异步编程,应根据具体的应用场景和需求来决定。


在我们知道RPC框架基础知识后,我们需要从RPC框架整体性能去考虑问题,例如怎么提升RPC框架的性能、稳定性、安全性、吞吐量,以及如何在分布式的场景下快速定位问题等。

影响RPC调用吞吐量的根本原因是什么?

处理RPC请求比较耗时,并且CPU大部分时间都在等待而非去计算,从而导致CPU利用率不高。RPC请求的耗时大部分是业务耗时,比如业务逻辑中有访问数据库执行慢SQL的操作,所以我们要看怎么能提升业务逻辑处理。

要提升吞吐量,关键就两个字:异步

服务调用端怎么异步? 对于调用端来说,向服务端发送请求消息与接受服务端发送过来的响应消息,这两个处理过程是两个完全独立的过程,这两个过程甚至在大多数情况下都不在一个线程中进行,也就是说,对于RPC框架,无论是同步调用还是异步调用,调用端的内部实现都是异步的。

调用端发送的每条信息都有一个唯一的消息标识,实际上调用端想服务端发送请求消息之前会创建一个Future,并会存储这个消息标识与这个Future的映射,动态代理所获得的返回值最终就是从这个Future中获取的,当收到服务端响应的消息时,调用端会根据响应消息的唯一标识,通过之前存储的映射找到对应的Future,将结果注入给那个Future,再进行一系列的处理逻辑,最后动态代理从Future中得到正确的返回值。

所谓同步调用,是指RPC框架在调用端的处理逻辑中主动执行了Future.get()方法,让动态代理等待返回值,而异步调用则是RPC框架没有主动执行这个方法,用户可以从请求上下文中得到这个Future,自己决定什么时候执行Future.get()方法。

Future模式的示意图如下。

image-20240520111315955

RPC调用在调用方和提供方之间完全异步。

CompletableFuture是Java 8原生支持的。如果RPC框架能够支持CompletableFuture,那发布一个RPC服务,服务接口定义的返回值是CompletableFuture对象,整个调用过程会分为以下几步:

  1. 服务调用方发起RPC请求,直接拿到返回值CompletableFuture对象,之后就不需要任何额外的与RPC框架相关的操作了,直接就可以进行异步处理。
  2. 在服务提供方业务逻辑中创建一个返回值CompletableFuture对象,之后服务端真正的业务逻辑完全可以在一个线程池中异步处理,业务逻辑完成之后再调用这个CompletableFuture对象的complete方法,完成异步通知。
  3. 服务调用方在收到服务提供方发送过来的响应之后,RPC框架再自动地调用服务调用方拿到的那个 返回值CompletableFuture对象的complete方法,这样一次异步调用就完成了。

RPC远程方法调用,有以下几种方式:

  1. sync,默认方式,这是在“方法”内部同步,但RPC框架还是异步处理的。
  2. future,RPC消费者得到future,自行决定何时获取返回结果。
  3. callback,RPC调用端不需要同步处理响应结果,可以直接返回,最后返回结果将会在回调线程中异步处理。
  4. oneway,调用端发起请求后不需要接收响应。

18 | 安全体系:如何建立可靠的安全体系?

RPC一般用于解决内部应用之间的通信,这里的“内部”是指应用都部署在同一个大局域网中,这样在RPC中,我们很少考虑像数据包篡改、请求伪造等恶意行为。

我们主要关注两种类型的安全场景:

  1. 调用方之间的安全保证。
  2. 服务发现中的安全保证。

我们可以引入一个授权平台,来对调用方的身份进行验证,调用方可以在授权平台上申请自己应用里面需要调用的接口,而服务方可以在授权平台上进行审批,只有服务提供方审批后,调用方才能够调用。

上面的设计方案,授权平台承担了公司内所有RPC请求的次数总和,当RPC请求量达到一定水平,授权平台会成为一个瓶颈点。

为了解决这个问题,我们可以将授权平台所做的事情转移到服务提供方。我们使用一种不可逆加密算法,例如HMAC算法。服务提供方应用里面放一个用于HMAC签名的私钥,在授权平台上用这个私钥为申请调用的调用方应用进行签名,这个签名生成的串就变成了调用方唯一的身份。服务提供方在收到调用方的授权请求之后,我们只需要验证这个签名更调用方应用信息是否对应的上就可以了。

服务提供方可以提供的安全校验方式:

  1. md5摘要校验
  2. 非对称加密算法
  3. OAuth2授权

为了避免同一个接口有多个应用做发布提供者,我们需要把接口跟应用绑定上,一个接口只允许有一个应用发布提供者,避免其他应用也能发布这个接口。

当注册中心收到服务提供方注册申请时,可以验证下请求过来的应用是否跟接口绑定的应用一样,只有相同才允许注册,否则就返回错误信息给启动的应用,从而避免假冒的服务提供者对外提供错误服务。

19 | 分布式环境下如何快速定位问题?

分布式环境下定位问题有什么难点?

分布式环境下定位问题的难点在于,各子应用、子服务之间有复杂的依赖关系,我们有时很难确定是哪个服务的哪个环节出现的问题。如果要通过日志来排查问题,就需要对每个子应用、子服务逐一进行排查,很难一步到位。

在分布式环境下如何快速定位问题? 有两种方式:

  1. 借助合理封装的异常信息
  2. 借助分布式链路跟踪

RPC框架打印的异常信息中,需要包含定位问题所需要的异常信息的,比如哪些异常引起的问题(如序列化问题或网络超时问题),是调用端还是服务端出现的异常,调用端与服务端的IP是多少,以及服务接口与服务分组是什么等等。

异常的示意图如下所示。

image-20240520113224912

一款优秀的RPC框架要对异常进行详细地封装,还要对各类异常进行分类,每类异常都要有明确的异常标识码,并整理成一份简明的文档。适用房可以快速地通过异常标识码在文档中查阅,从而快速定位问题,找到原因,并且异常信息中药包含排查问题时所需要的重要信息,比如服务接口名、服务分组、调用端和服务端的IP,以及产生异常的原因。总之,要让适用房在复杂的分布式应用系统重,根据异常信息快速地定位到问题。

分布式链路跟踪可以让我们快速的知道整个服务调用的链路信息以及被调用的各个服务是否存在问题。例如服务A调用下游服务B,服务B又调用了B依赖的下游服务,如果服务A可以清楚的知道整个调用链路,并且能准确的直到调用链路中个服务的状态,那么就可以快速的定位问题。

分布式链路跟踪有Trace和Span两个关键概念:

  • Trace:代表整个链路,每次分布式都会产生一个Trace,每个Trace都有它的唯一标识,即TraceId,在分布式链路跟踪系统重,就是通过TraceId来区分每个Trace的。
  • Span:代表了整个链路的一段链路,也就说Trace是由多个Span组成的。在一个Trace下,每个Span也有唯一的标识SpanId,而Span之间是存在父子关系的。

Trace和Span的关系如下图所示。

image-20240520113243384

RPC在整合分布式链路跟踪所需要做的核心事情有2件:

  1. 埋点:分布式链路跟踪系统要想获得一次分布式调用的完整链路信息,就必须对这次分布式调用进行数据采集,而采集这些数据的方法就是通过RPC框架对分布式链路跟踪进行埋点。RPC调用端在访问服务端时,在发送请求消息前会触发分布式跟踪埋点,在接收到服务端响应时,也会触发分布式跟踪埋点,并且在服务端也会有类似的埋点。这些埋点最终可以记录一个完整的Span,而这个链路的源头会记录一个完整的Trace,最终Trace信息也会上报给分布式链路跟踪系统。
  2. 传递:上游调用端将Trace信息与父Span信息传递给下游服务的服务端,由下游触发埋点,对这些信息进行处理,在分布式链路跟踪系统重,每个子Span都存有父Span的相关信息以及Trace的相关信息。

20 | 详解时钟轮在RPC中的应用

RPC中的定时任务应该如何处理?

  1. 针对有定时需求的请求,建立额外的线程,使用Thread.sleep方法来处理。
  2. 建立一个单独的线程,来持续扫描有定时需求的请求,判断是否到时间了。
  3. 使用时钟轮方法

在时钟轮机制中,有时间槽和时钟轮的概念,时间槽相当于时钟的刻度,时钟轮箱单与秒针与分针等跳动的一个周期,我们会将每个人物放到相应的时间槽位上。

时钟轮的运行机制和生活中的时钟是一样的,每隔固定的单位时间,就会从一个时间槽位调到下一个时间槽位,这就相当于我们的秒针跳动了一次;时钟轮可以分为多层,下一层时钟轮中每个槽位的单位时间是当前时间轮整个周期的时间,这就相当于1分钟等于60秒钟;当时钟轮将一个周期的所有槽位都跳动完后,就会从下一层时钟轮中取出一个槽位的任务,重新分不到当前的时钟轮中,当前时钟轮则从第0槽位开始重新跳动,这就相当于下一分钟的第1秒。

在RPC框架中哪些功能会用到时钟轮?

  1. 调用端请求超时处理,我们每发一次请求,都创建一个处理请求超时的定时任务放到时钟轮里,在高并发、高访问量的情况下,时钟轮每次只轮询一个时间槽位中的任务,这样会节省大量的CPU。
  2. 心跳检测,对于这种需要重复执行的定时任务,我们可以在定时任务执行逻辑的最后,重设这个任务的执行时间,把它重新丢回到时钟轮里面。

在使用时间轮时,我们需要注意两件事情:

  1. 时间槽位的单位时间越短,时间轮触发任务的时间就越精确。
  2. 时间轮的槽位越多,那么一个任务呗重复扫描的概率就越小,因为只有在多层时钟轮中的任务才会被重复扫描。

21 | 流量回放:保障业务技术升级的神器

什么是流量回放?

流量就是指在某个时间段内的所有请求,我们通过某种手段把发送到A应用的所有请求录制下来,然后把这些请求统一转发到B应用,让B应用接收到的请求参数和A应用保持一致,从而实现A接收到的请求在B应用里面重新请求了一遍,这个过程,我们称为“流量回放”。

当我们对应用逻辑有改动,但在做了单元测试和回归测试之后,因为线上环境更加复杂,为了降低出错的概率,可以尝试使用流量回放。

传统QA测试不能满足要求的根本原因就是在于改造后的应用在上线后出现跟应用上线前不一致的行为。我们测试的目的就是为了保证改造后的应用跟改造前应用的行为一致,我们测试Case也应该尽力去模拟应用在线上的行为,这时最好的方式就是用线上流量来验证,但是又不能把新的应用直接上线,所以我们可以考虑流量回放。也就是说我们可以把线上一段时间内的请求参数和响应结果保存下来,然后把这些请求参数在新改造的应用里面重新请求一遍,对比一下改造前后的响应结果是否一致,这样就间接达到了使用线上流量进行测试的效果。

我们常用的流量回放方案包括TcpCopy、Nginx等。

在RPC框架中,因为所有的请求都经过RPC,我们可以在RPC中拿到这些请求参数,将这些参数旁录下来,并将旁录结果用异步的方式发送到一个固定的地方保存起来,这样就完成了流量录制功能。

在完成录制功能后,我们需要模拟一个应用调用方,将录制好的请求参数重新发送一遍到要回归测试的应用里面,然后对比录制拿到的请求结果和新请求的结果,这样就完成了请求回放的过程。

流量回放不是RPC框架的核心功能,但是有了这个功能以后,用户可以更放心的升级自己的应用了。

使用流量回放,对请求有一些限制:

  1. 请求是否依赖底层数据,如果依赖,那么需要保证底层数据是一致的。
  2. 请求是否与当前系统状态或者系统时间有关系,如果相关,那么相关依赖也需要保持一致。
  3. 请求所执行的方法是否幂等,如果不幂等,很可能会影响验证结果。

实现流量回放的设计思路:

  1. 使用动态代理,切面拦截对应的方法,获取出入参。
  2. 把拦截信息异步转存到线上验证系统。
  3. 通过线上验证系统调用待验证的防范。
  4. 收集结果对比信息,设置报警功能。

服务分组:为了避免非核心业务因调用量突增而影响整个系统的可用性,我们可以把服务按照调用方的不同划分为不同的小组或集群,这样可以实现调用流量的隔离,保证不同的业务之间不会相互影响。并且,通过将非核心业务应用和核心业务应用分开,可以进一步保护核心业务的稳定性。

分组机器配置:通过压力测试来评估每台服务器能承受的请求量(QPS),然后根据每个分组的总调用量计算出所需要的机器数量。为了应对可能的流量增长,我们通常会在计算出的机器数量基础上增加一定比例的额外机器作为缓冲。

动态分组:当某个分组的流量突然增加,而预留的机器无法满足需求时,我们可以检查其他分组是否有多余的处理能力来帮助处理请求。这就涉及到动态分组的概念,即通过修改注册中心的数据,动态调整服务分组,以适应不同的调用需求。

22 | 动态分组:超高效实现秒级扩缩容

我们之前学习过服务分组,在调用方复杂的情况下,如果让所有调用方都调用同一个集群,那么很可能会因为非核心业务调用量的突增,造成整个集群都不可用了,为了避免这种情况,我们需要把整个打击群根据不同的调用方划分出不同的小集群,从而实现调用方流量隔离的效果,保证不同业务之间不会相互影响。

在给集群分组的时候,我们一般会选择性的合并一些调用方到同一个分组里,至于如何合并,并没有统一标准,一般来说,我们可以按照应用的重要级别来划分,让非核心业务应用和核心业务应用不要共用一个分组,并且非核心应用之间也最好别用一个分组。

那么我们如何为每个分组配置合适的机器数量呢?一般会通过压测来评估服务提供方单台机器所能承受的QPS,然后再计算出每个分组里面的所有调用方的调用总量,考虑到可能的不确定性因素,我们可以在现有调用总量的基础上,添加一个百分比作为buffer,这个百分比一般来自经验总结。

我们计算每个分组所需要的机器数量时,会额外增加一些机器,这样让每个小集群可以有一定的抗压能力,而抗压能力取决于预留机器的数量,这就需要在成本和可用性之间做权衡。

当某个分组的调用方流量突增,而分组所预留的空间不能满足当前流量要求时,我们可以看一下其他分组的服务提供方是否有富余能力来帮忙处理请求,这也就是动态分组的含义。

因为服务提供方的分组信息以及机器节点都保存在注册中心里面,我们可以在注册中心里面将部分实例的别名改成我们想要的别名,然后通过服务发现进而影响到不同调用方能够调用的服务提供方实例集合,换句话说,我们可以通过控制注册中心,来管理服务调用方可以触达的服务提供方以及分组节点的信息。

通过直接修改注册中心数据,我们可以让任何一个分组瞬间拥有不同规模的集群能力。我们不仅可以实现把某个实例的分组名改成另一个分组名,还可以让某个实例分组名变成多个分组名,这就是我们在动态分组里面最常见的两种动作:追加和替换。

我们还可以利用动态分组解决分组后的每个分组预留机器冗余的问题,我们没有必要把所有冗余的机器都分配到分组里面,我们可以把这些机器做成一个共享的池子,从而减少整理预留的实例数量。

23 | 如何在没有接口的情况下进行RPC调用?

我们什么情况下需要在没有接口时进行RPC调用? 列举2个典型场景:

  1. 我们搭建一个测试平台,允许各个业务方在测试凭条上通过输入接口、分组名、方法名以及参数值,在线测试自己发布的RPC服务。
  2. 我们要搭建一个轻量级的服务网关,可以让各个业务方用HTTP的方式,通过服务网关调用其他服务。

所谓RPC调用,本质上就是调用端向服务端发送一条请求消息,服务端接收并处理,之后向调用端发送一条响应消息,调用端处理完响应消息后,一次RPC调用就完成了。

如果调用端可以将服务端需要知道的消息,例如接口名、业务分组名、方法名以及参数信息封装成请求消息发送给服务器,服务端就能够解析并处理这条请求信息,这样问题就解决了。

我们可以使用泛化接口的方式,来让RPC框架通过动态代理的方式,在没有接口的情况下,进行RPC调用,也称为泛化调用。

24 | 如何在线上环境里兼容多种RPC协议?

不同的RPC框架随着互联网技术的发展而慢慢涌现,这些框架会在不同时期被引入到不同的项目中去解决应用之间的通信问题,这样就导致了我们在线上的环境中会存在各种各样的RPC框架。

我们可以尝试通过自下而上的滚动升级方式,最终让所有的应用都切换到统一的RPC框架上,这种方法有2个局限:

  1. 这要求我们能够清楚的梳理出各个应用之间的调用关系,只有这样,我们才能按部就班地把所有应用都升级到新的RPC框架上。
  2. 这要求应用之间的关系不能存在互相调用的情况,最好是应用之间的调用关系就像一棵树,有一定的层次关系。但实际上,应用之间的调用关系往往会变成一张网。

这里的关键在于,我们要让新的RPC能同时支持多种RPC调用,当一个调用方切换到新的RPC之后,调用方和服务提供方之间就可以用新的协议完成调用,当调用方用老的RPC进行调用时,调用方和服务提供方之间就继续沿用老的协议完成调用。

RPC协议的作用是用来分割二进制数据流,不同的协议约定的数据包格式是不一样的,而且每种协议开头都有一个协议编码, 一般叫做magic number。

当RPC收到数据包之后,我们可以先解析出magic number,之后就可以找到对应协议的数据格式,然后使用相应的数据格式去解析收到的二进制数据包。

协议解析过程就是把一连串的二进制数据变成一个RPC内部对象,我们可以把和协议相关的对象转换成一个和协议无关的对象。

当完成真正的方法调用以后,RPC返回的也是一个和协议无关的通用对象,当我们向调用方回写数据时,我们还需要把通用对象转换成和协议相关的对象。

推荐阅读:https://github.com/lubanproj/grpc-read