分布式系统第三讲 通信技术

第三讲 通信技术

本章内容

  • 底层通信技术

    • 基于 TCP 的点对点通信技术
    • 基于 UDP 的点对点通信技术

    底层通信技术的好处:

    • 逻辑可以自己控制
    • 可以写出很快的代码

    坏处:

    • 要处理很多问题,比如异常,还有并发问题,同步异步,加锁等问题

    因此出现了上层通信技术

  • 并发服务技术

  • 上层通信技术

    • 远程过程调用 RPC/ 远程方法调用 RMI
    • 基于消息队列的通信技术
    • WebService 技术

解释一下上层通信技术:

  • RPC,也叫作远程过程调用,像C这样的面向过程语言,有一个主程序,还有若干个子程序,C执行的过程就是主程序调用子程序

    但是现在主程序和子程序不在一个节点了,比如主程序在A节点,子程序在B节点,RPC可以让A的主程序调用B的子程序,这就叫做远程过程调用

    调用的时候不需要自己管理socket,序列化等问题,只用使用一些RPC的中间件,就可以让本地的程序调用远端的服务

  • RMI,远程方法调用,也是和RPC类似,是点对点的通信技术


  • 基于消息队列的通信技术

    image-20230531080756635

    此处的队列不是一个计算机内的队列,而是跨计算机的队列,队列可以是一个节点。A将数据放在队列中,B从队列中拿取

    如果A放的快,B放的慢,那么可以起一个缓存的作用

    image-20230531080920723

    也可以起到一个主题订阅的作用


  • WebService技术

    这是RPC技术的一个变种,强调大的公司之间,特别强调跨国跨集团 跨域

    比如上游市场和下游市场,上游市场付款,下游市场根据付款下订单等

TCP/IP网络体系介绍

1. 7层协议和4层协议

image-20230531081217934

四层模型具体如下

image-20230531081440782

2. 各层网络协议的存在位置

  • 路由器中只包含物理层、链路层、网络层协议实现模块
  • 主机中包含五层协议实现模块
  • 操作系统负责实现传输层以下网络协议(也就是TCP、IP等,操作系统都实现了)的实现

image-20230531081556268

3.1 Socket

3.1.1 什么是Socket

传输层网络层提供给 应用层标准化编程接口(或称为编程接口)

image-20230531085604556

网络层也是软件实现的,是代码,因此可以直接给应用层提供接口,应用层可以跨过传输层直接通过ip层发包

当然,应用层可以直接调用数据链路层的,比如局域网内网络数据的抓包,

以太网是一种总线结构

image-20230531085812188

A B 通信,实际上C也会看到,只不过C的网卡发现不是给自己的包,就丢掉了,

因此应用层可以对数据链路层编码,进行抓包

只不过链路层对应用层没有提供包

一般说Socket有两种意思

  • 标准接口服务(API)

  • 点对点双向字节流管道(也就是一个管道)

    这个双向意味着服务端也可以直接给客户端消息

image-20230531090630416

Socket管道是以进程为单位的,所以是有端口号的

两个进程之间的管道也成为Socket

3.1.2 套接字分类

image-20230531085929890

  • 流失套接字是面向TCP的
  • 数据报套接字是面向UDP的
  • 原始套接字是面向IP的

3.1.3 如何标识一个Socket

事实上,在任何语言中,使用Socket,都是用这个方式来标识的

image-20230531090630416

对于上述的管道,如何标识

使用一个五元组:

属性 说明
sIP 本地ip
sPort 本地端口号(本地端口号(通常临时分配: 1024 5000)
dIP 目标ip
dPort 目标端口号(远程端口号(通常使用保留端口号: 1 1023)
协议 TCP/IP等

实际上是四元组,协议一般都是TCP/IP,只是因为Socket出现的比TCP/IP早,那个时候有很多协议,Socket当时想着将所有协议都统起来

  • 五元组实际上是历史遗留下来的说法,但是现在一般都是四元组

3.1.4 套接字编程典型模型

(1) TCP套接字

image-20230531092051582

这个实际上也可以成为是客户端和服务端的Socket的生命周期

image-20230531092113382

(2) UDP套接字

image-20230531092153568

3.1.5 Socket实现回声服务器

image-20230531092420183

回声服务器就是客户端发什么,服务端接收什么

(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();

    }
}
注意点
  1. 客户端向服务端发送socket的时候,仅仅关注的是远端的信息,近端的信息是在底层自动生成填充的,从而建立socket
  2. socket的通信都是通过socket的 iostream进行的,这些也可以使用包装
  3. 可以使用System.in来讲标准输入(键盘)作为输入
  4. 按下Ctrl + C表示输入结束

(2) 服务端

原理讲解(监听socket和通信socket)

先说明一下步骤

  1. 服务端先在本地建立监听socket
  2. 客户端向服务端的监听socket发出请求,建立socket通道
  3. 服务端将监听到的 与客户端的连接的 socket通道放在 连接记录队列中
  4. 服务端从连接记录队列中取出socket,给予通信socket,然后通信socket与服务端进行通信

  1. 服务端先在本地建立监听socket

image-20230531101748191

这个socket的目的就是为了监听来自客户端的socket连接请求

  1. 客户端向服务端的监听socket发出请求,建立socket通道

image-20230531101821793

注意,此时建立了通道是已经经过TCP的三次握手之后建立的

  1. 服务端将监听到的 与客户端的连接的 socket通道放在 连接记录队列中

image-20230531102315378

  1. 服务端从连接记录队列中取出socket,给予通信socket,然后通信socket与服务端进行通信

image-20230531102401577

源码
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是一种中间件

image-20230531143758762

数据库的软件,像Mysql,SQL Server等等都是属于中间件的

RPC让程序员从Socket的通信管理中解脱出来

3.2 远程过程调用RPC

3.2.1 说明

  • 远程过程调用 (Remote Procedure Call, RPC): 使应用程序可以像调用本地节点上的 过程 子程序 那样去调用一个远程节点上的子程序。
  • 对于被调用者而言也无法区分调用者来自于本地还是远程
  • RPC 将面向过程的通用编程模型扩展到了分布式环境。
  • 实现了跨进程、跨语言、跨网络、跨平台的过程调用
  • 强化了面向接口编程的编程风格
  • 实现 RPC 必须要有 RPC 中间件的支持。
  • 思考 :你自己如何利用 Socket API 实现远程过程调用?

在之前写算法的过程中,多考虑正确性和效率,但是在分布式系统中,还要考虑可扩展性,这里是指性能的可扩展性

  • 垂直可扩展性:机器性能提升,效率提升
  • 水平可扩展性:机器数量提升,效率提升

RPC的作用

  • 序列化/反序列化
  • 定义一套协议
  • 接口定义语言
  • 注册中心

接口定义语言:RPC是可以支持不同的语言的,比如Java和C++,这就需要一种中间语言来对接,这就是接口定义语言。

这种语言不同于汇编语言,仅仅是用于对接口的

image-20230531194336978

注册中心:

  • 被调用方和调用方可能是一对多的关系,server端可能负载过重
  • 而且server可能会挂掉
image-20230531152350928

​ 但是此时如果建立一个注册中心,每个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)
  • 过程服务进程 或远程对象 的 集中注册与发现(目录服务)
  • 远程对象的统一标识和生命周期管理
  • 在服务端支持并发访问。(多采用多线程技术)

image-20230531151813834

说明

示例图如下

可以看到,在调用的底部,还是依靠的是RPC中间件

image-20230531151141611

① 序列化和反序列化

image-20230531151250527

序列化就是将内存中的对象等内容,转化为可以在网络中传输或者可以在硬盘中存储的字节

但是序列化并不是想象中的那么简单

image-20230531151346101

比如A里面含有B 的对象,但这仅仅是在逻辑上,在实际上A可能是有B 的指针,这些问题都是在设计中间件的时候需要考虑的

序列化不是转换成JSON格式,因为JSON格式仅仅是便于用户随时查看,但是效率还是比较慢

可以看到序列化的过程如下

image-20230531151514883

② 错误处理

通信过程中的 错误处理( At most once, At least once,Exactly once)

image-20230531173034486

例如执行结束之后,Server会给Client一个ok,但是如果这个ok没有被Client接受到,那么Client就会再次发送一次,然后server再次执行一次

At most once就是最多执行一次,其他的也是这个意思

这个是RPC的问题,就是可能出现这种问题

要解决,只能在应用层做判断,比如判断编号

3.2.3 工作流程演示

image-20230531152424762 image-20230531152444323
image-20230531152231198 image-20230531152244737
image-20230531152250911 image-20230531152259107
image-20230531152304533 image-20230531152319802
image-20230531152338332 image-20230531152350928

3.3 基于消息中间件的技术

3.3.1 问题引入

为什么要使用消息中间件?

(1) 仅支持点对点通信的缺点

image-20230531211914900

  • 在复杂分布式系统中,仅支持点对点通信会使得不同节点之间的通信关系十分复杂,耦合度高
  • 数据生产节点需要记录多个消费节点的标识,消费节点需要记录多个生产节点的标识。
  • 可扩展性差:每增加一个生产者或消费者会对多个节点产生影响。
  • 容错性差:节点失效后,会丢失失效期间的数据;生产者、消费者速度不匹配时也会丢失数据。

(2) 解决方案—增加中介节点

image-20230531212024062

  • 降低了耦合度:数据生产者只向中介节点发送数据;数据消费者只向中介者订阅自己感兴趣的数据。
  • 提高了容错性:中介节点具有数据缓存功能,部分节点失效、或者通信
    双方速度暂时不匹配时数据也不会丢失。
  • 提高了可扩展性:增加消费节点对生成节点无影响;增加同类型的生成节点,对消费节点无影响。

可以类比微信公众号系统,生产者只要将文章发送即可,不用管谁能读到,订阅者登录后文章会自动地推送到订阅者号上

这个中介节点就是消息中间件。

由于这个中间件也有缓存的功能,所以叫做消息队列中间件

基于消息中间件的通信技术

面向消息中间件 MOM: Message Oriented Middleware
提供了一种分布式消息队列服务,使得节点之间可以实现基于消息的形式灵活的 异步 通信。

image-20230531212337909

异步的含义:

  • 发送方可以在任意时刻发出消息,不必等待接收方上线,更不必等待消息发送成功再做下一步工作;
  • 接收方不必以阻塞方式等待消息的到来。

3.3.2 过程原型

image-20230531220619849

  • 发送方节点和接收方节点都需要使用 client SDK,使用SDK提供的函数向MOM节点发布或者订阅消息
  • MOM是一个节点,是一个独立的后台进程

(1) 分布式系统的总线型架构

image-20230531220815580

  • 不同节点之间通过虚拟总线相连
  • 消息发送者不必知道接收者是谁,接收者也不知道发送者是谁
  • 发送者和接收者之间用异步方式通信
  • 一种松耦合架构
  • 不同节点完成不同功能,分工协作

(2) 消息中间件工作原理

image-20230531220854085

3.3.3 MOM支持的两种通信模式

(1) 消息队列通信模式

  • 在 生产者 和 消费者 之间建立的满足先进先出的 消息队列
  • 一个队列可以有多个生产者,也可以有多个消费者。
  • 消息队列中的消息一旦被某个消费者取走,该消息就从队列中删除。
  • 出队的消息按照某种负载均衡策略发送给特定的消费者。
  • 高级队列模式:带优先级的队列;支持持久性的队列

image-20230531221019499

比如快递,客户下达了多个快递点,只要有一个快递员接受了快递,就会从队列中删除这个消息

(2) 主题/订阅通信模式

  • 支持向一个特定的 消息主题 发布消息。
  • 多个订阅同一主题的消费者可以同时接收发布到该消息主题的消息
  • 可以灵活地实现广播、组播等多对多通信模式

image-20230531223247864

这个不是只有一个队列。而是每一个主题有一个队列。

类似于微信的公众号,假如有10个用户订阅了一个主题,那么当这个队列接受到10个ok之后才会删除消息

物联网的应用

在物联网中,经常使用这种模式

image-20230531223716928

MQTT是物联网信息传输的一种协议

如何搭建这种物联网

  1. 物联网平台,使用开源的中间件,以及阿里的云服务器,将中间件部署到云服务器上(甚至有的阿里云服务就提供一些物联网服务)
  2. 找一些开发板,一般都会买一个带完整解决方案的开发板,比如说功能强大的单片机 STM32或者ESP,这里面会配相关的软件,以及将MQTT实现好了
  3. 买一些传感器,比如湿度传感器、温度传感器,当然要写自己的程序,将传感器的信息读出来,送到自己的物联网平台
  4. 自己设置客户端从物联网平台获取信息,也是使用MQTT协议

应用场景和区别

消息队列通信模式 主题/订阅通信模式
适用范围 负载均衡。这里可以将消费者视作服务器,生产者视作客户,客户发出请求过多而消费者不足的时候,可以随时添加消费者,从而实现负载均衡。生产者减少的时候,又可以减小消费者的数量 这个适用于不同类型的客户订阅相同主题的队列的时候使用

image-20230531212024062

主题/订阅模式

就和上图类似,一个订单可能有多个系统都要用,但是使用的用途不一样

当每个系统都获取了之后才会删除订单

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) 标准化协议进行通信
  • 必须部署 中心服务器 作为 消息路由代理 。中心服务器可由服务器集群代替。

image-20230531222029458

  • 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

image-20230531222204973

3.3.6 三种接收方式

  • 阻塞接收
  • 轮询接收
  • 回调接收(通知接收)

image-20230601084844909

  • receive阻塞接收:如果接受不到就阻塞,写程序的时候就接受一条处理一条一直往下写

    msg = queue.receive(1000); 这里可以加一个1000ms的timeout

  • poll轮询接收:一直循环接受

  • notify回调接收:

    myreceive(msg){...}
    queu.registerReceiver(myreceive)
    
    只要注册就行了,然后就可以做其他事情了
    

第三次作业

  • 功能要求:

利用MOM 消息队列技术实现一个分布式随机信号分析系统,具体要求:

  1. 随机信号产生器微服务 每隔 100 毫秒左右就产生一个正态分布的随机数字,并作为一个消息发布
  2. 一个 随机信号统计分析微服务 ,对信号进行如下分析
    • 计算过去 N 个随机信号的均值和方差( N 为常量,可设置)
    • 计算所有历史数据中的最大值和最小值
    • 定时地将分析结果打包成一个新消息并通过 MOM 发布出去
  3. 一个 实时数据显示微服务
    • 实时绘制过去一段时间内随机信号的折线图
    • 实时显示随机信号统计分析结果
posted @ 2023-10-07 17:25  Crispy·Candy  阅读(104)  评论(0编辑  收藏  举报