分布式系统第三讲 通信技术
第三讲 通信技术
本章内容
-
底层通信技术
- 基于 TCP 的点对点通信技术
- 基于 UDP 的点对点通信技术
底层通信技术的好处:
- 逻辑可以自己控制
- 可以写出很快的代码
坏处:
- 要处理很多问题,比如异常,还有并发问题,同步异步,加锁等问题
因此出现了上层通信技术
-
并发服务技术
-
上层通信技术
- 远程过程调用 RPC/ 远程方法调用 RMI
- 基于消息队列的通信技术
- WebService 技术
解释一下上层通信技术:
RPC,也叫作远程过程调用,像C这样的面向过程语言,有一个主程序,还有若干个子程序,C执行的过程就是主程序调用子程序
但是现在主程序和子程序不在一个节点了,比如主程序在A节点,子程序在B节点,RPC可以让A的主程序调用B的子程序,这就叫做远程过程调用
调用的时候不需要自己管理socket,序列化等问题,只用使用一些RPC的中间件,就可以让本地的程序调用远端的服务
RMI,远程方法调用,也是和RPC类似,是点对点的通信技术
基于消息队列的通信技术
此处的队列不是一个计算机内的队列,而是跨计算机的队列,队列可以是一个节点。A将数据放在队列中,B从队列中拿取
如果A放的快,B放的慢,那么可以起一个缓存的作用
也可以起到一个主题订阅的作用
WebService技术
这是RPC技术的一个变种,强调大的公司之间,特别强调跨国跨集团 跨域
比如上游市场和下游市场,上游市场付款,下游市场根据付款下订单等
TCP/IP网络体系介绍
1. 7层协议和4层协议
四层模型具体如下
2. 各层网络协议的存在位置
- 路由器中只包含物理层、链路层、网络层协议实现模块
- 主机中包含五层协议实现模块
- 操作系统负责实现传输层及以下网络协议(也就是TCP、IP等,操作系统都实现了)的实现
3.1 Socket
3.1.1 什么是Socket
传输层和网络层提供给 应用层 的标准化编程接口(或称为编程接口)
网络层也是软件实现的,是代码,因此可以直接给应用层提供接口,应用层可以跨过传输层直接通过ip层发包
当然,应用层可以直接调用数据链路层的,比如局域网内网络数据的抓包,
以太网是一种总线结构
A B 通信,实际上C也会看到,只不过C的网卡发现不是给自己的包,就丢掉了,
因此应用层可以对数据链路层编码,进行抓包
只不过链路层对应用层没有提供包
一般说Socket有两种意思
-
标准接口服务(API)
-
点对点双向字节流管道(也就是一个管道)
这个双向意味着服务端也可以直接给客户端消息
Socket管道是以进程为单位的,所以是有端口号的
两个进程之间的管道也成为Socket
3.1.2 套接字分类
- 流失套接字是面向TCP的
- 数据报套接字是面向UDP的
- 原始套接字是面向IP的
3.1.3 如何标识一个Socket
事实上,在任何语言中,使用Socket,都是用这个方式来标识的
对于上述的管道,如何标识
使用一个五元组:
属性 | 说明 |
---|---|
sIP | 本地ip |
sPort | 本地端口号(本地端口号(通常临时分配: 1024 5000) |
dIP | 目标ip |
dPort | 目标端口号(远程端口号(通常使用保留端口号: 1 1023) |
协议 | TCP/IP等 |
实际上是四元组,协议一般都是TCP/IP,只是因为Socket出现的比TCP/IP早,那个时候有很多协议,Socket当时想着将所有协议都统起来
- 五元组实际上是历史遗留下来的说法,但是现在一般都是四元组
3.1.4 套接字编程典型模型
(1) TCP套接字
这个实际上也可以成为是客户端和服务端的Socket的生命周期
(2) UDP套接字
3.1.5 Socket实现回声服务器
回声服务器就是客户端发什么,服务端接收什么
(1) 客户端
代码
public class EchoClient {
public static void main(String[] args) throws IOException {
String userInput = null;
String echoMessage = null;
// 这里将标准输入作为输入
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
// 向服务器127.0.0.1:8189发出连接服务
// 只关注远端的ip和端口号,近端的自动生成的
Socket socket = new Socket("127.0.0.1", 8189);
// 获取输入和输出
InputStream inStream = socket.getInputStream();
OutputStream outStream = socket.getOutputStream();
// 进行包装
BufferedReader in = new BufferedReader(new InputStreamReader(inStream));
PrintWriter out = new PrintWriter(outStream);
// 一直从键盘获取数据,直到键盘输入ctrl + c的时候才会返回null
while ((userInput = stdIn.readLine()) != null) {
out.println(userInput);
// 使用bufferStream,会先保存到缓存的,用flush才能输出
out.flush();
// 输入来获取信息
echoMessage = in.readLine();
System.out.println("Echo from server: " + echoMessage);
}
// 关闭套接字
socket.close();
}
}
注意点
- 客户端向服务端发送socket的时候,仅仅关注的是远端的信息,近端的信息是在底层自动生成填充的,从而建立socket
- socket的通信都是通过socket的 iostream进行的,这些也可以使用包装
- 可以使用System.in来讲标准输入(键盘)作为输入
- 按下Ctrl + C表示输入结束
(2) 服务端
原理讲解(监听socket和通信socket)
先说明一下步骤
- 服务端先在本地建立监听socket
- 客户端向服务端的监听socket发出请求,建立socket通道
- 服务端将监听到的 与客户端的连接的 socket通道放在 连接记录队列中
- 服务端从连接记录队列中取出socket,给予通信socket,然后通信socket与服务端进行通信
- 服务端先在本地建立监听socket
这个socket的目的就是为了监听来自客户端的socket连接请求
- 客户端向服务端的监听socket发出请求,建立socket通道
注意,此时建立了通道是已经经过TCP的三次握手之后建立的
- 服务端将监听到的 与客户端的连接的 socket通道放在 连接记录队列中
- 服务端从连接记录队列中取出socket,给予通信socket,然后通信socket与服务端进行通信
源码
public class EchoServer {
public static void main(String[] args) throws IOException {
Socket clientSocket = null;
// 这里创建箭筒socket对象,并在8189端口监听握手请求
ServerSocket listenSocket = new ServerSocket(8189);
System.out.println("Server listening at 8189");
// 从连接队列中取出1个连接记录并创建新的通信socket
clientSocket = listenSocket.accept();
System.out.println("Accepted connection from client");
InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream();
BufferedReader in = new BufferedReader(new InputStreamReader(inputStream));
PrintWriter out = new PrintWriter(outputStream);
String line = null;
while ((line = in.readLine()) != null) {
System.out.println("Message from client: " + line);
out.println(line);
out.flush();
}
clientSocket.close();
listenSocket.close();
}
}
不足,上面的服务端只能接受一个客户端的请求,因此改成多线程
多线程改进
就是将上述的客户端改成多线程的
public class MultiThreadEchoServer {
public static void main(String[] args) throws IOException {
// 创建监听socket,开始监听
ServerSocket listenSocket = new ServerSocket(8189);
int count = 0;
System.out.println("Server listen at 8189");
while (true) {
// 通信socket
Socket socket = listenSocket.accept();
count++;
System.out.println("The total number of clients is " + count);
ServerThread serverThread = new ServerThread(socket);
serverThread.start();
}
}
}
public class ServerThread extends Thread{
Socket socket = null;
public ServerThread(Socket socket) {
this.socket = socket;
}
public void run() {
InputStream is = null;
BufferedReader br = null;
OutputStream os = null;
PrintWriter pw = null;
try {
is = socket.getInputStream();
br = new BufferedReader(new InputStreamReader(is));
os = socket.getOutputStream();
pw = new PrintWriter(os);
String line = null;
while ((line = br.readLine()) != null) {
System.out.println("Message from client: " + line);
pw.println(line);
pw.flush();
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
// finally统一用try捕获异常
try {
if (pw != null) {
pw.close();
}
if (os != null) {
os.close();
}
if (br != null) {
br.close();
}
if(is != null) {
is.close();
}
if (socket != null) {
socket.close();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
不足:
- 每次连接都要新开一个线程
- 每次断开都要关闭一个线程
- 但是线程的开关是消耗资源的,可以使用线程池优化
使用线程池改进
public class ThreadPoolTest {
public static void main(String[] args) {
// 开启线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 200, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(5));
for(int i=0;i<15;i++){
MyTask myTask = new MyTask(i);
executor.execute(myTask);
System.out.println("The number of threads in the ThreadPool:"+executor.getPoolSize());
System.out.println("The number of tasks in the Queue:" + executor.getQueue().size());
System.out.println("The number of tasks completed:"+executor.getCompletedTaskCount());
}
executor.shutdown();
}
}
class MyTask implements Runnable {
private int taskNum;
public MyTask(int num) {
this.taskNum = num;
}
@Override
public void run() {
int sum = 0;
System.out.println("Task"+taskNum+"is running!");
try {
for(int i=0; i<15; i++) {
sum += i;
}
Thread.currentThread().sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task "+taskNum+" has been done!");
}
}
3.1.6 UDP的Socket使用
客户端
public class UDPClient {
public static void main(String args[]) {
// args give message contents and server hostname
DatagramSocket aSocket = null;
try {
aSocket = new DatagramSocket();
byte[] m = args[0].getBytes();
InetAddress aHost = InetAddress.getByName("127.0.0.1");
int serverPort = 6789;
DatagramPacket request = new DatagramPacket(m, m.length, aHost, serverPort);
aSocket.send(request);
byte[] buffer = new byte[1000];
DatagramPacket reply = new DatagramPacket(buffer, buffer.length);
aSocket.receive(reply);
System.out.println("Reply: " + new String(reply.getData()));
} catch (SocketException e) {
System.out.println("Socket: " + e.getMessage());
} catch (IOException e) {
System.out.println("IO: " + e.getMessage());
} finally {
if (aSocket != null) aSocket.close();
}
}
}
服务端
public class UDPServer{
public static void main(String args[]){
DatagramSocket aSocket = null;
int serverPort = 6789;
try{
aSocket = new DatagramSocket(serverPort);
byte[] buffer = new byte[1000];
while(true){
DatagramPacket request = new DatagramPacket(buffer, buffer.length);
aSocket.receive(request);
DatagramPacket reply = new DatagramPacket(request.getData(),
request.getLength(), request.getAddress(), request.getPort());
aSocket.send(reply);
}
} catch (SocketException e){
System.out.println("Socket: " + e.getMessage());
} catch (IOException e) {
System.out.println("IO: " + e.getMessage());
} finally {
if (aSocket != null) aSocket.close();
}
}
}
3.1.7 缺点
Socket编程程序员要写大量的与业务无关的代码
如定义自己的应用协议
比如要在server实现add delete等功能,client可能要给server发送的信息是
# add # 1372 # 56 #...
意思是需要程序员定义一套规则
而且如果出现了信息发送中断,比如发了137,但是迟迟等不来下一个 #等
这些逻辑都是与业务无关的逻辑,都应该由底层自己去处理
以及高并发的问题等等
使用Socket可能很多时候都在处理底层的通信,而真正的业务可能就是添加一个订单
由此引入下面的RPC
RPC是一种中间件
数据库的软件,像Mysql,SQL Server等等都是属于中间件的
RPC让程序员从Socket的通信管理中解脱出来
3.2 远程过程调用RPC
3.2.1 说明
- 远程过程调用 (Remote Procedure Call, RPC): 使应用程序可以像调用本地节点上的 过程 子程序 那样去调用一个远程节点上的子程序。
- 对于被调用者而言也无法区分调用者来自于本地还是远程
- RPC 将面向过程的通用编程模型扩展到了分布式环境。
- 实现了跨进程、跨语言、跨网络、跨平台的过程调用
- 强化了面向接口编程的编程风格
- 实现 RPC 必须要有 RPC 中间件的支持。
- 思考 :你自己如何利用 Socket API 实现远程过程调用?
在之前写算法的过程中,多考虑正确性和效率,但是在分布式系统中,还要考虑可扩展性,这里是指性能的可扩展性
- 垂直可扩展性:机器性能提升,效率提升
- 水平可扩展性:机器数量提升,效率提升
RPC的作用
- 序列化/反序列化
- 定义一套协议
- 接口定义语言
- 注册中心
接口定义语言:RPC是可以支持不同的语言的,比如Java和C++,这就需要一种中间语言来对接,这就是接口定义语言。
这种语言不同于汇编语言,仅仅是用于对接口的
注册中心:
- 被调用方和调用方可能是一对多的关系,server端可能负载过重
- 而且server可能会挂掉
但是此时如果建立一个注册中心,每个server登录之后可以注册自己,client不需要直接查询server,而是先向注册中心查询,然后再调用
可以动态增加服务器,提高了可扩展性;降低了client和server的耦合性
3.2.2 RPC/RMI的实现原理
- 定义并利用 Socket 服务接口实现了一套调用者和被调用者之间的通信协议。( 远程过程调用协议 ),例如 Java RMI 的Java Remote Method Protocol (JRMP)
- 实现了过程参数(类似于上面的
# add # 1372 # 56 #...
)的 序列化(下面有什么是序列化) 、 反序列化 ;过程运算结果的序列化、反序列化。(详细在下面的说明中有) - 通信过程中的 错误处理( At most once, At least once,Exactly once)
- 过程服务进程 或远程对象 的 集中注册与发现(目录服务)
- 远程对象的统一标识和生命周期管理
- 在服务端支持并发访问。(多采用多线程技术)
说明
示例图如下
可以看到,在调用的底部,还是依靠的是RPC中间件
① 序列化和反序列化
序列化就是将内存中的对象等内容,转化为可以在网络中传输或者可以在硬盘中存储的字节
但是序列化并不是想象中的那么简单
比如A里面含有B 的对象,但这仅仅是在逻辑上,在实际上A可能是有B 的指针,这些问题都是在设计中间件的时候需要考虑的
序列化不是转换成JSON格式,因为JSON格式仅仅是便于用户随时查看,但是效率还是比较慢
可以看到序列化的过程如下
② 错误处理
通信过程中的 错误处理( At most once, At least once,Exactly once)
例如执行结束之后,Server会给Client一个ok,但是如果这个ok没有被Client接受到,那么Client就会再次发送一次,然后server再次执行一次
At most once就是最多执行一次,其他的也是这个意思
这个是RPC的问题,就是可能出现这种问题
要解决,只能在应用层做判断,比如判断编号
3.2.3 工作流程演示
3.3 基于消息中间件的技术
3.3.1 问题引入
为什么要使用消息中间件?
(1) 仅支持点对点通信的缺点
- 在复杂分布式系统中,仅支持点对点通信会使得不同节点之间的通信关系十分复杂,耦合度高。
- 数据生产节点需要记录多个消费节点的标识,消费节点需要记录多个生产节点的标识。
- 可扩展性差:每增加一个生产者或消费者会对多个节点产生影响。
- 容错性差:节点失效后,会丢失失效期间的数据;生产者、消费者速度不匹配时也会丢失数据。
(2) 解决方案—增加中介节点
- 降低了耦合度:数据生产者只向中介节点发送数据;数据消费者只向中介者订阅自己感兴趣的数据。
- 提高了容错性:中介节点具有数据缓存功能,部分节点失效、或者通信
双方速度暂时不匹配时数据也不会丢失。 - 提高了可扩展性:增加消费节点对生成节点无影响;增加同类型的生成节点,对消费节点无影响。
可以类比微信公众号系统,生产者只要将文章发送即可,不用管谁能读到,订阅者登录后文章会自动地推送到订阅者号上
这个中介节点就是消息中间件。
由于这个中间件也有缓存的功能,所以叫做消息队列中间件
基于消息中间件的通信技术
面向消息中间件 MOM: Message Oriented Middleware
提供了一种分布式消息队列服务,使得节点之间可以实现基于消息的形式灵活的 异步 通信。
异步的含义:
- 发送方可以在任意时刻发出消息,不必等待接收方上线,更不必等待消息发送成功再做下一步工作;
- 接收方不必以阻塞方式等待消息的到来。
3.3.2 过程原型
- 发送方节点和接收方节点都需要使用 client SDK,使用SDK提供的函数向MOM节点发布或者订阅消息
- MOM是一个节点,是一个独立的后台进程
(1) 分布式系统的总线型架构
- 不同节点之间通过虚拟总线相连
- 消息发送者不必知道接收者是谁,接收者也不知道发送者是谁
- 发送者和接收者之间用异步方式通信
- 一种松耦合架构
- 不同节点完成不同功能,分工协作
(2) 消息中间件工作原理
3.3.3 MOM支持的两种通信模式
(1) 消息队列通信模式
- 在 生产者 和 消费者 之间建立的满足先进先出的 消息队列
- 一个队列可以有多个生产者,也可以有多个消费者。
- 消息队列中的消息一旦被某个消费者取走,该消息就从队列中删除。
- 出队的消息按照某种负载均衡策略发送给特定的消费者。
- 高级队列模式:带优先级的队列;支持持久性的队列
比如快递,客户下达了多个快递点,只要有一个快递员接受了快递,就会从队列中删除这个消息
(2) 主题/订阅通信模式
- 支持向一个特定的 消息主题 发布消息。
- 多个订阅同一主题的消费者可以同时接收发布到该消息主题的消息
- 可以灵活地实现广播、组播等多对多通信模式
这个不是只有一个队列。而是每一个主题有一个队列。
类似于微信的公众号,假如有10个用户订阅了一个主题,那么当这个队列接受到10个ok之后才会删除消息
物联网的应用
在物联网中,经常使用这种模式
MQTT是物联网信息传输的一种协议
如何搭建这种物联网
- 物联网平台,使用开源的中间件,以及阿里的云服务器,将中间件部署到云服务器上(甚至有的阿里云服务就提供一些物联网服务)
- 找一些开发板,一般都会买一个带完整解决方案的开发板,比如说功能强大的单片机 STM32或者ESP,这里面会配相关的软件,以及将MQTT实现好了
- 买一些传感器,比如湿度传感器、温度传感器,当然要写自己的程序,将传感器的信息读出来,送到自己的物联网平台
- 自己设置客户端从物联网平台获取信息,也是使用MQTT协议
应用场景和区别
消息队列通信模式 | 主题/订阅通信模式 | |
---|---|---|
适用范围 | 负载均衡。这里可以将消费者视作服务器,生产者视作客户,客户发出请求过多而消费者不足的时候,可以随时添加消费者,从而实现负载均衡。生产者减少的时候,又可以减小消费者的数量 | 这个适用于不同类型的客户订阅相同主题的队列的时候使用 |
主题/订阅模式
就和上图类似,一个订单可能有多个系统都要用,但是使用的用途不一样
当每个系统都获取了之后才会删除订单
3.3.4 工作方式分类
(1) 缓存模式
① 持久化模式
MOM 把接收的消息缓存在内存的同时还保存一份拷贝到持久化存储器中(如硬盘)。如果消息被成功投递给接收者,则同时删除内存和硬盘中的消息。在 MOM 崩溃并重新上线后,将继续尝试投递那些被持久化的且未成功投递的消息。
好处:消息中间件重启的时候会再次读取上次没有发出的消息
② 非持久化模式
只在内存中缓存
(2) 投递模式
- At most once 消息可能会丢,但绝不会重复投递
- At least one 消息绝不会丢,但可能会重复投递
- Exactly once 每条消息肯定会被投递一次且仅投递一次(程序员喜欢,但是中间件比较累)
这三个也是要进行选择的
比如设计一个大数据系统,使用 at most once比较合适,因为大数据的结果不会因为多一条和少一条而影响
银行系统使用 Exactly once合适
3.3.5 常用的MOM中间件
ActiveMQ
- 由 Apache 出品,完全兼容 JMS Java Message Service
- 为多种编程语言提供客户端 API
- 与生产者、消费者客户端之间采用 AMQP(Advanced Message Queuing Protocol) 标准化协议进行通信
- 必须部署 中心服务器 作为 消息路由代理 。中心服务器可由服务器集群代替。
- RabbitMQ :采用 Erlang 语言实现的 AMQP 协议的消息中间件,最初起源于金融系统。
- RocketMQ :阿里 的开源产品,用 Java 语言实现;在阿里内部被广泛应用在订单,交易,充值,流计算,消息推送,日志流式处理等场景。
- Apache Kafka :提供完全分布式架构,与 Apache 的其他平台如 Hadoop 、 Apache Storm 、 Spark 、 Flink 等集成方便。
- ZeroMQ :号称史上最快的消息队列,基于 C 语言开发。
- WebsphereMQ IBM 的 MOM 中间件产品
JMS(Java Message Service)
- 为 Java 应用程序提供的访问不同 MOM 中间件的统一 API 接口;
- 应用程序坚持通过 JMS API 访问 MOM 中间件的服务,那么在更换MOM 产品类型时不会对应用程序造成影响。
- JMS 包含通用接口层(由 JDK 提供)和 JMS Provider 两大主要模块。
- 不同的 MOM 产品提供不同 JMS Provider 。
类似于 JDBC 或 ODBC
3.3.6 三种接收方式
- 阻塞接收
- 轮询接收
- 回调接收(通知接收)
receive阻塞接收:如果接受不到就阻塞,写程序的时候就接受一条处理一条一直往下写
msg = queue.receive(1000); 这里可以加一个1000ms的timeout
poll轮询接收:一直循环接受
notify回调接收:
myreceive(msg){...} queu.registerReceiver(myreceive) 只要注册就行了,然后就可以做其他事情了
第三次作业
- 功能要求:
利用MOM 消息队列技术实现一个分布式随机信号分析系统,具体要求:
- 随机信号产生器微服务 每隔 100 毫秒左右就产生一个正态分布的随机数字,并作为一个消息发布
- 一个 随机信号统计分析微服务 ,对信号进行如下分析
- 计算过去 N 个随机信号的均值和方差( N 为常量,可设置)
- 计算所有历史数据中的最大值和最小值
- 定时地将分析结果打包成一个新消息并通过 MOM 发布出去
- 一个 实时数据显示微服务
- 实时绘制过去一段时间内随机信号的折线图
- 实时显示随机信号统计分析结果