一、什么是中间件?
中间件(Middleware)是处于操作系统和应用程序之间的软件。中间件是一种独立的系统软件或服务程序,分布式应用软件借助这种软件在不同的技术之间共享资源。
中间件有很多产品,比如之前学习过的缓存中间件redis,全文搜索引擎中间件ElasticSearch,消息中间件RabbitMQ、ActiveMQ、Kafka,还有数据库中间件MyCat、Sharding-JDBC等。这些统称为中间件。
二、为什么需要(消息)中间件呢?
像redis、es这些就不说了,都是为了提高搜索速度等,这里重点说一下消息中间件。
在早期all in one的单体系统架构中,其实是不怎么需要消息中间件的,或者说即使单体的系统架构中用到了消息中间件,其实用到的地方也很少。常见的消息中间件有ActiveMQ、RabbitMQ、Kafka、RocketMQ等。消息中间件技术主要用来解决分布式架构系统中的一些问题:
- 跨系统数据传递,降低分布式架构中各个系统之间的耦合性
举例:A 系统发送数据到 BCD 三个系统,通过接口调用发送。如果 E 系统也要这个数据呢?那如果 C 系统现在不需要了呢?A 系统负责人几乎崩溃…
使用消息中间件后:A只需将消息发生到mq中,其他系统去读消息就行了,解除了A直接与其他系统之间的调用耦合
- 异步处理机制,提高用户体验效果。
单体架构中用户发起一个请求所需等待的时间为3+300+450+200=953ms
采用消息中间件后,就可以将请求消息发送到mq中,然后直接返回结果。其他系统去读mq的消息即可,达到异步处理请求的效果。此时只需花费3+5=8ms
- 高并发流量削峰。有时候一个系统中平时每秒并发30个请求,但是在某一时间段并发数量会达到5K以上,此时这5K个请求在同一时刻大量涌入msql,则有可能会导致数据库崩溃。
而此时若引入一个mq中间件,将5K个请求先发送到mq,A系统从mq中再以每秒拉取2K个请求过来处理,这样就大大缓解了数据库的压力。(不用担心mq中会有消息积压的情况,因为这种高并发的每秒5K个请求是短暂的,而A系统一直在以每秒2K个请求的速率拉取,所以只要高峰期一过,消息马上就都会被消费掉,不会积压。)
- 另外消息中间件可以有效的来解决分布式事务的问题,这个在下面分布式事务章节详细说明。
三、消息中间件的几个核心组成部分
由于rabbitMq采用的是AMQP协议,为了更好的去理解AMQP,这里把网络协议这个知识点再重新巩固一下:
网络协议是指计算机网络中进行数据交换而建立的规则、标准或约定的集合。
简单来说就是在整个计算机网络中,各个计算机实体之间要进行通信,由于各个计算机终端的字符集不同,所以两个计算机之间输入的命令也不同,但是为了让两个计算机之间能通信,那么就规定每个计算机都要去实现一套标准的字符集,然后用这套标准的字符集进入网络中进行传输,以达到计算机之间可以通信的目的。
网络协议又分为很多层,不同的层对应不同的协议
以上图为例,我们常说的TCP/UDP 是传输层协议,
TCP也就是最常用的传输层协议,3次握手那一套,特点就是传输可靠,稳定,但是传输慢,效率低。适用场景:文件传输,邮件发送,平常的网络页面的访问等。
UDP的特点就是不用像TCP那样3次握手后建立连接再传输,它的特点就是简单,传输快,但缺点也很明显就是不可靠,不稳定,对网络环境有较大的要求。适用场景,打电话,视频通话等(所以网络不好的时候,打电话会听不清或者视频会卡住)
HTTP协议是应用层协议,也叫超文本传输协议,这个千万不要和TCP/UDP搞混了,这是处于两个不同层面的协议。
举个例子,网络层协议就相当于是公路,传输层协议相当于跑在路上的车,而应用层协议HTTP就是在车里的人。 网络协议的这几层是由下而上层层递进的关系。
AMQP全称:Advanced Message Queuing Protocol(高级消息队列协议)。和http类似也是应用层协议的一个开发标准,为面向消息的中间件设计。一般来说每一个消息中间件都有对应的应用层协议,与AMQP类似的消息传输协议还有:MQTT协议、OpenMessage协议、Kafka协议等。
面试题:为什么消息中间件不直接使用http协议,而要用另一套协议呢?
- 因为http请求报文头和响应报文头是比较复杂的,包含了cookie,数据的加密解密,状态码,响应码等附加的功能,但是对于一个消息而言,我们并不需要这么复杂,也没有这个必要性,它其实就是负责数据传递,存储,分发就行,一定要追求的是高性能。尽量简洁,快速。
- 大部分情况下http大部分都是短链接,在实际的交互过程中,一个请求到响应很有可能会中断,中断以后就不会就行持久化,就会造成请求的丢失。这样就不利于消息中间件的业务场景,因为消息中间件可能是一个长期的获取消息的过程,出现问题和故障要对数据或消息就行持久化等,目的是为了保证消息和数据的高可靠和稳健的运行。
其实这个问题简单来说就是http协议比较复杂,我们消息中间件需要做的只是传递个消息数据,追求的是速度快,高性能。再一个就是http大部分都是短链接,一旦中断,就会造成数据的丢失。消息中间件是一个长期获取消息的过程,出现问题要立即进行消息持久化,要实现高可靠。
消息队列持久化:在服务器重启后,数据不会丢失。和Redis类似,RabbitMQ也是将数据保存在一个文件中,持久化这块就不做过多概述了。(RabbitMQ中持久化前提是你创建交换机或队列的时候要声明持久化)
消息的分发策略:就是消息到达队列中后,消费者如何去拿到消息,用什么方式去消费消息。
注意:这里说的是消息的分发策略,是指的消费者这端怎么去消费队列里的消息。下面讲的MQ的几种工作模式fanout、direct、topic等指的是交换机发送消息到队列中的模式,千万别搞混了!
四、RabbitMQ入门及安装
RabbitMQ是一个开源的遵循AMQP协议实现的基于Erlang语言编写,支持多种客户端(语言)。用于在分布式系统中存储消息,转发消息,具有高可用,高可扩性,易用性等特征。
消息中间件在系统中所处的位置:
RabbitMQ安装步骤(以下是在Linux上的安装):
RabbitMQ是采用Erlang语言开发的,所以必须提供Erlang环境,首先安装erlang。安装前要注意erlang的版本要和rabbitmq的版本对应,不对应的话后期会出现不兼容的问题,这个对应的关系表可以在rabbitmq官网上查到:https://www.rabbitmq.com/which-erlang.html
由于官网下载速度慢,这里先在本地下载好rpm包后,拷贝到linux系统中
- rpm -Uvh erlang-solutions-2.0-1.noarch.rpm
- yum install -y erlang
- erl -v 查看erlang版本,出现版本,表示安装erlang成功!
安装socat: 因为rabbitmq需要这个插件
- yum install -y socat
安装rabbitmq:
- rpm -Uvh rabbitmq-server-3.8.13-1.el8.noarch.rpm 这一个命令就安装好了
- systemctl start rabbitmq-server 启动rabbitmq
- systemctl status rabbitmq-server 查看rabbitmq的状态,显示active启动成功!
默认情况下,rabbitmq没有安装图形化管理界面,我们需要手动安装:
- rabbitmq-plugins enable rabbitmq_management 安装web管理插件
- systemctl restart rabbitmq-server 安装后重启服务即可
web管理插件的端口为15672,浏览器输入ip+端口号访问即可
注意:rabbitmq有一个默认的账号:guest/guest ,但这个账号只能在localhost本机上访问,由于我们rabbitmq是在远程服务器上安装的,所以需要添加一个可远程登录的用户。
- rabbitmqctl add_user admin admin 新增用户
- rabbitmqctl set_user_tags admin administrator 设置用户权限(administrator 最高级别,相当于root用户)
用户权限有以下级别:
- administrator 可以登录控制台、查看所有信息、可以对rabbitmq进行管理
- monitoring 监控者 登录控制台,查看所有信息
- policymaker 策略制定者 登录控制台,指定策略
- managment 普通管理员 登录控制台
登录后,出现以下界面:
以上就是rabbitMQ的大致整个安装过程了,下面就开始RabbitMQ的学习吧。
五、RabbitMQ的几种工作模式
从官网的介绍看一共有以下几种模式:
由于第6,7种模式用到的很少,我们只讨论前面5种模式。
Simple 简单模式
这是几种工作模式中最简单的一个模式,就是生产者生产消息到一条指定的队列,消费者去消费这条消息。但是需要注意的是,这并不是没有交换机,要知道所有的队列接受消息都是由生产者先发生给交换机,交换机再发生给队列的,上图没有交换机是因为使用的是默认的交换机。
下面用最原生的代码的方式来实现simple模式的发送消息和消费消息的整个过程:
public static void main(String[] args) {
// 1. 创建一个连接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
// 2. 设置连接属性
connectionFactory.setHost("139.196.255.42");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/hhl");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
Connection connection = null;
Channel channel = null;
// 3. 从工厂中获取连接
try {
connection = connectionFactory.newConnection("生产者");
// 4. 从连接中获取通道,rabbitmq都是通过信道在操作的,后面详细说明为什么
channel = connection.createChannel();
// 5. 创建队列Queue
/*
* 如果队列不存在,则会创建
* Rabbitmq不允许创建两个相同的队列名称,否则会报错。
*
* @params1: queue 队列的名称
* @params2: durable 队列是否持久化
* @params3: exclusive 是否排他,即是否私有的,如果为true,会对当前队列加锁,其他的通道不能访问,并且连接自动关闭
* @params4: autoDelete 是否自动删除,当最后一个消费者断开连接之后是否自动删除消息。
* @params5: arguments 可以设置队列附加参数,设置队列的有效期,消息的最大长度,队列的消息生命周期等等。
* */
channel.queueDeclare("queue1",false,false,false,null);
// 6. 创建要发送的消息
String message = "Hello Simple !";
// 7. 发送消息到rabbitmq-server
// @params1: 交换机exchange (交换机名称,为空不是没有交换机,而是相当于使用默认交换机)
// @params2: 队列名称/routing(若指定交换机了,这个参数就是路由;若没指定交换机是空的(那就是用默认的交换机),这个参数就是队列名称)
// @params3: 属性配置 (也是一些条件,比如headers类型的交换机就是专门用属性配置的)
// @params4: 发送消息的内容
// 一般来说交换机在实际开发中是必须要指定的!
channel.basicPublish("","queue1",null,message.getBytes());
System.out.println("消息发送成功!");
} catch (Exception e) {
e.printStackTrace();
}finally {
if(channel!=null && channel.isOpen()){
try {
channel.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if(connection!=null){
try {
connection.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
登录rabbitmq管理界面查看,消息发送成功,已存入队列queue1
下面进行消费者的代码创建,并消费消息
public static void main(String[] args) {
// 1. 创建一个连接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
// 2. 设置连接属性
connectionFactory.setHost("139.196.255.42");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/hhl");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
Connection connection = null;
Channel channel = null;
try {
// 3. 从工厂中获取连接
connection = connectionFactory.newConnection("消费者");
// 4. 从连接中获取通道
channel = connection.createChannel();
channel.basicConsume("queue1", true, new DeliverCallback() {
@Override
public void handle(String s, Delivery delivery) throws IOException {
try {
System.out.println("收到队列queue1的消息:" + new String(delivery.getBody(), "UTF-8"));
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
}, new CancelCallback() {
@Override
public void handle(String s) throws IOException {
System.out.println("接受消息异常...");
}
});
System.out.println("开始接收消息...");
System.in.read();
} catch (Exception e) {
e.printStackTrace();
}finally {
if(channel!=null && channel.isOpen()){
try {
channel.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if(connection!=null){
try {
connection.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
消息被成功消费:
以上就是simple模式的简单创建。
Work模式:主要分为轮询和公平分发两个模式
由上图就可以看到,这模式主要是针对一个队列有多个消费者时,多个消费者之间如何去消费同一个队列里的消息。其实这个模式较为特殊,其他模式都是说的交换机向队列发送消息的机制,work模式说的主要是多个消费者如何消费同一个队列的消息的机制,这个概念一定一定要清除。
首先我们要明确的一点是,一条消息只能被一个消费者消费一次,不能说一条消息同时被两个消费者或多个消费者同时消费。
所以问题就来了,比如一个队列里有10条消息,有两个消费者同时在监听这个队列,那么这10条消息会如何分配到这两个消费者中呢?
这就用到了work模式,有两种消费模式,首先先来说轮询模式:
轮询模式就是说10条消息一条一条的分配到两个消费者中,每个人各5条。轮询模式也是rabbitmq中有多个消费者时候的一种默认的消费模式。
下面上代码,生产者就不上了,生产者就是发送了10条消息到队列中:
消费者C1
// 消费者1
public static void main(String[] args) {
// 1. 创建一个连接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
// 2. 设置连接属性
connectionFactory.setHost("139.196.255.42");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/hhl");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
Connection connection = null;
Channel channel = null;
try {
// 3. 从工厂中获取连接
connection = connectionFactory.newConnection("消费者");
// 4. 从连接中获取通道
channel = connection.createChannel();
// 轮询分发不用设置什么,默认就是轮询分发 @param2 true就是自动应答
channel.basicConsume("queue1", true, new DeliverCallback() {
@Override
public void handle(String s, Delivery delivery) throws IOException {
try {
System.out.println("收到队列的消息:" + new String(delivery.getBody(), "UTF-8"));
Thread.sleep(100); // 轮询模式和性能无关,所以这里不管是设置多久,都会均衡的消费到相同的消息数目
} catch (Exception e) {
e.printStackTrace();
}
}
}, new CancelCallback() {
@Override
public void handle(String s) throws IOException {
System.out.println("接受消息异常...");
}
});
System.out.println("开始接收消息...");
System.in.read();
} catch (Exception e) {
e.printStackTrace();
}finally {
if(channel!=null && channel.isOpen()){
try {
channel.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if(connection!=null){
try {
connection.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
消费者C2
public static void main(String[] args) {
// 1. 创建一个连接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
// 2. 设置连接属性
connectionFactory.setHost("139.196.255.42");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/hhl");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
Connection connection = null;
Channel channel = null;
try {
// 3. 从工厂中获取连接
connection = connectionFactory.newConnection("消费者");
// 4. 从连接中获取通道
channel = connection.createChannel();
channel.basicConsume("queue1", true, new DeliverCallback() {
@Override
public void handle(String s, Delivery delivery) throws IOException {
try {
System.out.println("收到队列的消息:" + new String(delivery.getBody(), "UTF-8"));
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
}
}, new CancelCallback() {
@Override
public void handle(String s) throws IOException {
System.out.println("接受消息异常...");
}
});
System.out.println("开始接收消息...");
System.in.read();
} catch (Exception e) {
e.printStackTrace();
}finally {
if(channel!=null && channel.isOpen()){
try {
channel.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if(connection!=null){
try {
connection.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
同时启动消费者C1 C2,查看控制台消费情况:
可以看到,两个消费者并没有因为消费消息所需时间的长短而拿到的消息数量不同,就是一个挨着一个的拿消息消费。
公平模式:上面的轮询模式存在一定弊端,若两台消费者机器性能、CPU、硬件或者网络不同,我们不应该让它们去拿到相同数量的消息,而应该性能好的多消费一些,性能差的少消费一些,这也就是公平模式了。
由于轮询模式是默认的,所以公平模式就需要一些配置来去开启公平模式,主要就是在消费者这段的一些配置,需要关闭自动应答,开启手动应答。
public static void main(String[] args) {
// 1. 创建一个连接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
// 2. 设置连接属性
connectionFactory.setHost("139.196.255.42");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/hhl");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
Connection connection = null;
Channel channel = null;
try {
// 3. 从工厂中获取连接
connection = connectionFactory.newConnection("消费者");
// 4. 从连接中获取通道
channel = connection.createChannel();
Channel finalChannel = channel;
// 公平分发需要注意的就是abc这3点
// a. qos指标定义出来,一次读取几条消息,这个值要根据内存、CPU、消息总条数等因素综合来考虑
finalChannel.basicQos(1);
// b. @param2设置为false 表示关闭自动应答
finalChannel.basicConsume("queue1", false, new DeliverCallback() {
@Override
public void handle(String s, Delivery delivery) throws IOException {
try {
System.out.println("收到队列的消息:" + new String(delivery.getBody(), "UTF-8"));
通过休眠时间的不同,来模拟性能好与差的机器。也就是谁的性能好处理的快,谁就消费的消息多
Thread.sleep(100);
// c. 开启手动应答
finalChannel.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
} catch (Exception e) {
e.printStackTrace();
}
}
}, new CancelCallback() {
@Override
public void handle(String s) throws IOException {
System.out.println("接受消息异常...");
}
});
System.out.println("开始接收消息...");
System.in.read();
} catch (Exception e) {
e.printStackTrace();
}finally {
if(channel!=null && channel.isOpen()){
try {
channel.close();
} catch (Exception e) {
e.printStackTrace();
}
}
if(connection!=null){
try {
connection.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
查看控制台输出:
可以看到,由于消费者1的休眠时间短,所以它拿到的消息就多有8条,消费者2拿到的消息只有2条,这也就是公平模式,性能好的自然就消费的消息就多。
Fanout模式:(也叫发布订阅模式,广播模式)
从上图可以看到与上面simple和work模式不同的是,这个里出现交换机了,也就是表明这个模式需要指定交换机了,而不用默认的交换机了(不画交换机的不是没交换机,而是使用默认交换机)。
注意:fanout模式,包括下面的routing模式,topics模式,都是说的交换机发送消息到队列的这个机制,要和上面的work模式区别开!!!
这个模式中,我们要先创建交换机、队列,然后绑定交换机和队列的关系(这个过程我们在rabbitmq的web界面中完成,之后springboot整合rabbitmq时会在代码中实现),生产者发送消息到交换机即可,交换机会根据不同的模式发送消息到指定的队列中。
生产者代码:(其他代码都一样,这里就只展示不一样的地方)
// 7. 发送消息到rabbitmq-server
String exchange="fanout_exchange"; // 指定交换机名称,使用fanout类型的交换机
String routing_Key=""; // 路由key,fanout交换机是发给所有的绑定的队列,所以这里指定路由是无意义的
// 其实这几种消息模式的区别就在这个方法上,这个交换机、路由上
channel.basicPublish(exchange,routing_Key,null,message.getBytes());
System.out.println("消息发送成功!");
比如交换机fanout_exchange绑定了3个队列queue1、queue2、queue3,那么就会将这同一条消息一下子发送到这3个队列中。
消费者就不展示了,就是几个队列里都有消息, 去消费就行了。
Direct模式:(路由模式)
简单来说就是在fanout模式上加个where的条件,每个队列都有一个路由key,direct交换机在发送消息时加上一个路由key的条件,那这条消息就会发送到满足这个key的队列上去。
// 5. 创建队列Queue(交换机和队列和互相之间绑定的关系已在rabbitmq控制台创建好了,这里省略)
// *******************用代码创建交换机和队列并互相绑定 start ******************************
// 创建交换机 @params1 交换机名称 @params2 交换机类型 @params3 是否持久化
channel.exchangeDeclare("direct_exchange","direct",true);
// 创建队列
channel.queueDeclare("queue5",true,false,false,null);
channel.queueDeclare("queue6",true,false,false,null);
channel.queueDeclare("queue7",true,false,false,null);
// 绑定队列和交换机的关系 @params1 队列名称 @params2 交换机名称 @params3 队列所对应的路由key
channel.queueBind("queue5","direct_exchange","email");
channel.queueBind("queue6","direct_exchange","sms");
channel.queueBind("queue7","direct_exchange","wechat");
// *******************用代码创建交换机和队列并互相绑定 end ******************************
// 6. 创建要发送的消息
String message = "Hello Direct !";
// 7. 发送消息到rabbitmq-server
String exchange="direct_exchange"; // 指定交换机名称
String routing_Key1="email"; // 路由key
String routing_Key2="sms";
String routing_Key3="wechat";
// 其实这几种消息模式的区别就在这个方法上,这个交换机、路由上
channel.basicPublish(exchange,routing_Key1,null,message.getBytes());
channel.basicPublish(exchange,routing_Key2,null,message.getBytes());
channel.basicPublish(exchange,routing_Key3,null,message.getBytes());
System.out.println("消息发送成功!");
Topics:(主题模式)
topics模式也就是通配符模式,和路由模式不同的是,路由模式相当于where后面是=的条件,topic模式就是把=换成like,和模糊匹配类似。
// 7. 发送消息到rabbitmq-server
String exchange="topic_exchange"; // 指定交换机名称
String routing_Key="com.user.order"; // 路由key, 和direct类似,topic这里就是换成了规则
// 其实这几种消息模式的区别就在这个方法上,这个交换机、路由上
channel.basicPublish(exchange,routing_Key,null,message.getBytes());
System.out.println("消息发送成功!");
需要注意的是通配符#和*的区别:
#:代表可以是一个多个或无字符
*:代表有且只有一个字符(必须有,且只能是一个字符)
六、SpringBoot集成RabbitMQ
在实际开发中我们肯定是在springboot项目中去使用rabbitmq,下面来介绍一下rabbitmq在springboot项目中如何使用:
创建springboot项目,引入相应依赖
<!-- rabbitMQ依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
编写配置文件:
server:
port: 8080
spring:
rabbitmq:
username: admin
password: admin
# 这个其实就是用来区别的,就好比一个mysql下可以建立多个数据库,一个rabbitmq下也可以有多个主机,每个主机下面有对应的交换机和队列
virtual-host: /hhl
host: 000.000.000.00
port: 5672
以direct模式为例:
- 创建一个rabbitmq的配置类,在配置类中创建交换机、队列、并绑定它们之间的关系
/**
* 此配置类用于
* 1.创建队列
* 2.创建交换机
* 3.绑定队列与交换机之间的关系
*/
@Configuration
public class RabbitConfig {
// 创建队列
@Bean
public Queue emailQueue(){
return new Queue("email.direct.queue",true);
}
@Bean
public Queue smsQueue(){
return new Queue("sms.direct.queue",true);
}
@Bean
public Queue wechatQueue(){
return new Queue("wechat.direct.queue",true);
}
// 创建交换机
@Bean
public DirectExchange directOrderExchange(){
return new DirectExchange("direct_order_exchange",true,false);
}
// 绑定交换机与队列
@Bean
public Binding bindingFanout1(){
return BindingBuilder.bind(emailQueue()).to(directOrderExchange()).with("email");
}
@Bean
public Binding bindingFanout2(){
return BindingBuilder.bind(smsQueue()).to(directOrderExchange()).with("sms");
}
@Bean
public Binding bindingFanout3(){
return BindingBuilder.bind(wechatQueue()).to(directOrderExchange()).with("wechat");
}
}
- 生产者发送消息
@Autowired
private RabbitTemplate rabbitTemplate;
private String exchangeName = "direct_order_exchange";
private String routingKey1 = "email";
private String routingKey2 = "sms";
private String routingKey3 = "wechat";
public void addOrder(Long userID, Long productID, int num){
String msg = "ID为"+userID+"的用户,下单成功!商品id为"+productID+",数量为"+num+"个,订单编号为:"+ UUID.randomUUID().toString();
rabbitTemplate.convertAndSend(exchangeName,routingKey1,msg);
rabbitTemplate.convertAndSend(exchangeName,routingKey2,msg);
rabbitTemplate.convertAndSend(exchangeName,routingKey3,msg);
}
- 消费者消费消息
@Component
public class ConsumerService {
// 这里只需用@RabbitListener注解配置监听的队列即可,队列中有消息后会立马消费
@RabbitListener(queues = {"email.direct.queue"})
public void getEmailQueueMessage(String message){
System.out.println("email消费了消息:"+message);
}
@RabbitListener(queues = {"sms.direct.queue"})
public void getSmsQueueMessage(String message){
System.out.println("sms消费了消息:"+message);
}
//也可以在消费者端创建 队列 交换机 ,并绑定关系,下面是用注解的方式来执行这些动作,当然用配置类的那种更好点
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "email.topic.queue",autoDelete = "false"),
exchange = @Exchange(value = "topic.order.exchange",type = ExchangeTypes.TOPIC),
key = "#.email.*"
))
public void getMessage(String message){
System.out.println("wechat消费了消息:"+message);
}
}
七、RabbitMQ图形化管理Web界面的内容详解
概览页面:
交换机页面:
交换机详情页面:
队列页面:
队列详情页面:
Admin页面:
八、RabbitMQ高级
上面的内容是rabbitmq最基础的几个工作模式和使用步骤,下面开始进行一些RabbitMQ中另一些高级的知识,这些内容也很重要!
消息确认机制:生产者发送一条消息到broker后,消息到底有没有被broker成功接收呢,或者说消息到底有没有发送成功?这就需要用到消息确认机制,就是生产者发送消息后,broker给生产者一个反馈到底有没有成功接收到消息。
实现步骤:
rabbitmq新增一行配置:
spring:
rabbitmq:
username: admin
password: admin
virtual-host: /hhl
host: 139.196.255.42
port: 5672
publisher-confirm-type: correlated # 就是这行配置
publisher-confirm-type 是发布确认属性的配置,有以下几个值:
- NONE :禁用,默认是禁用的
- CORRELATED :发布消息成功到交换器后会触发回调方法,也就是开启消息确认机制
- SIMPLE:经测试有两种效果,其一效果和CORRELATED值一样会触发回调方法,其二在发布消息成功后使用rabbitTemplate调用waitForConfirms或waitForConfirmsOrDie方法等待broker节点返回发送结果,根据返回结果来判定下一步的逻辑,要注意的点是waitForConfirmsOrDie方法如果返回false则会关闭channel,则接下来无法发送消息到broker
定义一个类,实现RabbitTemplate的回调函数接口,也就是RabbitTemplate在执行发送消息的方法后会进行回调这个方法,以达到确认消息是否发送成功的目的。
public class MessageConfirmCallback implements RabbitTemplate.ConfirmCallback{
@Override
public void confirm(CorrelationData correlationData, boolean b, String s) {
if(b){
System.out.println("消息发送成功!");
}else{
System.out.println("消息发送失败!");
}
}
}
发送消息:
public void makeOrderTopic(){
String orderId = UUID.randomUUID().toString();
// 发送消息
// 设置消息确认机制
rabbitTemplate.setConfirmCallback(new MessageConfirmCallback());
rabbitTemplate.convertAndSend("direct_order_exchange","email",orderId);
}
发送成功后触发回调函数:
过期时间(TTL):
RabbitMQ可以为队列和消息分别设置过期时间
设置队列过期时间:很简单,就是在创建队列的时候加个参数x-message-ttl,设置后消息达到队列后未在指定过期时间内消费,消息会自动删除。
@Configuration
public class TTLRabbitConfig {
@Bean
public Queue ttlQueue(){
Map<String,Object> args = new HashMap<>();
// 设置ttl队列过期,消息到达9秒后过期, 只需加这个参数就可以
args.put("x-message-ttl",9000);
return new Queue("ttl_direct_queue",true,false,false,args);
}
}
设置消息过期时间:
public void addMessageTTL(){
// 设置某一条消息的过期时间
MessagePostProcessor messagePostProcessor = new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
message.getMessageProperties().setExpiration("10000"); // 设置消息10秒后过期
message.getMessageProperties().setContentType("UTF-8");
return message;
}
};
rabbitTemplate.convertAndSend("fanout_order_exchange","","hello ttlMessage",messagePostProcessor);
}
注意: 当队列和消息同时都设置了过期时间,那么会以这两个里较短的那个过期时间为准,也就是 若队列设置了10秒过期,而一条消息设置了5秒过期,那么这条消息放入队列中则会5秒后就过期
死信队列(DLX): Dead-Letter-Exchange
从上面的过期队列可以引出一个问题,队列里的消息过期后直接删除这种方式太过暴力,删除后就找不到了,所以我们可以声明一个交换机来专门接收这些过期的消息,那这个交换机就称为死信交换机,该交换机下绑定的队列就称为死信队列。
@Configuration
public class TTLRabbitConfig {
@Bean
public Queue ttlQueue(){
Map<String,Object> args = new HashMap<>();
// 设置ttl队列过期,消息到达9秒后过期, 只需加这个参数就可以
args.put("x-message-ttl",9000);
// 指定死信队列,就是消息过期后我要把过期的消息发送到哪个交换机上,被指定的交换机会再把消息发送到队列(死信队列)
args.put("x-dead-letter-exchange","dead_direct_exchange");
// 由于被指定的交换机是direct类型的,所以这里还要配置路由。当然如果是fanout模式的这里就不用配置了
args.put("x-dead-letter-routing-key","dead");
return new Queue("ttl_direct_queue",true,false,false,args);
}
@Bean
public DirectExchange directOrderExchange2(){
return new DirectExchange("ttl_direct_exchange",true,false);
}
@Bean
public Binding bindingttl(){
return BindingBuilder.bind(ttlQueue()).to(directOrderExchange2()).with("ttl");
}
//注意: 下面的死信队列和死信交换机就是普通的队列和交换机,
// 只不过其他队列里的消息过期后指定了要把过期消息放到下面这个交换机中,再由此交换机发到队列,
// 所以就称之为死信队列和交换机,其实就是普通的交换机,重点在上面的队列指定死信队列的配置那里
// 创建一个死信队列
@Bean
public Queue deadQueue(){
return new Queue("dead_direct_queue",true,false,false);
}
// 创建死信交换机
@Bean
public DirectExchange directDeadExchange(){
return new DirectExchange("dead_direct_exchange",true,false);
}
// 互相绑定
@Bean
public Binding bindingDead(){
return BindingBuilder.bind(deadQueue()).to(directDeadExchange()).with("dead");
}
}
注意:死信交换机和死信队列就是普通的交换机和队列,上面代码中的注释写的很清楚。
什么情况下消息会进入死信队列?
- 消息被拒绝
- 消息过期
- 队列达到最大长度
分布式事务
分布式事务是分布式架构系统中必须面临的要解决的一个问题,也是面试最常问到的问题。分布式事务的解决办法基本上有以下几种:
- 两阶段提交(2PC)需要数据库产商的支持,java组件有atomikos等。
- 补偿事务(TCC) 严选,阿里,蚂蚁金服。
- 本地消息表(异步确保)比如:支付宝、微信支付主动查询支付状态,对账单的形式
- MQ 事务消息 异步场景,通用性较强,拓展性较高。
这里主要是讲解用消息中间件RabbitMQ来如何解决分布式事务的问题,这个应该也是最常用到的来解决分布式事务的方法吧~
什么是分布式事务?
简单来说就是在分布式系统中,一个操作可能要调用多个服务,这些服务又处在不同的服务器上,每个服务器又有对应的不同的数据库,分布式事务就是保证一个操作下来,所有需调用的服务要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
举个例子,比如下订单的操作,订单系统和库存系统是两个不同的系统,下订单是一个操作,这个操作里需要在订单系统中新增一条订单,库存系统中减少一条库存,那么这两个步骤必须要么一起成功要么一起失败,不能一个成功一个失败,这也就是分布式事务要解决的问题。
下面通过一个图来展示分布式事务所要解决的问题:
基于MQ来解决分布式事务问题整体设计思路:
由上图可以看到,MQ解决分布式事务的关键就是在两个系统之间增加MQ中间件。
由于两个系统之间加上了中间件,那么我们就需要考虑消息的发送和消费高可靠问题,我们先来看消息的生产高可靠如何解决:
大致的思路就是,在订单系统中下订单后,订单表肯定新增一条订单记录,然后我们需要发送新生成一条订单的消息到MQ中,而此时我们不知道消息是否发送成功,假设消息发送失败了我们不去处理,那MQ都没有消息更别谈配送中心了,它更不知道已经新增了一条订单了。
所以我们首先要保证MQ必须接收到这条新订单已生成的这条消息,那解决方案就是利用一张冗余表和上面提到过的消息应答机制相结合。
在订单表生成新订单记录后,同时向冗余表也生成一条新订单记录,这个冗余表的订单记录有个状态字段status默认是0,也就是记录消息是否发送成功的字段,当消息发送到MQ后,MQ通过应答机制回应给订单系统消息接收成功,那么就把这个字段status改成1,表示信息已成功发送到MQ中。
若MQ接收消息失败,那就不改变这个值,可以在系统中定时去查这个冗余表中status=0的记录,这些都是消息没发送成功了,查到后重发,通过这种方式来达到消息生产高可靠。
解决了消息生成高可靠,下面就要再来解决消息消费高可靠的问题:
消费者拿到消息后去执行对应的业务逻辑,比如还是上面的例子,订单中心成功把订单消息发送到MQ,消费者监听MQ中的队列,拿到消息进行消费完成配送中心的逻辑,当然这是正常情况下。但是如果消费者在消费的业务逻辑中代码出现问题报错了,此时会发生什么问题呢?消息会丢失还是会一直发呢?这也就是消费高可靠问题:
模拟一个错误:
@RabbitListener(queues = {"email.direct.queue"})
public void getEmailQueueMessage(String message){
System.out.println("email消费了消息开始");
System.out.println(1/0);// 手动制造异常
System.out.println("email消费了消息:"+message);
}
经测试发现,消费者消费消息报错后,会触发消息重试机制,在不配置重试的情况下,会造成死循环一直触发消费消息的代码循环。
解决死循环的方法大致有以下几种:
- 控制重试次数+死信队列
rabbitmq:
port: 5672
host: 00.000.000.00
username: admin
password: admin
virtual-host: /
listener:
simple:
acknowledge-mode: manual # 这里是开启手动ack,让程序去控制MQ的消息的重发和删除和转移
retry:
enabled: true # 开启重试
max-attempts: 3 #最大重试次数
initial-interval: 2000ms #重试间隔时间
- try+catch+手动ack
- try+catch+手动ack+死信队列+人工干预
上面的方法其实也就是用来解决消费高可靠问题的方法,只不过最好的是第3种,下面详细讲解第3中方案。
@RabbitListener(queues = {"order.queue"})
// 参数中的tag是一条消息的唯一标识,可以当做消息的id
public void messageconsumer(String ordermsg, Channel channel,
CorrelationData correlationData,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception {
try {
System.out.println("收到MQ的消息是: " + ordermsg + ",count = " + count++);
Order order = JsonUtil.string2Obj(ordermsg, Order.class);
String orderId = order.getOrderId();
// 4:保存运单(这里会涉及到幂等性问题,就是消息消费失败后会进行重试,那像下面service中的方法就会一直循环执行,
//那我们就需要考虑多次循环这个方法不能让它每次循环都保存了订单数据吧,比如可以给order表设置主键id,或者分布式锁的方式,来解决这个幂等性问题)
dispatchService.dispatch(orderId);
System.out.println(1 / 0); //出现异常
// 5:手动ack告诉mq消息已经正常消费,注意这里是basicAck,正常消费后的应答
channel.basicAck(tag, false);
} catch (Exception ex) {
// 捕获异常后要也要进行应答,这里是basicNack的方法来应答
//@param1:消息的tag @param2:false 多条处理 @param3:requeue 是否重发
// 参数3 false 不会重发,会把消息打入到死信队列
// 参数3 true 会重发,也就是陷入死循环了(注意,此时重发次数的配置会失效)
channel.basicNack(tag, false, false);// 死信队列
}
}
消费出错后手动应答,把消息打入死信队列中,再来一个消费者去监听私信队列
@RabbitListener(queues = {"dead.order.queue"})
public void messageconsumer(String ordermsg, Channel channel,
CorrelationData correlationData,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception {
try {
// 1:获取消息队列的消息
System.out.println("收到MQ的消息是: " + ordermsg );
// 2: 获取订单服务的信息
Order order = JsonUtil.string2Obj(ordermsg, Order.class);
// 3: 获取订单id
String orderId = order.getOrderId();
// 幂等性问题
//int count = countOrderById(orderId);
// 4:保存运单
//if(count==0)dispatchService.dispatch(orderId);
//if(count>0)dispatchService.updateDispatch(orderId);
dispatchService.dispatch(orderId);
// 3:手动ack告诉mq消息已经正常消费
channel.basicAck(tag, false);
} catch (Exception ex) {
// 若死信队列的处理逻辑中还是报错,那就要进行人工干预了
System.out.println("人工干预");
System.out.println("发短信预警");
System.out.println("同时把消息转移别的存储DB");
channel.basicNack(tag, false,false);
}
}
以上就是try+catch+手动ack+死信队列+人工干预这种方式来解决消费高可靠问题的代码实现,其大致流程图如下:
以上就是用MQ来解决分布式事务的方法,其重点就是在生产者生产消息高可靠和消费者消费消息高可靠两个方面做处理。
这种基于MQ来解决分布式事务问题的方法有以下优缺点:
优点:
- 通用性强
- 拓展方便
- 耦合度低,方案也比较成熟
缺点:
- 基于消息中间件,只适合异步场景
- 消息会延迟处理,需要业务上能够容忍
其实总得来说在实际开发中我们应该尽量去避免分布式事务,因为再好的处理方法也不能保证百分之百的不出错。
九、RabbitMQ集群架构
对于一个中间件而言,集群是必须要学习与掌握的一个点,任何中间件在生产环境中都不会做单点部署,肯定都是集群,所以对于rabbitmq的集群工作原理要做个了解。
首先说一下,RabbitMQ的集群原理和RocketMQ、Kafka的集群不太一样,不一样的点主要在于比如Kafka的集群是基于Partition分区维度的,Rocketmq的集群是基于broker消息队列维度的,它们这么做的目的是为了保证了消息的分布式存储,以达到一种更高的吞吐量。
RabbitMQ有两种集群模式,普通集群和镜像集群,普通集群虽然有点分布式存储的样子,但又不能算真正的分布式存储,因为在读取数据的时候还是要将数据先进行同步,而镜像集群就是各个节点数据直接同步,每个节点都存储了全量数据。也正是因为Rabbitmq的这个原因,导致它的吞吐量没有Rocketmq或kafka高。下面来看一下RabbitMQ的这两种集群模式:
普通集群:
如图,RabbitMQ的普通集群工作原理就是,首先每个节点上都存储了相同的元数据信息,这些元数据信息包括交换机信息,每个消息队列在哪个节点上的元数据信息。但注意,普通集群模式下,真正存储数据的消息队列是分布在各个节点上的,比如当客户发送请求到节点2上需要读取队列1的数据时,此时节点2会和节点1直接进行通信,把节点1的队列1中的消息传输复制到节点2中,然后再返回给客户端。(虽然普通集群将消息分布到各个节点存放了,但读取消息时还要进行消息复制同步,等于还是没有真正做到分布式存储啊!)
普通集群模式下有这么几个弊端:
- 不支持高可用,某一节点宕机后,需要手动重启服务
- 节点宕机后,该节点上的数据无法进行消费,必须等到该节点人工恢复后才能运行。不像rocketmq的主broker挂掉从broker上有数据可顶上去,或者kafka的分区,主分区节点宕机,存放副分区的节点可顶上去。
- 这种消息在节点间来回传输复制会影响性能。
镜像集群:
RabbitMQ镜像集群是在普通集群的基础上做了改进,普通集群上每个节点存储的消息数据不同,当从某个节点读取消息时可能会发生数据复制与传输的效率问题,且普通集群中一个节点挂掉后该节点上的数据也暂时不能被读取无法工作。
镜像集群就是集群中的各个节点之间会进行消息的主动同步,每个节点上都存储了所有的全量数据(其实就是变成普通的主从架构了),也正因为每个节点上存储了全量数据,当主master节点宕机后会在剩下的slave节点中自动选取出一个新的master,提升了集群的高可用。
但也正因为镜像集群中每个节点存储了全量数据,所以它的缺点也很明显,当消息数据增多时,数据没有分布式存储单机器容量有限制,集群内部节点之间进行消息同步也要耗费大量的性能。
综上其实我们也就能得出为什么RabbitMQ的吞吐量没法和RocketMQ或者kafka相比了,主要就是RabbitMQ的集群模式下消息数据不能分布式存储还有数据同步这些相关的问题。