1.系统间通信技术
一般来说,大型应用通常会被拆分称多个子系统,这些子系统可能会部署在多台机器上,也可能只是一台机器的多个进程中,这样的应用就是分布式应用。而分布式应用的子系统之间并不是完全独立的,它们需要相互通信来共同完成某个功能,这就涉及系统间通信了。
目前业界通常有两种方式来实现系统间通信,其中一种是基于远程过程调用的方式,也就是我们常说的RPC调用;另外一种是基于消息队列的方式;(后续单独加RPC和消息队列的区别及各自的优劣势)
2.为何要用消息队列
消息队列的主要应用场景:异步、解耦、削峰;
- 异步
一些非必要的业务逻辑以同步的方式运行,太耗费时间。将消息写入消息队列,非必要的业务逻辑以异步的方式运行,加快响应速度。
场景说明:用户注册后,需要发注册邮件和注册短信。传统的做法有两种 1.串行的方式;2.并行方式
a、串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端。
b、并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的时间。
假设三个业务节点每个使用50毫秒,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。
因为CPU在单位时间内处理的请求数是一定的,假设CPU1秒内吞吐量是100次。则串行方式1秒内CPU可处理的请求量是7次(1000/150)。并行方式处理的请求量是10次(1000/100)
小结:如以上案例描述,传统的方式系统的性能(并发量,吞吐量,响应时间)会有瓶颈。如何解决这个问题呢?
引入消息队列,将不是必须的业务逻辑,异步处理。改造后的架构如下:
按照以上约定,用户的响应时间相当于是注册信息写入数据库的时间,也就是50毫秒。注册邮件,发送短信写入消息队列后,直接返回,因此写入消息队列的速度很快,基本可以忽略,因此用户的响应时间可能是50毫秒。因此架构改变后,系统的吞吐量提高到每秒20 QPS。比串行提高了3倍,比并行提高了两倍。
2. 解耦
系统间耦合性太强,彼此强依赖,互相影响。
场景说明:用户下单后,订单系统需要通知库存系统。传统的做法是,订单系统调用库存系统的接口。如下图:
传统模式的缺点:假如库存系统无法访问,则订单减库存将失败,从而导致订单失败,订单系统与库存系统耦合。
如何解决以上问题呢?引入应用消息队列后的方案,如下图:
订单系统:用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功。
库存系统:订阅下单的消息,采用拉/推的方式,获取下单信息,库存系统根据下单信息,进行库存操作。
假如:在下单时库存系统不能正常使用。也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其他的后续操作了。实现订单系统与库存系统的应用解耦。
3. 削峰
流量削锋也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。
应用场景:秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列。
a、可以控制活动的人数
b、可以缓解短时间内高流量压垮应用
用户的请求,服务器接收后,首先写入消息队列。假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到错误页面。秒杀业务根据消息队列中的请求信息,再做后续处理。
每天 0:00 到 12:00,A 系统风平浪静,每秒并发请求数量就 50 个。结果每次一到 12:00 ~ 13:00 ,每秒并发请求数量突然会暴增到 5k+ 条。但是系统是直接基于 MySQL 的,大量的请求涌入 MySQL,每秒钟对 MySQL 执行约 5k 条 SQL。
一般的 MySQL,扛到每秒 2k 个请求就差不多了,如果每秒请求到 5k 的话,可能就直接把 MySQL 给打死了,导致系统崩溃,用户也就没法再使用系统了。
但是高峰期一过,到了下午的时候,就成了低峰期,可能也就 1w 的用户同时在网站上操作,每秒中的请求数量可能也就 50 个请求,对整个系统几乎没有任何的压力。
如果使用 MQ,每秒 5k 个请求写入 MQ,A 系统每秒钟最多处理 2k 个请求,因为 MySQL 每秒钟最多处理 2k 个。A 系统从 MQ 中慢慢拉取请求,每秒钟就拉取 2k 个请求,不要超过自己每秒能处理的最大请求数量就 ok,这样下来,哪怕是高峰期的时候,A 系统也绝对不会挂掉。而 MQ 每秒钟 5k 个请求进来,就 2k 个请求出去,结果就导致在中午高峰期(1 个小时),可能有几十万甚至几百万的请求积压在 MQ 中。
这个短暂的高峰期积压是 ok 的,因为高峰期过了之后,每秒钟就 50 个请求进 MQ,但是 A 系统依然会按照每秒 2k 个请求的速度在处理。所以说,只要高峰期一过,A 系统就会快速将积压的消息给解决掉。
消息队列的优点上面已经讲了,缺点有哪些呢?
1、系统可用性降低
系统引入的外部依赖越多,越容易挂掉。本来你就是 A 系统调用 BCD 三个系统的接口就好了,ABCD 四个系统还好好的,没啥问题,你偏加个 MQ 进来,万一 MQ 挂了咋整?MQ 一挂,整套系统崩溃,你不就完了? 如何保证消息队列的高可用呢?下一章讲解
2、系统复杂度提高
硬生生加个 MQ 进来,你怎么保证消息没有重复消费?怎么处理消息丢失的情况?怎么保证消息传递的顺序性?头大头大,问题一大堆,痛苦不已。
3、一致性问题
A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,咋整?你这数据就不一致了。
所以消息队列实际是一种非常复杂的架构,你引入它有很多好处,但是也得针对它带来的坏处做各种额外的技术方案和架构来规避掉,做好之后,你会发现,妈呀,系统复杂度提升了一个数量级,也许是复杂了 10 倍。但是关键时刻,用,还是得用的。
3.消息队列的功能特点
消息队列,包含2个关键词:消息和队列。
消息是指在应用间传送的数据,消息的表现形式是多样的,可以简单到只包含文本字符串,也可以复杂到有一个结构化的对象定义格式。
对于队列,从抽象意义上来理解,就是指消息的进和出。从时间顺序上说,进和出并不一定是同步进行的,所以需要一个容器来暂存和处理消息。因此,一个典型意义上的消息队列,至少需要包括消息的发送、接收和暂存功能。
- Broker:消息处理中心,负责消息的接收、存储、转发等。
- Producer:消息生产者,负责产生和发送消息到消息处理中心。
- Consumer:消息消费者,负责从消息处理中心获取消息,并进行相应的处理。
但是在生产环境应用中,对消息队列的要求远不止基本的消息发送、接收和暂存。在不同的业务场景中,需要消息队列产品能解决诸如消息堆积、消息持久化、可靠投递、消息重复、严格有序、集群等各种问题。 - 消息堆积
生产者、消费者速度差别过大,消息处理中心的系统资源被耗尽,导致机器挂掉甚至整个消息队列不可用。
给消息队列设置阀值。 - 消息持久化
如果业务场景不允许有消息的丢失,那么就要将消息持久化。持久化方案有很多种,比如将消息存到本地文件、分布式文件系统、数据库系统中等。 - 可靠投递
可靠投递是不允许存在消息丢失的情况。从消息的整个生命周期来分析,消息丢失的情况一般发生在如下过程中:
从生产者到消息处理中心
从消息处理中心到消息消费者
消息处理中心持久化消息 - 消息重复
有些消息队列为了支持消息可靠投递,会选择在接收到消息后先持久化到本地,然后发送给消费者。当消息发送失败或者不知道是否发送成功时(比如超时),消息的状态是待发送,定时任务不停地轮询所有的待发送消息,最终保证消息不会丢失,这就带来了消息可能会重复的问题。 - 严格有序
有些业务场景,需要按生产消息时的顺序来消费。 - 集群
排除单点故障引起的服务中断,提高服务的可用性。
多个节点负载均衡,提高消息通信的吞吐量。 - 消息中间件
中间件:非底层操作系统软件、非业务应用软件,不是直接给最终用户使用的,不能直接给客户带来价值的软件系统。
消息中间件关注于数据的发送和接收,利用高效、可靠的异步消息传递机制继承分布式系统。
4.设计一个简单的消息队列
看了那么多文字描述,不如自己动手实践一遍体会深刻。下面是用Java语言写一个简单的消息队列。
消息队列的完整使用场景中至少包含三个角色。
- 消息处理中心:负责消息的接收、存储、转发等。
- 消息生产者:负责产生和发送消息到消息处理中心。
- 消息消费者:负责从消息处理中心获取消息,并进行相应的处理。
消息处理中心
消息处理中心Broker类的实现
import java.util.concurrent.ArrayBlockingQueue;
/**
* @author wangmin
* @description 消息处理中心
* created at 2020/5/27 4:37 下午
*/
public class Broker {
//队列存储消息的最大数量
private final static int MAX_SIZE = 3;
//保存消息数据的容器
private static ArrayBlockingQueue<String> messageQueue = new ArrayBlockingQueue<>(MAX_SIZE);
/**
* 生产消息
* @param msg
*/
public static void produce(String msg) {
if (messageQueue.offer(msg)) {
System.out.println("成功向消息中心投递消息:" + msg + ",当前暂存的消息数量是:" + messageQueue.size());
} else {
System.out.println("消息处理中心暂存的消息达到最大负荷,不能继续放入消息!");
}
System.out.println("====================");
}
public static String consume() {
String msg = messageQueue.poll();
if (msg != null) {
//消费条件满足情况,从消息容器中取出一条消息
System.out.println("已经消费消息:" + msg + ",当前暂存的消息数量是:" + messageQueue.size());
} else {
System.out.println("消息处理中心内没有消息可供消费!");
}
System.out.println("====================");
return msg;
}
}
作为一个消息处理中心,至少要有一个数据容器用来保存接收到的消息。Java中的队列(Queue)是提供该功能的一种简单的数据结构,同时为简化对队列操作的并发访问处理,我们选择了它的一个子类ArrayBlockingQueue。该类提供了对数据的插入、获取、查询等操作,其底层将数据以数组的形式保存。
有了消息处理中心类之后,需要将该类的功能暴露出去,这样别人才能用它来发送和接收消息。所以,我们定义了BrokerServer类用来对外提供Broker类的服务。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @author wangmin
* @description 用来对外提供Broker类的服务
* created at 2020/5/27 5:09 下午
*/
public class BrokerServer implements Runnable {
public static int SERVICE_PORT = 9999;
private final Socket socket;
public BrokerServer(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream());
while (true) {
String str = in.readLine();
if (str == null) {
continue;
}
System.out.println("接收到原始数据:" + str);
if (str.equals("CONSUME")) { //CONSUME表示要消费一条消息
//从消息对列中消费一条消息
String message = Broker.consume();
out.println(message);
out.flush();
} else {
//其他情况都表示生产消息放到消息队列中
Broker.produce(str);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(SERVICE_PORT);
while (true) {
BrokerServer brokerServer = new BrokerServer(serverSocket.accept());
new Thread(brokerServer).start();
}
}
}
Java中涉及服务器功能的软件一般少不了套接字(Socket)和线程(Thread),因为需要通过线程的方式将应用启动起来,而服务器和应用的客户端需要用Socket进行网络通信。
客户端访问
有了消息处理中心后,自然需要有相应客户端与之通信来发送和接收消息。
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
/**
* @author wangmin
* @description 客户端访问消息处理中心
* created at 2020/5/27 5:26 下午
*/
public class MqClient {
/**
* 生产消息
* @param message
* @throws Exception
*/
public static void produce(String message) throws Exception {
Socket socket = new Socket(InetAddress.getLocalHost(), BrokerServer.SERVICE_PORT);
try {
PrintWriter out = new PrintWriter(socket.getOutputStream());
out.println(message);
out.flush();
} catch (Exception e) {
throw e;
}
}
/**
* 消费消息
* @return
* @throws Exception
*/
public static String consume() throws Exception {
Socket socket = new Socket(InetAddress.getLocalHost(), BrokerServer.SERVICE_PORT);
try {
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream());
//先向消息队列发送字符串"CONSUME"表示消费
out.println("CONSUME");
out.flush();
//再从消息队列获取一条消息
String message = in.readLine();
return message;
} catch (Exception e) {
throw e;
}
}
}
因为客户端和服务端是通过网络通信的,所以显然也是通过Socket来实现的。生产消息就是通过网络与消息处理中心通信的,将数据写入输出流中,这就模拟了生产消息并发送到消息队列的过程。消费消息实际是先向消息处理中心服务器写入字符串“CONSUME”,表示当前需要消费一条消息,然后通过Socket的输入流从消息处理中心服务器获取消息数据,再返回给调用者。
以上是通用的客户端访问代码,接下来是生产消息和消费消息的示例。
生产消息:
/**
* @author wangmin
* @description 生产者客户端
* created at 2020/5/27 5:34 下午
*/
public class ProduceClient {
public static void main(String[] args) throws Exception {
MqClient.produce("Hello World");
}
}
执行main方法,可以在BrokerServer类的控制台看到消息被写入队列中。
因为微队列设置了大小为3,所以如果执行了4次,则会看到超过队列容量,不能继续放入消息了。
消费消息:
/**
* @author wangmin
* @description 消费者客户端
* created at 2020/5/27 5:28 下午
*/
public class ConsumeClient {
public static void main(String[] args) throws Exception {
String message = MqClient.consume();
System.out.println("获取的消息为:" + message);
}
}
执行main方法,可以在ConsumeClient类的控制台看到消费了一条消息。
从BrokerServer类的控制台可以看到接收到“CONSUME”字符串并消费了消息。
如果消息队列中没有消息,则会从控制台看到提醒。