苍穹之边,浩瀚之挚,眰恦之美;悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》


随着业务需求的发展和用户数量的激增,对于互联网应用系统或者服务应用程序则提出了新的挑战,也对从事系统研发的开发者有了更高的要求。作为一名IT从业研发人员,我们都知道的事,良好的用户体验是我们和应用系统间快速反馈,一直以来都是我们考量一个系统是否稳定和是否高效的设计目标,但是保证这个目标的关键之一,主要在于如何保证系统间的通信稳定和高效。从而映射出,如何正确理解软件应用系统中关于系统通信的那些事?是我们必须了解和理解的一项关键工作,接下来,我们就一起来总结和探讨一下。

要想理解系统服务间的交流,拿我们人与人的交流来做类比是个不错的选择。我们都知道,人与人之间的实现交流的基本元素主要有以下几个方面:
从而得知,系统服务间的交流的主要表现在以下几个方面:
实现系统间通信主要的三个要素:通信格式,通信协议,通信模型。

根据人与人的交流的构成要素,抽象成计算机系统服务中对应的概念(行之有效的概念往往是简单且趋同的),系统间通信主要考虑以下三个方面:通信格式,通信协议,通信模型。具体详情如下:
接下来,我们来详细解析这些组成要素:

我们用手机连接上网的时候,会用到许多网络协议。从手机连接 W i F i 开始, 使用的是 8 0 2 . 11 (即 W L A N ) 协议, 通过 W L A N 接入网络; 手机自动获取网络配置,使用的是 D H C P 协议,获取配置后手机才能正常通信。这时手机已经连入局域网,可以访问局域网内的设备和资源, 但还不能使用互联网应用,例如:微信、抖音等。想要访问互联网,还需要在手机的上联网络设备上实现相关协议, 即在无线路由器上配置 N AT、 P P P O E 等功能, 再通过运营商提供的互联网线路把局域网接入到互联网中, 手机就可以上网玩微信、刷抖音了。常见的网络主要有:
简单来说,就是手机、无线路由器等设备通过多种网络协议实现通信。网络协议就是为了通信各方能够互相交流而定义的标准或规则, 设备只要遵循相同的网络协议就能够实现通信。那网络协议又是谁规定的呢? ISO 制定了一个国际标准OSI , 其中的 OSI 参考模型常被用于网络协议的制定。常见的网络协议:
从计算机网络层面来说,常见网络模型主要有OSI 参考模型和TCP/IP 模型两种,主要表达如下:

O S I 参考模型将网络协议提供的服务分成 7 层,并定义每一层的服务内容, 实现每一层服务的是协议, 协议的具体内容是规则。上下层之间通过接口进行交互,同一层之间通过协议进行交互。 O S I 参考模型只对各层的服务做了粗略的界定, 并没有对协议进行详细的定义,但是许多协议都对应了 7 个分层的某一层。所以要了解网络,首先要了解 O S I 参考模型:


由于 OSI 参考模型把服务划得过于琐碎,先定义参考模型再定义协议,有点理想化。 TCP / IP 模型则正好相反, 通过已有的协议归纳总结出来的模型,成为业界的实际网络协议标准。TCP / IP 是有由 I E T F 建议、推进其标准化的一种协议, 是 IP 、 TCP 、HTTP 等协议的集合。TCP / IP是为使用互联网而开发制定的协议族, 所以互联网的协议就是 TCP / IP。TCP / IP 每层的主要协 议详情如下:
除此之外,我们还需要知道Linux 网络I/O 模型和Java JDK中的I/O 模型:

Linux的内核将所的外部设备看作一个文件来操作,对于一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd,File Descriptor);同时,在面对一个Socket的读写时也会有相应的套接字描述符(socketfd,Socket File Descriptor),描述符是一个数字,它指向内核中的一个结构体,比如文件路径,数据区等。Linux 网络I/O 模型是按照UNIX网络编程来定义的,主要有:

最流行的I/O模型,本书到目前为止的所有例子都使用该模型。默认情形下,所有套接字都是阻塞的。使用UDP而不是TCP为例子的原因在于就UDP而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有。对于TCP而言,诸如套接字低水位标记等额外变量开始起作用,道指这个概念复杂。我们把recvfrom函数视为系统调用,因为我们正在区分应用进程和内核。不管如何实现,一般都会从在应用进程空间中国运行切换到在内核空间中运行,一端时间之后再切换回来。 在上图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发送错误才返回。最常见的错误是系统调用被信号中断,我们说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

进程把一个套接字设置成非阻塞是在通知内核:当所有请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。接着处理数据。当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们成为轮询,应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到。

I/O复用,我们就可以调用select或者poll,阻塞在这两个系统调用中的某一个,而不是阻塞在真正的I/O系统调用上。我们阻塞与select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所可读数据报复制到应用进程缓冲区。比较上面两图,I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,其优势在于可以等待多个描述符就绪。

可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。称为信号驱动式I/O。我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理。也可以立即通知循环,让它读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们如何启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。我们调用aio_read函数,给内核传递描述符、缓冲区指针。缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等到I/O完成期间,我们的进程不被阻塞。

在Java语言中,应用程序发起 I/O 调用后,会经历两个阶段:
其中,阻塞和非阻塞:
而我们常说的同步和异步主要如下:

同步阻塞 IO 模型中,服务器应用程序发起 read 系统调用后,会一直阻塞,直到内核把数据拷贝到用户空间。完整的架构应该是 客户端-内核-服务器,客户端发起IO请求,服务器发起系统调用,内核把IO数据从内核空间拷贝到用户空间,服务器应用程序才能使用到客户端发送的数据。一般来说,客户端、服务端其实都属于用户空间,借助内核交流数据。
当用户进程发起了read系统调用,kernel就开始了IO的第一个阶段:准备数据。对于网络IO来说,很多时候数据在一开始还没有到达内核(比如说客户端目前只是建立了连接,还没有发送数据 或者是 网卡等待接收数据),所以kernel就需要要等待足够的数据到来。而在服务器进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除阻塞状态,重新运行起来。
Java中的JDBC也使用到了BIO技术。BIO在客户端连接数量不高的情况下是没问题的,但是当面对十万甚至百万级连接的时候,无法处理这种高并发情况,因此我们需要一种更高效的 I/O 处理模型来应对。

Java 中的 NIO 于 JDK 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)情况下,应使用 NIO 。
当服务器进程发出read操作时,如果kernel中数据还没准备好,那么并不会阻塞服务器进程,而是立即返回error,用户进程判断结果是error,就知道数据还没准备好,此时用户进程可以去干其他的事情。一段时间后用户进程再次发read,一直轮询直到kernel中数据准备好,此时用户发起read操作,产生system call,kernel 马上将数据拷贝到用户内存,然后返回,进程就能使用到用户空间中的数据了。
BIO一个线程只能处理一个IO流事件,想处理下一个必须等到当前IO流事件处理完毕。而NIO其实也只能串行化的处理IO事件,只不过它可以在内核等待数据准备数据时做其他的工作,不像BIO要一直阻塞住。NIO它会一直轮询操作系统,不断询问内核是否准备完毕。但是,NIO这样又引入了新的问题,如果当某个时间段里没有任何客户端IO事件产生时,服务器进程还在不断轮询,占用着CPU资源。所以要解决该问题,避免不必要的轮询,而且当无IO事件时,最好阻塞住(线程阻塞住就会释放CPU资源了)。所以NIO引入了多路复用机制,可以构建多路复用的、同步非阻塞的IO程序。

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是进程操作之后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。用户进程发起read操作之后,立刻就可以开始去做其它的事。
内核收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

Java 中的 NIO ,提供了 Selector(选择器)这个封装了操作系统IO多路复用能力的工具,通过Selector.select(),我们可以阻塞等待多个Channel(通道),知道任意一个Channel变得可读、可写,如此就能实现单线程管理多个Channels(客户端)。当所有Socket都空闲时,会把当前线程(选择器所处线程)阻塞掉,当有一个或多个Socket有I/O事件发生时,线程就从阻塞态醒来,并返回给服务端工作线程所有就绪的socket(文件描述符)。各个操作系统实现方案:
IO多路复用题同非阻塞IO本质一样,只不过利用了新的select系统调用,由内核来负责本来是服务器进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用的开销,不过因为可以支持多路复用IO,即一个进程监听多个socket,才算提高了效率。进程先是阻塞在select/poll上(进程是因为select/poll/epoll函数调用而阻塞,不是直接被IO阻塞的),再是阻塞在读写操作的第二阶段上(等待数据从内核空间拷贝到用户空间)。
IO多路复用的实现原理:利用select、poll、epoll可以同时监听多个socket的I/O事件的能力,而当有I/O事件产生时会被注册到Selector中。在所有socket空闲时,会把当前选择器进程阻塞掉,当有一个或多个流有I/O事件(或者说 一个或多个流有数据到达)时,选择器进程就从阻塞态中唤醒。通过select或poll轮询所负责的所有socket(epoll是只轮询那些真正产生了事件的socket),返回fd文件描述符集合给主线程串行执行事件。
⚠️[特别注意]:
select和poll每次调用时都需要将fd_set(文件描述符集合)从用户空间拷贝到内核空间中,函数返回时又要拷贝回来(epoll使用mmap,避免了每次wait都要将数组进行拷贝)。
在实际开发过程中,基于消息进行系统间通信,我们一般会有四种方法实现:
在Java中可基于Socket、ServerSocket来实现TCP/IP+BIO的系统通信。
为了满足服务端可以同时接受多个请求,最简单的方法是生成多个Socket。但这样会产生两个问题:
为了解决上面的问题,通常采用连接池的方式来维护Socket。一方面能限制Socket的个数;另一方面避免重复创建Socket带来的性能下降问题。这里有一个问题就是设置合适的相应超时时间。因为连接池中Socket个数是有限的,肯定会造成激烈的竞争和等待。
Server服务端:
//创建对本地端口的监听
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//向服务器发送字符串信息
out.println("hello");
//阻塞读取服务端的返回信息
in.readLine();Client客户端:
//创建连接
Socket socket = new Socket(目标IP或域名, 目标端口);
//BufferedReader用于读取服务端返回的数据
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//PrintWriter向服务器写入流
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
//像服务端发送流
out.println("hello");
//阻塞读取服务端的返回信息
in.readLine();Java可以基于Clannel和Selector的相关类来实现TCP/IP+NIO方式的系统间通信。Channel有SocketClannel和ServerSocketChannel两种:
Server服务端:
SocketChannel channel = SocketChannel.open();
//设置为非阻塞模式
channel.configureBlocking(false);
//对于非阻塞模式,立即返回false,表示连接正在建立中
channel.connect(SocketAdress);
Selector selector = Selector.open();
//向channel注册selector以及感兴趣的连接事件
channel.regester(selector,SelectionKey.OP_CONNECT);
//阻塞至有感兴趣的IO事件发生,或到达超时时间
int nKeys = selector.select(超时时间【毫秒计】);
//如果希望一直等待知道有感兴趣的事件发生
//int nKeys = selector.select();
//如果希望不阻塞直接返回当前是否有感兴趣的事件发生
//int nKeys = selector.selectNow();
//如果有感兴趣的事件
SelectionKey sKey = null;
if(nKeys>0){
Set keys = selector.selectedKeys();
for(SelectionKey key:keys){
//对于发生连接的事件
if(key.isConnectable()){
SocketChannel sc = (SocketChannel)key.channel();
sc.configureBlocking(false);
//注册感兴趣的IO读事件
sKey = sc.register(selector,SelectionKey.OP_READ);
//完成连接的建立
sc.finishConnect();
}
//有流可读取
else if(key.isReadable()){
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel sc = (SocketChannel) key.channel();
int readBytes = 0;
try{
int ret = 0;
try{
//读取目前可读取的值,此步为阻塞操作
while((ret=sc.read(buffer))>0){
readBytes += ret;
}
}
fanally{
buffer.flip();
}
}
finally{
if(buffer!=null){
buffer.clear();
}
}
}
//可写入流
else if(key.isWritable()){
//取消对OP_WRITE事件的注册
key.interestOps(key.interestOps() & (!SelectionKey.OP_WRITE));
SocketChannel sc = (SocketChannel) key.channel();
//此步为阻塞操作
int writtenedSize = sc.write(ByteBuffer);
//如未写入,则继续注册感兴趣的OP_WRITE事件
if(writtenedSize==0){
key.interestOps(key.interestOps()|SelectionKey.OP_WRITE);
}
}
}
Selector.selectedKeys().clear();
}
//对于要写入的流,可直接调用channel.write来完成。只有在未写入成功时才要注册OP_WRITE事件
int wSize = channel.write(ByteBuffer);
if(wSize == 0){
key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}Server端实体:
ServerSocketChannel ssc = ServerSocketChannel.open();
ServerSocket serverSocket = ssc.socket();
//绑定要监听的接口
serverSocket.bind(new InetSocketAdress(port));
ssc.configureBlocking(false);
//注册感兴趣的连接建立事件
ssc.register(selector,SelectionKey.OP_ACCEPT);Java对UDP/IP方式的网络数据传输同样采用Socket机制,只是UDP/IP下的Socket没有建立连接,因此无法双向通信。如果需要双向通信,必须两端都生成UDP Server。 Java中通过DatagramSocket和DatagramPacket来实现UDP/IP+BIO方式和系统间通信:
DatagramSocket:负责监听端口和读写数据
//如果希望双向通信,必须启动一个监听端口承担服务器的职责
//如果不能绑定到指定端口,则抛出SocketException
DatagramSocket serverSocket = new DatagramSocket(监听的端口);
byte[] buffer = new byte[65507];
DatagramPacket receivePacket = new DatagramPacket(buffer,buffer.length);
DatagramSocket socket = new DatagramSocket();
DatagramPacket packet = new DatagramPacket(datas,datas.length,server.length);
//阻塞方式发送packet到指定的服务器和端口
socket.send(packet);
//阻塞并同步读取流消息,如果读取的流消息比packet长,则删除更长的消息
//当连接不上目标地址和端口时,抛出PortUnreachableException
DatagramSocket.setSoTimeout(超时时间--毫秒级);
serverSocket.receive(receivePacket);Java中可以通过DatagramClannel和ByteBuffer来实现UDP/IP方式的系统间通信:
//读取流信息
DatagramChannel receiveChannel = DatagramChannel.open();
receiveChannel.configureBlocking(false);
DatagramSocket socket = receiveChannel.socket();
socket.bind(new InetSocketAddress(rport));
Selector selector = Selector.open();
receiveChannel.register(selector, SelectionKey.OP_REEAD);
//之后即可像TCP/IP+NIO中对selector遍历一样的方式进行流信息的读取
//...
//写入流信息
DatagramChannel sendChannel = DatagramChannel.open();
sendChannel.configureBlocking(false);
SocketAdress target = new InetSocketAdress("127.0.0.1",sport);
sendChannel.connect(target);
//阻塞写入流
sendChannel.write(ByteBuffer);
从软件系统的发展历程来看,在分布式应用出现之前,市面上几乎所有的软件系统都是集中式的,软件,硬件以及各个组件之间的高度耦合组成了单体架构软件平台,即就是所谓的单机系统。
一般来说,大型应用系统通常会被拆分成多个子系统,这些子系统可能会部署在多台机器上,也有可能只在一台机器上的多个线程中,这就是我们常说的分布式应用。
从部署形态上来说,以多台服务器和多个进程部署服务,都是为了实现一个业务需求和程序功能。分布式系统中的网络通信一般都会采用四层的 TCP 协议或七层的 HTTP 协议,在我的了解中,前者占大多数,这主要得益于 TCP 协议的稳定性和高效性。网络通信说起来简单,但实际上是一个非常复杂的过程,这个过程主要包括:对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等,每一项都很复杂。
对于系统间通信来说,我们需要区分集群和分布式两个标准:

在分布式服务诞生以前,主要采用以下几种方式实现系统间的通信:
在分布式应用时代,业界通常一般两种方式可以来实现系统间的通信,主要如下:
同时,从各系统间通信的整合方式,可以分为:

RPC是一种通过网络从远程计算机程序上请求服务,不需要我们了解底层网络技术的协议。主要体现在以下几个方面:

在 RPC 框架里面,我们是怎么支持插件化架构的呢?我们可以将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离,并提供接口的默认实现。在 Java 里面,JDK 有自带的 SPI(Service Provider Interface)服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。
但在实际项目中,我们其实很少使用到 JDK 自带的 SPI 机制,首先它不能按需加载,ServiceLoader 加载某个接口实现类的时候,会遍历全部获取,也就是接口的实现类得全部载入并实例化一遍,会造成不必要的浪费。另外就是扩展如果依赖其它的扩展,那就做不到自动注入和装配,这就很难和其他框架集成,比如扩展里面依赖了一个 Spring Bean,原生的 Java SPI 就不支持。
我们将每个功能点抽象成一个接口,将这个接口作为插件的契约,然后把这个功能的接口与功能的实现分离并提供接口的默认实现。这样的架构相比之前的架构,有很多优势。首先它的可扩展性很好,实现了开闭原则,用户可以非常方便地通过插件扩展实现自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精简,依赖外部包少,这样可以有效减少开发人员引入 RPC 导致的包版本冲突问题。
一般一个RPC 框架里面都有会涉及两个模块:
除此之外,我们还可以在协议模块中加入压缩功能,这是因为压缩过程也是对传输的二进制数据进行操作。在实际的网络传输过程中,我们的请求数据包在数据链路层可能会因为太大而被拆分成多个数据包进行传输,为了减少被拆分的次数,从而导致整个传输过程时间太长的问题,我们可以在 RPC 调用的时候这样操作:在方法调用参数或者返回值的二进制数据大于某个阈值的情况下,我们可以通过压缩框架进行无损压缩,然后在另外一端也用同样的压缩算法进行解压,保证数据可还原。
传输和协议这两个模块是 RPC 里面最基础的功能,它们使对象可以正确地传输到服务提供方。但距离 RPC 的目标——实现像调用本地一样地调用远程,还缺少点东西。因为这两个模块所提供的都是一些基础能力,要让这两个模块同时工作的话,我们需要手写一些黏合的代码,但这些代码对我们使用 RPC 的研发人员来说是没有意义的,而且属于一个重复的工作,会导致使用过程的体验非常不友好。

分布式子系统之间需要通信时,就发送消息。一般通信的两个要点是:消息处理和消息传输。
消息队列本质上是一种系统间相互协作的通信机制。一般使用消息队列可以业务解耦,流量削峰,日志收集,事务最终一致性,异步处理等业务场景。在我们实际开发工作中,一般消息队列的使用需要实现:
当然,在技术选型的时候,我们需要选择最适合我们的。
版权声明:本文为博主原创文章,遵循相关版权协议,如若转载或者分享请附上原文出处链接和链接来源。