一、什么是消息中间件?
1、什么是消息中间件?
在分布式项目中,一个系统 A (消费者),调用另一个系统 B (提供者)去向用户发送一些成功提示消息(下单成功等)。如果我们直接让 A 去调用 B,那么会存在耦合性的问题,系统的性能也会收到局限
业务场景说明:
像这样的消息中间件(也叫消息队列)在大型电子商务类网站,如京东、淘宝、去哪儿等网站有着深入的应用,为什么会产生消息队列?有几个原因:
- 不同进程(process)之间传递消息时,两个进程之间耦合程度过高,改动一个进程,引发必须修改另一个进程,为了隔离这两个进程,在两进程间在添加一层(一个模块),所有两进程之间传递的消息,都必须通过消息队列来传递,单独修改某一个进程,不会影响另一个;这就是降低耦合度
- 不同进程(process)之间传递消息时,为了实现标准化,将消息的格式规范化了,并且,某一个进程接受的消息太多,一下子无法处理完,并且也有先后顺序,必须对收到的消息进行排队,因此诞生了事实上的消息队列;这就是对系统进行消峰处理
- 在项目中,可将一些无需即时返回且耗时的操作提取出来,进行异步处理,而这种异步处理的方式大大的节省了服务器的请求响应时间,从而提高了系统的吞吐量。
2、什么是消息队列?
- 消息队列(MQ Message Queue)就是这样的一款消息中间件的系统,它是一种应用程序对应用程序的通信方法
- 消息队列就是数据结构中的 “先进先出” 队列,消息进入 MQ 后会排队等待订阅它的系统来处理它
- 消息传递:指的是程序之间通过消息发送数据j进行通信(上图第二种),而不是通过彼此之间调用来通信(上图第一种)
- 队列的主要作用是消除高并发访问高峰(消峰),加快网站的响应速度。
2.1、消息队列的三大作用
解耦服务、异步处理、流量消峰
1. 应用解耦
传统模式
传统模式下,系统间的耦合性太强;
中间件模式
中间件模式的优点:
- 将消息写入消息队列,需要消息的系统可以自己从消息中间件中订阅,无需消息发布者做任何修改 (发布订阅者模式)
2. 异步处理
场景说明:用户注册后,需要发注册消息和注册短信给用户,传统的方法有两种:
- 串行的方法
- 并行的方法
1)串行方式
将注册信息写入数据库后,发送注册邮件,再发送注册短信,以上三个任务全部完成后才返回给客户端。
这有一个问题是,邮件,短信并不是必须的,它只是一个通知,而这种做法让客户端等待没有必要等待的东西。
2)并行方式
将注册信息写入数据库后,发送邮件的同时,发送短信,以上三个任务完成后,返回给客户端,并行的方式能提高处理的时间。但是顾客还是多等了无关紧要的 50ms
3)消息队列
引入消息队列后,把发送邮件,短信不是必须的业务逻辑异步处理
这样可以看出,引入消息队列后,用户的响应时间就等于写入数据库的时间+写入消息队列的时间(可以忽略不计);
引入消息队列后处理后,响应时间是串行的3分之1,是并行的2分之1。
总之: 中间件模式将一些非必要的业务,以异步的方式执行,加快响应速度
3. 流量消峰
流量消峰一般在秒杀活动中应用广泛。
场景: 秒杀活动,一般会应为流量过大,导致服务挂掉。为了解决这个问题,一般在应用前端加入消息队列。
传统模式
如订单系统,在下单的时候就会向数据库中写入数据,但是数据库只能支撑 1000/s 左右的并发操作,并发量再高就会容易宕机。在高峰期的时候,并发量可能会突然激增,达到数据库承载极限,这个时候数据库可能就会卡死。
中间件模式
前端发送过来的数据被 消息中间件保留下来,通过消息队列一点一点(1000/s)的被后端服务订阅处理,这样中间件就会起到缓冲(消峰)的作用了
MQ:帮我们处理了流量洪峰,保护了系统 A
中间模式的优点
- 系统按照数据库能够处理的并发量(1000/s)慢慢地从消息队列中拉取消息。在生产中,短暂的高峰期积压是被允许的
- 流量消峰也叫做消峰填谷
- 使用了MQ之后,限制消费消息的速度为1000,但是这样一来,高峰期产生的数据势必会被积压在MQ中,高峰就被“削”掉了。但是因为消息积压,在高峰期过后的一段时间内,消费消息的速度还是会维持在1000QPS,直到消费完积压的消息,这就叫做“填谷”
2.2、AMQP 和 JMS
MQ是消息通信的模型;实现MQ的大致有两种主流方式:AMQP、JMS。
- AMQP 是一种高级消息队列协议(Advanced Message Queuing Protocol),更准确的说是一种 binary wire-level protocol(链接协议)。这是其和 JMS 的本质差别,AMQP 不从 API 层进行限定,而是直接定义网络交换的数据格式。
- JMS 即 Java 消息服务(JavaMessage Service)应用程序接口,是一个 Java 平台中关于面向消息中间件(MOM)的 API,用于在两个应用程序之间,或分布式系统中发送消息,进行异步通信。
AMQP 和 JMS 的区别
- JMS 是定义了统一的接口,来对消息操作进行统一;AMQP 是通过规定协议来统一数据交互的格式
- JMS 限定了必须使用Java语言;AMQP 只是协议,不规定实现方式,因此是跨语言的。
- JMS 规定了两种消息模式;而 AMQP 的消息模式更加丰富
2.3、消息队列的产品
市场上常见的消息队列有如下:
- ActiveMQ:基于JMS
- ZeroMQ:基于C语言开发
- Rabbitmq:基于AMQP协议,erlang语言开发,稳定性好
- RocketMQ:基于JMS,阿里巴巴产品
- Kafka:类似MQ的产品;分布式消息系统,高吞吐量
2.4、RabbitMQ
- RabbitMQ 是由 erlang 语言开发,基于 AMQP(Advanced Message Queue 高级消息队列协议)协议实现的消息队列,它是一种应用程序之间的通信方法,消息队列在分布式系统开发中应用非常广泛。
- RabbitMQ 官方地址:http://www.rabbitmq.com/
- RabbitMQ 提供了6种模式:简单模式,work 模式,Publish/Subscribe 发布与订阅模式,Routing 路由模式,Topics 主题模式,RPC 远程调用模式(远程调用,不太算 MQ;暂不作介绍);
- 官网对应模式介绍:https://www.rabbitmq.com/getstarted.html
RabbitMQ 简介
AMQP,即 Advanced Message Queuing Protocol(高级消息队列协议),是一个网络协议,它只是应用层协议的一个开放标准,为面向消息的中间件设计。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同的开发语言等条件的限制。2006年,AMQP 规范发布。类比HTTP。
所以 RabbitMQ 是跨平台的
RabbitMQ 基础架构如下图:
- Broker:就相当于一个数据库服务
接收和分发消息的应用,RabbitMQ Server就是 Message Broker。 - Virtual host:就相当于是数据库服务中的一个库
出于多租户和安全因素设计的,把 AMQP 的基本组件划分到一个虚拟的分组中,类似于网络中的 namespace 概念。当多个不同的用户使用同一个 RabbitMQ server 提供的服务时,可以划分出多个vhost,每个用户在自己的 vhost 创建 exchange/queue 等; - Connection:相当于 jdbc链接
publisher/consumer 和 broker 之间的 TCP 连接; - Channel:就是对一个链接进行多路复用,省去每次建立链接的开销
如果每一次访问 RabbitMQ 都建立一个 Connection,在消息量大的时候建立 TCP - Connection的开销将是巨大的,效率也较低。Channel 是在 connection 内部建立的逻辑连接,如果应用程序支持多线程,通常每个thread创建单独的 channel 进行通讯,AMQP method 包含了- channel id 帮助客户端和message broker 识别 channel,所以 channel 之间是完全隔离的。Channel 作为轻量级的 Connection 极大减少了操作系统建立 TCP connection 的开销; - Exchange:交换机或者说是路由,负责按照指定模式分发消息到多个消息队列中
message 到达 broker 的第一站,根据分发规则,匹配查询表中的 routing key,分发消息到queue 中去。常用的类型有:点对点 direct (point-to-point) ,发布订阅 topic (publish-subscribe) ,广播 fanout (multicast) - Queue:存储消息的容器,消息最终被送到这里,等待 consumer 取走
- Binding:通过 key 将一个或一类消息绑定到某一个消息 Queue 中
exchange 和 queue 之间的虚拟连接,binding 中可以包含 routing key。Binding 信息被保存到 exchange 中的查询表中,用于 message 的分发依据
二、RabbitMQ 的安装
1、安装
由于 RabbitMQ 是 erlang 语言开发的,所以我们要首先安装 erlang 语言的开发环境,此外 RabbitMQ 还依赖了 socat,所以要下载 socat
安装工具提取码:6rzc
RabbitMQ 版本对应的 erlang 版本:
https://www.rabbitmq.com/which-erlang.html
-
顺序安装
rpm -ivh erlang-21.3.8.9-1.el7.x86_64.rpm
rpm -ivh socat-1.7.3.2-1.el6.lux.x86_64.rpm
rpm -ivh rabbitmq-server-3.8.1-1.el7.noarch.rpm
-
启动管理插件(/usr/lib/rabbitmq/bin/)
rabbitmq-plugins enable rabbitmq_management -
启动 RabbitMQ
systemctl start rabbitmq-server.service
systemctl status rabbitmq-server.service
systemctl restart rabbitmq-server.service
systemctl stop rabbitmq-server.service
-
测试
在虚拟机浏览器中输入:localhost:15672/
默认账户:guest,密码:guest
注:guest 账户不支持远程访问 -
添加自定义账户(支持远程访问)
添加管理员账号密码:rabbitmqctl add_user admin admin
分配账号角色:rabbitmqctl set_user_tags admin administrator
修改密码:rabbitmqctl change_password admin 123456
查看用户列表:rabbitmqctl list_users
管理界面标签页介绍
- overview:概览
- connections:无论生产者还是消费者,都需要与RabbitMQ建立连接后才可以完成消息的生产和消费,在这里可以查看连接情况
- channels:通道,建立连接后,会形成通道,消息的投递获取依赖通道。
- Exchanges:交换机,用来实现消息的路由
- Queues:队列,即消息队列,消息存放在队列中,等待消费,消费后被移除队列。
端口
- 5672:rabbitMq 的编程语言客户端连接端口
- 15672:rabbitMq 管理界面端口
- 25672:rabbitMq 集群的端口
2、卸载
- rpm -qa | grep rabbitmq
- rpm -e rabbitmq-server
3、管理界面
如果不使用 guest,我们也可以自己创建一个用户
3.1、添加用户及用户类别
添加用户
添加管理员账号密码:rabbitmqctl add_user admin admin
分配账号角色:rabbitmqctl set_user_tags admin administrator
修改密码:rabbitmqctl change_password admin 123456
查看用户列表:rabbitmqctl list_users
用户类别
- 超级管理员(administrator)
可登录管理控制台,可查看所有的信息,并且可以对用户,策略(policy)进行操作。 - 监控者(monitoring)
可登录管理控制台,同时可以查看rabbitmq节点的相关信息(进程数,内存使用情况,磁盘使用情况等) - 策略制定者(policymaker)
可登录管理控制台, 同时可以对policy进行管理。但无法查看节点的相关信息。 - 普通管理者(management)
仅可登录管理控制台,无法看到节点信息,也无法对策略进行管理。 - 其他
无法登录管理控制台,通常就是普通的生产者和消费者。
3.2、创建 Virtual Hosts
虚拟主机:类似于 mysql 中的 database。他们都是以 “/” 开头
设置虚拟主机的权限
三、RabbitMQ 入门案例
官方文档:https://www.rabbitmq.com/getstarted.html
1、简单模式
需求:使用简单模式完成消息传递
步骤:
- 创建工程(生成者、消费者)
- 分别添加依赖
- 编写生产者发送消息
- 编写消费者接收消息
创建生产者
public class Producer {
public static final String QUEUE_NAME = "simple_queue";
public static void main(String[] args) throws IOException, TimeoutException {
// 1.创建链接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
connectionFactory.setHost("192.168.17.132");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
// 2.创建链接
Connection connection = connectionFactory.newConnection();
// 3.创建信道
Channel channel = connection.createChannel();
/**
* @param queue 队列名称
* @param durable 在 RabbitMQ 服务器重启之后,队列是否能存活(是否支持持久化)
* @param exclusive 是否独占本次链接(只能本消费者可以监听这个队列,)
* @param autoDelete 当没有消费者使用这个队列的时候,是否自动删除这个队列
* @param arguments 队列的一些其他参数(如:超时时间,过期时间,长度等等)
*/
// 4.创建队列
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
// 5.发送消息
String message = "Hello, Rabbit2!!!";
/**
* @param exchange 交换机的名称,没有设置为空
* @param routingKey 路由 key,简单模式可以设置为队列名称
* @param props 消息的属性。例如:消息的长度、类型等
* @param body 需要传递的消息
*/
channel.basicPublish("", QUEUE_NAME, null, message.getBytes("UTF-8"));
// 6. 关闭资源
channel.close();
connection.close();
}
}
创建消费者
public class Consumer {
public static final String QUEUE_NAME = "simple_queue";
public static void main(String[] args) throws IOException, TimeoutException {
// 1.创建链接工厂
ConnectionFactory connectionFactory = new ConnectionFactory();
connectionFactory.setHost("192.168.17.132");
connectionFactory.setPort(5672);
connectionFactory.setVirtualHost("/");
connectionFactory.setUsername("admin");
connectionFactory.setPassword("admin");
// 2.创建链接
Connection connection = connectionFactory.newConnection();
// 3.创建信道
Channel channel = connection.createChannel();
// 4.创建消息消费者,接受并确认消息
DefaultConsumer consumer = new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("consumerTag = " + consumerTag);
System.out.println("envelope = " + envelope);
System.out.println("properties = " + properties);
System.out.println("message = " + new String(body, "UTF-8"));
}
};
/**
* @param queue 声明队列的名称
* @param autoAck 是否自动确认消息(确认之后就会删除消息,一个消息只能确认一次)
* @param callback 消费者
*/
channel.basicConsume(QUEUE_NAME, true, consumer);
// 5.关闭资源
//channel.close();
//connection.close();
}
}
消费者
2、Work 模式
先启动消费者,在启动生产者
应用于生产者生产速度大于消费者消费速度
生产者
public class Producer {
public static final String QUEUE_NAME = "work_queue";
public static void main(String[] args) throws IOException, TimeoutException {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
for (int i = 0; i < 30; i++) {
String message = "Hello, Rabbit" + i;
channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
}
channel.close();
connection.close();
}
}
消费者
多个消费者同时消费一个队列,默认是平均分配,每个消费者拿到的消息数量是相等的
如果想实现性能高的消费者处理的消息更多,性能差的消费者处理的消息较少,则需要:
- 指定Qos为 1;
- 手动确认消息
不确认消息会怎样?
- 如果消息没有被确认,一旦消费者退出,停止消费,未被确认的消息不会被删除,原位返回队列;
什么时候该确认消息?
- 不重要的消息自动确认即可,重要的消息手动确认
- 假如:当我们下订单的时候,订单成功(数据库写入)就确认消息;如果数据库写入失败(报异常)就不确认消息
public class Consumer1 {
public static final String QUEUE_NAME = "work_queue";
public static void main(String[] args) throws IOException, TimeoutException {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
// 设置 Qos
channel.basicQos(1);
DefaultConsumer consumer = new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("message = " + new String(body, "UTF-8"));
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
/**
* 手动确认消息
* @param deliveryTag 消息递送的标签
* @param multiple 是否一次确认多条消息
*/
channel.basicAck(envelope.getDeliveryTag(), false);
}
};
// 手动确认消息
channel.basicConsume(QUEUE_NAME, false, consumer);
}
}
public class Consumer2 {
public static final String QUEUE_NAME = "work_queue";
public static void main(String[] args) throws IOException, TimeoutException {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
channel.basicQos(1);
DefaultConsumer consumer = new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("message = " + new String(body, "UTF-8"));
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
channel.basicAck(envelope.getDeliveryTag(), false);
}
};
channel.basicConsume(QUEUE_NAME, false, consumer);
}
}
3、订阅模式
实例图:
- 在简单和工作模式中,只有 3 个角色:P(生产者)、C(消费者)、queue(消息队列)
- 而在订阅模式中多了一个交换机(exchange)角色
- 交换机的作用:通过指定的模式将消息转交给特定的消息队列,以下有三种常用的模式:
- Fanout:广播模式,将消息发送给所有的队列;
- Direct:定向模式,将消息发送给绑定了指定 routing key 的消息队列;
- Topic:通配符模式,将消息发送给绑定了指定 routing pattern 的消息队列;
Headers:协议头模式,通过匹配 AMQP 协议的消息头来决定将消息发送给哪个队列,不同于路由键;不常用,这里不过多介绍
注:Exchange(交换机)只负责转发消息,不具备存储消息的能力,也就是说:如果没有任何队列与 Exchange 绑定,或者没有符合路由规则的队列,消息就会丢失!!!
3.1、发布与订阅模式
先运行生产者(创建交换机和队列),在运行消费者;因为我们需要生产者先行创建交换机和队列,否则先启动消费者会报错
生产者
public class Producer {
public static void main(String[] args) throws Exception {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
String exchangeName = "test_fanout";
/**
* 声明交换机
* @param exchange 声明交换机的名称
* @param type 声明交换机的类型
*/
channel.exchangeDeclare(exchangeName, BuiltinExchangeType.FANOUT,true,false,false,null);
//6. 创建队列
String queue1Name = "test_fanout_queue1";
String queue2Name = "test_fanout_queue2";
channel.queueDeclare(queue1Name,true,false,false,null);
channel.queueDeclare(queue2Name,true,false,false,null);
/**
* 绑定队列和交换机
* @param queue 队列名称
* @param exchange 交换机名称
* @param routingKey 路由 key, 如果交换机类型为 fanout, routingKey 设置为 ""
*/
channel.queueBind(queue1Name,exchangeName,"");
channel.queueBind(queue2Name,exchangeName,"");
String body = "日志信息:张三调用了findAll方法...日志级别:info...";
//8. 发送消息
channel.basicPublish(exchangeName,"",null,body.getBytes());
//9. 释放资源
channel.close();
connection.close();
}
}
在这里我们就创建了一个交换机,和两个队列
消费者
public class Consumer1 {
public static void main(String[] args) throws Exception {
Connection connection = ConnectionUtil.getConnection();
Channel channel1 = connection.createChannel();
String queue1Name = "test_fanout_queue1";
Consumer consumer1 = new DefaultConsumer(channel1){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("body:"+new String(body));
System.out.println("将日志信息打印到控制台.....");
}
};
channel1.basicConsume(queue1Name,true,consumer1);
}
}
启动 consumer1,我们发现队列 1 的消息被消费了
同理,启动 consumer2,也会消费队列 2 的消息,两个队列是独立的
小结:
- 交换机需要与队列进行绑定;绑定之后,一个消息就可以被多个消费者收到
发布订阅模式与工作队列模式的区别
- 工作队列模式不用定于交换机,而发布订阅模式需要定义交换机;
- 发送订阅模式的生产者是面向交换机发送消息的(解耦),工作队列模式是面向队列发送消息的(实际上底层使用了默认的交换机,一个 VirtualHost 会默认附带七个交换机);
- 发布订阅模式需要设置队列和交换机的绑定,而工作队列模式不需要去(实际上工作队列模式会将队列绑定到默认的交换机上)
3.2、Routing 路由模式
特点:
- 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey(路由key)
- 消息的发送方在 向 Exchange发送消息时,也必须指定消息的 RoutingKey。
- Exchange 不再把消息交给每一个绑定的队列,而是根据消息的 Routing Key 进行判断,只有队列的 Routing Key 与消息的 Routing Key完全一致,才会接收到消息
在编码上与发布与订阅模式的区别是交换机的类型为:Direct,还有队列绑定交换机的时候需要指定routing key。
生产者
public class Producer {
public static void main(String[] args) throws Exception {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
String exchangeName = "test_direct";
// 创建交换机
channel.exchangeDeclare(exchangeName, BuiltinExchangeType.DIRECT,true,false,false,null);
// 创建队列
String queue1Name = "test_direct_queue1";
String queue2Name = "test_direct_queue2";
// 声明(创建)队列
channel.queueDeclare(queue1Name,true,false,false,null);
channel.queueDeclare(queue2Name,true,false,false,null);
// 队列绑定交换机
// 队列 1 绑定 error
channel.queueBind(queue1Name,exchangeName,"error");
// 队列 2 绑定 info error warning
channel.queueBind(queue2Name,exchangeName,"info");
channel.queueBind(queue2Name,exchangeName,"error");
channel.queueBind(queue2Name,exchangeName,"warning");
String message = "日志信息:张三调用了delete方法.错误了,日志级别warning";
// 发送消息
channel.basicPublish(exchangeName,"warning",null,message.getBytes());
System.out.println(message);
channel.close();
connection.close();
}
}
我们发送一条绑定 routingkey为 “warning” 的消息,在管理台发现,只有队列 2 收到了消息
消费者
public class Consumer2 {
public static void main(String[] args) throws Exception {
Connection connection = ConnectionUtil.getConnection();
Channel channel1 = connection.createChannel();
String queue1Name = "test_direct_queue2";
Consumer consumer1 = new DefaultConsumer(channel1){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("body:"+new String(body));
}
};
channel1.basicConsume(queue1Name,true,consumer1);
}
}
3.3、Topics 通配符模式
说明:
- Topic 类型与 Direct 相比,都是可以根据 RoutingKey 把消息路由到不同的队列。只不过 Topic 类型 Exchange 可以让队列在绑定 Routing key 的时候使用通配符!
- Routingkey 一般都是有一个或多个单词组成,多个单词之间以 ”.” 分割,例如: item.insert
- 通配符规则:
- #:匹配零个或多个词
- *:匹配不多不少恰好1个词
举例:
- item.#:能够匹配item.insert.abc 或者 item.insert
- item.*:只能匹配item.insert
生产者
public class Producer {
public static void main(String[] args) throws Exception {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
String exchangeName = "test_topic";
channel.exchangeDeclare(exchangeName, BuiltinExchangeType.TOPIC,true,false,false,null);
String queue1Name = "test_topic_queue1";
String queue2Name = "test_topic_queue2";
channel.queueDeclare(queue1Name,true,false,false,null);
channel.queueDeclare(queue2Name,true,false,false,null);
// 绑定队列和交换机
//需求: 所有error级别的日志存入数据库,所有order系统的日志存入数据库
channel.queueBind(queue1Name,exchangeName,"#.error");
channel.queueBind(queue1Name,exchangeName,"order.*");
channel.queueBind(queue2Name,exchangeName,"*.*");
String body = "日志信息:张三调用了findAll方法...日志级别:info...";
//发送消息goods.info,goods.error
channel.basicPublish(exchangeName,"order.info",null,body.getBytes());
channel.close();
connection.close();
}
}
我们发送了一条 “order.info” 的消息,这时候队列 1,2 都能够匹配到
消费者
public class Consumer1 {
public static void main(String[] args) throws Exception {
Connection connection = ConnectionUtil.getConnection();
Channel channel = connection.createChannel();
String queue1Name = "test_topic_queue1";
Consumer consumer = new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("body:"+new String(body));
}
};
channel.basicConsume(queue1Name,true,consumer);
}
}
像这样,每一个队列我们都可以做成一个模块,例如:
- 我们可以绑定一个 “#.update.#” 的队列,所有的更新消息都转发给这个队列来做;绑定一个 “#.put.#” 的队列,所有的新增消息都转发给这个队列;
- 或者,我们可以绑定一个 “#.error.#”,来处理所有的错误消息,等等;
3.4、总结
Topic主题模式可以实现 发布与订阅模式 和 路由模式 的功能;只是Topic在配置routing key 的时候可以使用通配符,显得更加灵活。
RabbitMQ工作模式:
-
简单模式 HelloWorld
一个生产者、一个消费者,不需要设 置交换机(使用默认的交换机) -
工作队列模式 Work Queue
一个生产者、多个消费者(竞争关系),不需要设置交换机(使用默认的交换机) -
发布订阅模式 Publish/subscribe
需要设置类型为fanout的交换机,并且交换机和队列进行绑定,当发送消息到交换机后,交换机会将消息发送到绑定的队列 -
路由模式 Routing
需要设置类型为direct的交换机,交换机和队列进行绑定,并且指定routing key,当发送消息到交换机后,交换机会根据routing key将消息发送到对应的队列 -
通配符模式 Topic
需要设置类型为topic的交换机,交换机和队列进行绑定,并且指定通配符方式的routing key,当发送消息到交换机后,交换机会根据routing key将消息发送到对应的队列
总结:
- fanout 模式其实就是一种特殊的 Routing 模式,同时也是一种 Topics 模式,交换机的 routingKey 被我们设置为空,队列绑定的 key 也为空,这样我们就可以将消息广播到所有的队列上了
- 反向理解,Routing 模式或者 Topic 模式也是一种特殊的 fanout 模式,同一个路由 key 被多个队列绑定,那个消息转发的绑定了这个 key 的多个队列中就是广播模式
- fanout 模式,对个队列是相互独立的,每个队列都可以拿到相同的消息,独立消费;
扩展:
- 如果某一个 key 的队列消费速度较慢,我们就可以多绑定几个消费者到这个 key 的队列上(work 模式),来解决这个问题
- 我们可以利用路由模式或者 Topic 模式,来实现模块化开发,例如:
- 绑定一个 “update” 的队列,所有的更新请求都由这个队列的消费者来解决等等;
四、Spring 整合 RabbitMQ
1、简单模式
生产者
环境搭建
- 创建工程 spring-rabbitmq-producer
- 导入依赖
- 配置属性 (rabbitmq.properties)
- 创建配置文件
pom.xml 文件
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.1.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
<version>2.1.8.RELEASE</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.1.7.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
rabbitmq.properties
rabbitmq.host=192.168.17.132
rabbitmq.port=5672
rabbitmq.username=admin
rabbitmq.password=admin
rabbitmq.virtual-host=/
spring-rabbitmq.xml 配置文件
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:rabbit="http://www.springframework.org/schema/rabbit"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/rabbit
http://www.springframework.org/schema/rabbit/spring-rabbit.xsd">
<!-- 加载配置文件 -->
<context:property-placeholder location="classpath:rabbitmq.properties"/>
<!-- 定义rabbitmq connectionFactory -->
<rabbit:connection-factory id="connectionFactory" host="${rabbitmq.host}"
port="${rabbitmq.port}"
username="${rabbitmq.username}"
password="${rabbitmq.password}"
virtual-host="${rabbitmq.virtual-host}"/>
<!-- 对应 RabbitAdmin 类,管理交换机和队列 -->
<rabbit:admin connection-factory="connectionFactory"/>
<!-- 声明持久化队列,不存在则自动创建;
不绑定到交换机则绑定到默认交换机,默认交换机类型为 direct,名字为:"",路由键为队列的名称 -->
<rabbit:queue id="spring_queue" name="spring_queue" auto-declare="true"/>
<!-- 定义 rabbitTemplate 对象操作可以在代码中方便发送消息 -->
<rabbit:template id="rabbitTemplate" connection-factory="connectionFactory"/>
</beans>
搭建好环境之后我们就可以向队列中发消息了,用单元测试向队列发消息
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:spring-rabbitmq.xml")
public class ProducerTest {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 简单模式:
* 1. 默认交换机类型为 direct;
* 2. 交换机名称为空,路由键为队列名称;
*/
@Test
public void test() {
/**
* routingKey: 队列不指定 key, 默认为队列名称
* object: 需要发送的消息
*/
rabbitTemplate.convertAndSend("spring_queue", "hello, world!!!");
}
}
消费者
pom.xml 和 rabbitmq.properties 文件与生产者一致
spring-rabbitmq.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:rabbit="http://www.springframework.org/schema/rabbit"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/rabbit
http://www.springframework.org/schema/rabbit/spring-rabbit.xsd">
<!--加载配置文件-->
<context:property-placeholder location="classpath:rabbitmq.properties"/>
<!-- 定义rabbitmq connectionFactory -->
<rabbit:connection-factory id="connectionFactory" host="${rabbitmq.host}"
port="${rabbitmq.port}"
username="${rabbitmq.username}"
password="${rabbitmq.password}"
virtual-host="${rabbitmq.virtual-host}"/>
<!-- 声明监听器 -->
<bean id="springQueueListener" class="com.atguigu.rabbitmq.listener.SpringQueueListener"/>
<!-- 将消费者与队列进行绑定 -->
<rabbit:listener-container connection-factory="connectionFactory" auto-declare="true">
<rabbit:listener ref="springQueueListener" queue-names="spring_queue"/>
</rabbit:listener-container>
</beans>
创建监听器
public class SpringQueueListener implements MessageListener {
@Override
public void onMessage(Message message) {
try {
String msg = new String(message.getBody(), "UTF-8");
System.out.printf("接收路由名称为:%s,路由键为:%s,队列名为:%s的消息:%s \n",
message.getMessageProperties().getReceivedExchange(),
message.getMessageProperties().getReceivedRoutingKey(),
message.getMessageProperties().getConsumerQueue(),
msg);
} catch (Exception e) {
e.printStackTrace();
}
}
}
之后我们就可以消费队列中的消息了,通过单元测试进行
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:spring-rabbitmq.xml")
public class ConsumerTest {
@Test
public void test() {
while (true){
// 单元测试方法结束就会销毁上下文,所以我们不让它结束,
// 就放个死循环在这里即可,真实开发中肯定是放在 tomcat 里的;
}
}
}
2、广播模式
生产者
在生产者的 spring-rabbitmq.xml 配置文件中声明队列和交换机
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~广播;所有队列都能收到消息~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- 声明持久化队列,不存在则自动创建 -->
<rabbit:queue id="spring_fanout_queue_1" name="spring_fanout_queue_1" auto-declare="true"/>
<rabbit:queue id="spring_fanout_queue_2" name="spring_fanout_queue_2" auto-declare="true"/>
<!-- 定义广播类型交换机;并绑定上述两个队列 -->
<rabbit:fanout-exchange id="spring_fanout_exchange" name="spring_fanout_exchange" auto-declare="true">
<rabbit:bindings>
<rabbit:binding queue="spring_fanout_queue_1"/>
<rabbit:binding queue="spring_fanout_queue_2"/>
</rabbit:bindings>
</rabbit:fanout-exchange>
通过单元测试发送消息
@Test
public void testFanout() {
rabbitTemplate.convertAndSend("spring_fanout_exchange", "", "test fanout!!!");
}
消费者
在 spring-rabbitmq.xml 配置文件中声明监听器,并绑定队列
<!-- 声明监听器 -->
<bean id="springQueueListener" class="com.atguigu.rabbitmq.listener.SpringQueueListener"/>
<bean id="fanoutListener1" class="com.atguigu.rabbitmq.listener.FanoutListener1"/>
<bean id="fanoutListener2" class="com.atguigu.rabbitmq.listener.FanoutListener2"/>
<!-- 将消费者与队列进行绑定 -->
<rabbit:listener-container connection-factory="connectionFactory" auto-declare="true">
<rabbit:listener ref="springQueueListener" queue-names="spring_queue"/>
<rabbit:listener ref="fanoutListener1" queue-names="spring_fanout_queue_1"/>
<rabbit:listener ref="fanoutListener2" queue-names="spring_fanout_queue_2"/>
</rabbit:listener-container>
开启单元测试,消费消息
3、Topics 模式
生产者
声明队列和交换机并绑定
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~通配符;*匹配一个单词,#匹配多个单词 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- 声明持久化队列,不存在则自动创建 -->
<rabbit:queue id="spring_topic_queue_star" name="spring_topic_queue_star" auto-declare="true"/>
<rabbit:queue id="spring_topic_queue_well" name="spring_topic_queue_well" auto-declare="true"/>
<rabbit:queue id="spring_topic_queue_well2" name="spring_topic_queue_well2" auto-declare="true"/>
<rabbit:topic-exchange id="spring_topic_exchange" name="spring_topic_exchange" auto-declare="true">
<rabbit:bindings>
<rabbit:binding pattern="atguigu.*" queue="spring_topic_queue_star"/>
<rabbit:binding pattern="atguigu.#" queue="spring_topic_queue_well"/>
<rabbit:binding pattern="guigu.#" queue="spring_topic_queue_well2"/>
</rabbit:bindings>
</rabbit:topic-exchange>
发送消息
@Test
public void testTopics() {
rabbitTemplate.convertAndSend("spring_topic_exchange", "atguigu.ayi.zz", "test Topics!!!");
}
消费者
<!-- 声明监听器 -->
<bean id="springQueueListener" class="com.atguigu.rabbitmq.listener.SpringQueueListener"/>
<bean id="fanoutListener1" class="com.atguigu.rabbitmq.listener.FanoutListener1"/>
<bean id="fanoutListener2" class="com.atguigu.rabbitmq.listener.FanoutListener2"/>
<bean id="topicListenerStar" class="com.atguigu.rabbitmq.listener.TopicListenerStar"/>
<bean id="topicListenerWell" class="com.atguigu.rabbitmq.listener.TopicListenerWell"/>
<bean id="topicListenerWell2" class="com.atguigu.rabbitmq.listener.TopicListenerWell2"/>
<!-- 将消费者与队列进行绑定 -->
<rabbit:listener-container connection-factory="connectionFactory" auto-declare="true">
<rabbit:listener ref="springQueueListener" queue-names="spring_queue"/>
<rabbit:listener ref="fanoutListener1" queue-names="spring_fanout_queue_1"/>
<rabbit:listener ref="fanoutListener2" queue-names="spring_fanout_queue_2"/>
<rabbit:listener ref="topicListenerStar" queue-names="spring_topic_queue_star"/>
<rabbit:listener ref="topicListenerWell" queue-names="spring_topic_queue_well"/>
<rabbit:listener ref="topicListenerWell2" queue-names="spring_topic_queue_well2"/>
</rabbit:listener-container>
五、RabbitMQ 高级特性
1、消息的可靠投递(可靠性)
问题:
- 消费者怎么知道消息正确的到达了交换机?
- 怎么知道队列有没有收到交换机转发的消息,或在交换机有没有丢失消息(没有绑定的队列)?
在使用 RabbitMQ 的时候,作为消息的发送方(生产者)希望杜绝任何消息丢失或者投递失败的场景。RabbitMQ 提供了两个方式用来控制消息的可靠性投递:
- confirm 模式
- return 模式
confirm 模式
生产者在向交换机发送消息的时候,会首先建立信道,通过信道将消息发送给交换机,当交换机收到消息之后会给生产者发送一个确认帧 ACK,生产者一旦收到 ACK 就知道交换机正确的接受到消息了
return 模式
交换机在向队列发送消息的时候,如果队列正确收到消息不会返回任何确认,如果队列没有收到消息,生产者就会收到消息丢失的确认,从而调用响应的回调函数
注: 在生产者一方有两个回调函数 confirmCallback
和 returnCallback
两个对调函数,我们将利用这两个回调函数来控制消息的可靠投递
1.1、confirm 模式
环境搭建
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.1.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
<version>2.1.8.RELEASE</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.1.7.RELEASE</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:spring-rabbitmq.xml")
public class ProducerTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testConfirm() {
rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if (ack){
System.out.println("交换机成功接受消息, cause: " + cause);
}else {
System.out.println("交换机接受消息失败, cause: " + cause);
}
}
});
rabbitTemplate.convertAndSend("test_exchange_confirm", "confirm", "test confirm....."); // 成功
//rabbitTemplate.convertAndSend("test_exchange_confirm00", "confirm", "test confirm....."); // 失败
//交换机接受消息失败, cause: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'test_exchange_confirm00' in vhost '/', class-id=60, method-id=40)
}
}
将来我们就可以在发送失败之后,做一些操作,例如:重发,导入数据库(将来人工处理)等等;
1.2、return 模式
@Test
public void testReturn() {
// 设置消息必须到达,否则就返回失败消息
rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
System.out.println("message = " + message);
System.out.println("replyCode = " + replyCode);
System.out.println("replyText = " + replyText);
System.out.println("exchange = " + exchange);
System.out.println("routingKey = " + routingKey);
}
});
//rabbitTemplate.convertAndSend("test_exchange_confirm", "confirm", "test return...."); //成功
rabbitTemplate.convertAndSend("test_exchange_confirm", "confirm00", "test return...."); //失败
}
注:一定要设置 rabbitTemplate.setMandatory(true);
否则消息发送失败也不会回调;
1.3、总结
- 设置
connectionFactory
的publisher-confirms = "true"
:开启确认模式 - 使用
rabbitTemplate.setConfirmCallback
设置回调函数。当消息发送到exchange
后回调confirm
方法。在方法中判断ack
,如果为 true,则发送成功,如果为false,则发送失败,需要处理; - 设置
connectionFactory
的publisher-returns="true"
:开启退回模式 - 使用
rabbitTemplate.setReturnCallback
设置退回函数,当消息从exchange
路由到 queue 失败后,如果设置了rabbitTemplate.setMandatory(true)
参数,则会将消息退回给 producer 并执行回调函数 returnedMessage
2、Consumer Ack(消费失败)
Ack:表示消费端收到消息后的确认方式。确认方式有两种:
- 自动确认:acknowledge = “none”。默认
- 手动确认:acknowledge = “manual”。
其中,自动确认是指,当消息一旦被 Consumer 接受到,则自动确认收到。并将 Message 从 RabbitMQ 的消息缓存中删除。至于,之后消息处理的时候是否遇到异常,导致消费失败,就不在关心。所以消息可能会丢失。
如果设置了手动确认,则需要在业务处理完成之后,调用 channel.basicAck() 手动签收,如果异常,则调用 channel.basicNack() 拒签,让其重回队列,重新投递或者进入死信队列;
环境搭建
自动确认就是平常写的例子
手动确认
生产者使用的就是 “消息的可靠投递” 的例子
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:rabbit="http://www.springframework.org/schema/rabbit"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/rabbit
http://www.springframework.org/schema/rabbit/spring-rabbit.xsd">
<!-- 加载配置文件 -->
<context:property-placeholder location="classpath:rabbitmq.properties"/>
<!-- 定义rabbitmq connectionFactory -->
<rabbit:connection-factory id="connectionFactory" host="${rabbitmq.host}"
port="${rabbitmq.port}"
username="${rabbitmq.username}"
password="${rabbitmq.password}"
virtual-host="${rabbitmq.virtual-host}"/>
<bean id="ackListener" class="com.atguigu.listener.AckListener"/>
<rabbit:listener-container connection-factory="connectionFactory" acknowledge="manual">
<rabbit:listener ref="ackListener" queue-names="test_queue_confirm"/>
</rabbit:listener-container>
</beans>
public class AckListener implements ChannelAwareMessageListener {
@Override
public void onMessage(Message message, Channel channel) throws Exception {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
System.out.println(new String(message.getBody(), "utf-8"));
// 模拟消费失败
int i = 1/0;
/**
* @param deliveryTag 投递标签:消息的唯一标识,类似数据 id
* @param multiple true:签收所有的消息;false:只签收当前消息
*/
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
e.printStackTrace();
/**
* @param deliveryTag 投递标签
* @param multiple true:拒签所有的消息;false:拒签当前消息
* @param requeue true:重回队列,重新投递;false:直接丢弃或进入死信队列
*/
channel.basicNack(deliveryTag, false, true);
}
}
}
上面模拟的是消费失败的时候,拒签消息,让其重回队列,重新投递的场景。消息在被正确处理之前会一直重复投递,直到异常被解决(例如:数据库链接不上,服务没有开启等等)
3、消费端限流
在上面例子的基础上我们新增一个 QosListener,来模拟限流问题
- 新建 QosListener
消费消息,不进行签收
public class QosListener implements ChannelAwareMessageListener {
@Override
public void onMessage(Message message, Channel channel) throws Exception {
System.out.println(new String(message.getBody()));
}
}
prefetch
预先抓取多少个消息,prefetch = 1:表示一次只能消息一条消息,在该消息被签收或拒签之前,不能在抓取其他消息;
<bean id="qosListener" class="com.atguigu.listener.QosListener"/>
<rabbit:listener-container connection-factory="connectionFactory" acknowledge="manual" prefetch="30">
<rabbit:listener ref="qosListener" queue-names="test_queue_confirm"/>
</rabbit:listener-container>
我们在队列中预先准备 30 条消息
分别尝试 prefetch = 1,10,30 的情况
我们可以看到当我们将 prefetch = 10 时,会预先抓取 10 条消息,队列中有10条未被确认的消息,当有一条消息被确认的时候,就会再次从队列中抓取一条消息。
4、TTL
TTL:过期时间。当消息达到存活时间之后,还没有被消费,会被自动清除
RabbitMQ:可以对单个消息设置过期时间,也可以对整个队列设置过期消息(就是对进入队列的消息进行统一设置);以时间较短的为准
4.1、控制台演示
新增队列,设置队列的 TTL
新增交换机
绑定队列
点击刚刚新增的交换机
发布消息
我们就可以观察到 10 秒,后队列的中的消息自动删除了
4.2、代码实现
队列的过期时间
<!--TTL 队列-->
<rabbit:queue name="test_queue_ttl" id="test_queue_ttl">
<!--设置queue的参数-->
<rabbit:queue-arguments>
<!--
设置x-message-ttl队列的过期时间
默认情况下value-type的类型是String类型,但时间的类型是number类型,所以需要设置成integer类型
-->
<entry key="x-message-ttl" value="10000" value-type="java.lang.Integer"></entry>
</rabbit:queue-arguments>
</rabbit:queue>
<!--设置交换机-->
<rabbit:topic-exchange name="test_exchange_ttl">
<!--交换机绑定队列-->
<rabbit:bindings>
<rabbit:binding pattern="ttl.#" queue="test_queue_ttl"></rabbit:binding>
</rabbit:bindings>
</rabbit:topic-exchange>
消息的过期时间,通过消息的后置处理器,来设置消息的一些参数
/**
* TTL:过期时间
* 1. 队列统一过期
* 2. 消息单独过期
* 如果设置了消息的过期时间,也设置了队列的过期时间,它以时间短的为准。
*/
@Test
public void testMessageTtl() {
// 消息后处理器,设置一些消息的参数信息
MessagePostProcessor messagePostProcessor = new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
//1.设置message的信息
// 第二个方法:消息的过期时间 ,5秒之后过期
message.getMessageProperties().setExpiration("5000");
//2.返回该消息
return message;
}
};
//消息单独过期
rabbitTemplate.convertAndSend("test_exchange_ttl","ttl.hehe","message ttl....",messagePostProcessor);
}
5、死信队列
死信队列,英文缩写:DLX 。DeadLetter Exchange(死信交换机),当消息成为Dead message后,可以被重新发送到另一个交换机,这个交换机就是DLX。
什么是死信队列?
死信,就是无法被消费的消息。一般来说,producer 将消息投递到 broker 或者直接到 queue 里了,consumer 从 queue 取出消息进行消费,但某些时候由于特定的原因导致 queue 中的某些消息无法被消费,这样的消息如果没有后续的处理,就变成了死信,有死信,自然就有了死信队列;
消息成为死信的三种情况
- 队列接受消息的数量超过消息队列的长度,根据先进先出,最先发出的消息会被挤出队列,进入死信队列;
- 消费者拒接消费消息,basicNack/basicReject,并且不把消息重新放入原目标队列,requeue=false;
- 原队列存在消息过期设置,消息到达超时时间未被消费;
死信的处理方式
死信的产生既然不可避免,那么就需要从实际的业务角度和场景出发,对这些死信进行后续的处理,常见的处理方式大致有下面几种,
- 丢弃,如果不是很重要,可以选择丢弃
- 记录死信入库,然后做后续的业务分析或处理
- 通过死信队列,由负责监听死信的应用程序进行处理
综合来看,更常用的做法是第三种,即通过死信队列,将产生的死信通过程序的配置路由到指定的死信队列,然后应用监听死信队列,对接收到的死信做后续的处理,
队列绑定死信交换机
给队列设置参数: x-dead-letter-exchange 和 x-dead-letter-routing-key
代码实现
<!-- 声明正常队列 -->
<rabbit:queue name="test_queue_dlx" id="test_queue-dlx">
<rabbit:queue-arguments>
<!-- 绑定死信交换机 -->
<entry key="x-dead-letter-exchange" value="exchange_dlx"/>
<!-- 绑定死信路由 key -->
<entry key="x-dead-letter-routing-key" value="dlx.messgae"/>
<!-- 设置队列的过期时间 -->
<entry key="x-message-ttl" value="10000" value-type="java.lang.Integer"/>
<!-- 设置队列的长度 -->
<entry key="x-max-length" value="10" value-type="java.lang.Integer"/>
</rabbit:queue-arguments>
</rabbit:queue>
<!-- 声明正常交换机,并绑正常队列 -->
<rabbit:topic-exchange name="test_exchange_dlx">
<rabbit:bindings>
<rabbit:binding pattern="test.dlx.#" queue="test_queue-dlx"/>
</rabbit:bindings>
</rabbit:topic-exchange>
<!-- 声明死信队列 -->
<rabbit:queue name="queue_dlx" id="queue_dlx"/>
<rabbit:topic-exchange name="exchange_dlx">
<rabbit:bindings>
<rabbit:binding pattern="dlx.#" queue="queue_dlx"/>
</rabbit:bindings>
</rabbit:topic-exchange>
<rabbit:template id="rabbitTemplate" connection-factory="connectionFactory"/>
- 注释队列长度属性,打开 TTL 属性,测试消息过期而进入死信队列,观察后台数据的变化,10 秒后,test_queue_dlx 的消息清零,queue_dlx 新增了一条消息
//测试过期时间
@Test
public void testTTL() {
rabbitTemplate.convertAndSend("test_exchange_dlx", "test.dlx.hello", "测试过期消息进入死信队列");
}
- 注释 TTL 属性,打开队列长度属性,在管理界面将 test_queue_dlx 队列删除,测试消息过多超出队列长度,而进入死信队列
//测试队列长度
@Test
public void testLength() {
for (int i = 0; i < 11; i++) {
rabbitTemplate.convertAndSend("test_exchange_dlx", "test.dlx.hello", "测试过期消息进入死信队列" + i);
}
}
我们发现,test_queue_dlx 有 10 条消息,queue_dlx 有 1 条消息
点进 queue_dlx 队列查看该条消息,发现是最先发布的消息进入死信队列
- 注释 ttl 和队列长度两个属性,删除 test_queue_dlx,测试拒收消息,从而进入死信队列
先在 test_queue_dlx 中预先发布一条消息,然后通过消费者拒收进入死信队列
在后台查看消息,发现死信队列多了一条消息
小结
- 死信队列和死信交换机就是普通的交换机,只不过这个交换机负责将死信路由到死信队列,
- 我们可以声明任何一个交换机和队列,并通过
x-dead-letter-exchange
和x-dead-letter-routing-key
绑定到正常队列上,则这一组交换机和队列,就可以称为死信交换机/队列 - 当消息称为死信之后,如果该队列绑定了死信交换机,则该消息就会被死信交换机重新路由到死信队列
- 消息称为死信的三种情况:
- 消息数量超过队列长度(最先发布的消息会被挤出队列);
- 消费者消费失败,没有让消息重回队列(requeue=false);
- 消息过期;
6、延迟队列
延迟队列存储的对象是对应的延时消息,所谓”延时消息”是指当消息被发送以后,并不想让消费者立即拿到消息,而是等待指定时间后,消费者才拿到这个消息进行消费。
场景:在订单系统中,一个用户下单之后通常有30分钟的时间进行支付,如果30分钟之内没有支付成功,那么这个订单将进行取消处理。这时就可以使用延时队列将订单信息发送到延时队列。
需求:
- 下单后,30分钟未支付,取消订单,回滚库存。
- 新用户注册成功30分钟后,发送短信问候。
很可惜在 RabbitMQ 中没有延迟队列这个功能,但我们可以通过 TTL+死信队列 的方式实现演示队列的效果
正常队列是没有任何消费者监听的,正常队列设置过期时间 30 分钟,等待消息过期后将消息转发给死信交换机,路由给死信队列,消费者监听死信队列来实现 30 分钟延迟
六、RabbitMQ 集成 SpringBoot
之后会出一篇 SpringBoot 整合 RabbitMQ 高级特性篇
<!--
1. 父工程依赖
-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.2.RELEASE</version>
</parent>
<dependencies>
<!--2. rabbitmq-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
spring:
rabbitmq:
host: 192.168.137.118
port: 5672
username: admin
password: 123456
virtual-host: /
1、生产者
配置类
@Configuration
public class RabbitmqConfig {
public static final String EXCHANGE_NAME = "boot_topic_exchange";
public static final String QUEUE_NAME = "boot_queue";
// 交换机
@Bean("bootExchange")
public Exchange bootExchange(){
return ExchangeBuilder.topicExchange(EXCHANGE_NAME).durable(true).build();
}
// 队列
@Bean("bootQueue")
public Queue bootQueue(){
return QueueBuilder.durable(QUEUE_NAME).build();
}
// 交换机和队列绑定
@Bean
public Binding bindingQueueExchange(@Qualifier("bootQueue") Queue queue,@Qualifier("bootExchange") Exchange exchange){
return BindingBuilder.bind(queue).to(exchange).with("boot.#").noargs();
}
}
创建主启动类
创建测试程序,发布消息
@RunWith(SpringRunner.class)
@SpringBootTest
public class ProducerTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSend() {
rabbitTemplate.convertAndSend(RabbitmqConfig.EXCHANGE_NAME, "boot.message", "topic exchange message");
}
}
2、消费者
监听器
@Component
public class RabbitmqListener {
@RabbitListener(queues = "boot_queue")
public void listenerQueue(Message message){
System.out.println(new String(message.getBody()));
}
}
创建主启动类,主启动类启动时会创建容器自动加载监听器,消费
七、 消息的百分百投递
谈到消息的可靠性投递,无法避免的,在实际工作中我们会经常遇到。比如一些核心的业务(订单业务)需要保障消息不丢失。接下来我们看一个可靠性投递的流程,说明可靠性投递的概念:
一下就以订单系统为例,说明百分百投递
正确投递
Step1
Sender:订单系统(生产者)。用户在下单之后,首先将业务数据存储到数据库(BIZ DB)中,紧接着将消息记录也存储到消息记录表(MSG DB)中。之后,就可以发送消息到 MQ Broker 了。
在消息记录表中会记录消息的状态:0(未投递),1(成功投递),2(不可投递);
Step2、3、4
Sender(生产者)将消息发送给 Broker ,以 confirm 方式发送,保证消息的可靠投递。Sender 接受到 Broker 的 ack 之后就可以将消息记录表中的消息状态设置为 1。投递成功
那什么时候会投递失败呢?
在 Step 3 的时候,Sender 成功将消息发送给 Broker 了。但是 Sender 在接受 ack 的时候网络突然闪断,导致没有接受到 Broker 反馈的 ack。这个时候消息就会投递失败!!!
如何保证消息的百分百投递?
我们增加一个定时任务。定时查询消息记录表(MSG DB)中 status = 0 的消息,重新发送这些消息给 MQ Broker,重新投递,判断 ack,修改订单状态…;
可能一条消息,重复发送了多次,也没有成功投递(只要消息状态为 0,就会定时重复投递)。原因是,上面说的生产者在接受 ack 的时候网络闪断,导致没有收到 ack,从而不能修改订单状态。
当我们将一条消息重复投递了 3 次,还没有成功投递,就可以将消息的状态设置为 2(不可投递)。将来可以进行人工排查
那么问题来了
在 Sender 端,我们可能会因为投递失败导致同一个消息重复发送给 Broker,这样在 Broker 端就会接受到多条相同的消息;
而消费者在取消息的时候又会重复取到多条相同的消息,进行业务处理(例如:减少商品库存,增加销量等等);
这样因为我们的重复投递,就会导致一个订单重复减少了多次库存,这显然是不可行的。所以我们要在 Broker 端做一个 幂等性 操作,防止重复处理相同业务
幂等性操作
我们可以通过 乐观锁 来保证消息不被重复处理。在消息记录表中,我们记录着消息的 id,可以通过消息的 id,来控制消息的版本号;实现幂等性
以上就是百分百投递的流程,从成功投递,投递失败,定时投递,消息重复处理等几个方面来理解。
数据库设计
-- 消息记录表
DROP TABLE IF EXISTS `broker_message_log`;
CREATE TABLE `broker_message_log` (
`message_id` varchar(255) NOT NULL COMMENT '消息唯一ID',
`message` varchar(4000) NOT NULL COMMENT '消息内容',
`try_count` int(4) DEFAULT '0' COMMENT '重试次数',
`status` varchar(10) DEFAULT '' COMMENT '消息投递状态 0投递中,1投递成功,2投递失败',
`next_retry` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '下一次重试时间',
`create_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,
`update_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 业务表(订单)
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2018091102 DEFAULT CHARSET=utf8;
八、RabbitMQ 集群搭建
一般来说,如果只是为了学习 RabbitMQ 或者验证业务工程的正确性那么在本地环境或者测试环境上使用其单实例部署就可以了,但是出于MQ中间件本身的可靠性、并发性、吞吐量和消息堆积能力等问题的考虑,在生产环境上一般都会考虑使用RabbitMQ的集群方案。
1、集群方案的原理
RabbitMQ 这款消息队列中间件产品本身是基于 Erlang 编写,Erlang 语言天生具备分布式特性(通过同步 Erlang 集群各节点的 magic cookie 来实现)。因此,RabbitMQ 天然支持 Clustering。这使得 RabbitMQ 本身不需要像 ActiveMQ、Kafka 那样通过 ZooKeeper 分别来实现HA方案和保存集群的元数据。集群是保证可靠性的一种方式,同时可以通过水平扩展以达到增加消息吞吐量能力的目的。
在 RabbitMQ 集群中我们创建用户,为用户配置权限等都会自动同步,但是队列、消息的同步需要我们配置高可用策略
2、单机多实例部署
由于某些因素的限制,只能在一台机器上去搭建一个 rabbitmq 集群,这个有点类似 zookeeper 的单机版。真实生成环境还是要配成多机集群的。有关怎么配置多机集群的可以参考其他的资料,这里主要论述如何在单机中配置多个rabbitmq实例。
参考官方文档即可:https://www.rabbitmq.com/clustering.html
-- 首先确保 RabbitMQ 运行没有问题
systemctl start rabbitmq-server.service
systemctl status rabbitmq-server.service
-- 停止 RabbitMQ 服务
systemctl stop rabbitmq-server.service
-- 启动三个节点做集群演示,分别访问,看看是否成功启动(可以远程,也可以本地)
-- 192.168.xxx.xxx:15672
-- 192.168.xxx.xxx:15673
-- 192.168.xxx.xxx:15674
-- RABBITMQ_NODE_PORT=5672:节点的端口号
-- RABBITMQ_NODENAME=rabbit1:节点的名称
-- RABBITMQ_SERVER_START_ARGS="-rabbitmq_management listener [{port,15672}]":传入的参数
-- -detached:独立启动,不做集群
RABBITMQ_NODE_PORT=5672 RABBITMQ_NODENAME=rabbit1 RABBITMQ_SERVER_START_ARGS="-rabbitmq_management listener [{port,15672}]" rabbitmq-server -detached
RABBITMQ_NODE_PORT=5673 RABBITMQ_NODENAME=rabbit2 RABBITMQ_SERVER_START_ARGS="-rabbitmq_management listener [{port,15673}]" rabbitmq-server -detached
RABBITMQ_NODE_PORT=5674 RABBITMQ_NODENAME=rabbit3 RABBITMQ_SERVER_START_ARGS="-rabbitmq_management listener [{port,15674}]" rabbitmq-server -detached
-- 停止服务的命令,别执行
-- -n:节点名称
rabbitmqctl -n rabbit1 stop
为了方便远程访问我们可以新建一个管理员账号,至于虚拟主机的权限用 新增的管理账号进入系统,自己添加即可
rabbitmqctl add_user Username Password
rabbitmqctl -n rabbit1 set_user_tags Username administrator
以下演示集群搭建
-- rabbit1 作为主节点
-- 注:rabbitmqctl -n rabbit1 stop,这个命令是关闭节点,下面的是关闭应用容器
-- 如果执行了 rabbitmqctl -n rabbit1 stop,在执行下面的就报找不到该节点,要重新启动
rabbitmqctl -n rabbit1 stop_app
rabbitmqctl -n rabbit1 reset
rabbitmactl -n rabbit1 start_app
-- rabbit2 加入 rabbit1 的集群
rabbitmqctl -n rabbit2 stop_app
rabbitmqctl -n rabbit2 reset
rabbitmqctl -n rabbit2 join_cluster rabbit1
rabbitmactl -n rabbit2 start_app
-- rabbit3
rabbitmqctl -n rabbit3 stop_app
rabbitmqctl -n rabbit3 reset
rabbitmqctl -n rabbit3 join_cluster rabbit1
rabbitmactl -n rabbit3 start_app
-- 查看集群状态
rabbitmqctl -n rabbit1 cluster_status
3、集群管理命令
-- 将节点加入指定集群中。在这个命令执行前需要停止 RabbitMQ 应用并重置节点。
rabbitmqctl join_cluster {cluster_node} [–ram]
-- 也可以 -n 指定将哪个节点加入到哪个集群中.
rabbitmqctl -n node_name join_cluster {cluster_node} [–ram]
-- 显示集群的状态。
rabbitmqctl cluster_status
-- 修改集群节点的类型。在这个命令执行前需要停止 RabbitMQ 应用。
-- disc:数据持久化到硬盘;ram:数据持久化到内存
rabbitmqctl change_cluster_node_type {disc|ram}
-- 将节点从集群中删除,允许离线执行。
rabbitmqctl forget_cluster_node [–offline]
-- 在集群中的节点应用启动前咨询clusternode节点的最新信息,
-- 并更新相应的集群信息。这个和join_cluster不同,它不加入集群。
-- 考虑这样一种情况,节点A和节点B都在集群中,当节点A离线了,节点C
-- 又和节点B组成了一个集群,然后节点B又离开了集群,当A醒来的时候,
-- 它会尝试联系节点B,但是这样会失败,因为节点B已经不在集群中了。
rabbitmqctl update_cluster_nodes {clusternode}
-- 取消队列queue同步镜像的操作。
-- 如果取消队列的镜像操作,队列就不会自动同步了
rabbitmqctl cancel_sync_queue [-p vhost] {queue}
-- 设置集群名称。集群名称在客户端连接时会通报给客户端。
-- Federation 和 Shovel 插件也会有用到集群名称的地方。
-- 集群名称默认是集群中第一个节点的名称,通过这个命令可以重新设置。
rabbitmqctl set_cluster_name {name}
4、RabbitMQ 镜像集群配置
上面已经完成 RabbitMQ 默认集群模式,但并不保证队列的高可用性,尽管交换机、绑定这些可以复制到集群里的任何一个节点,但是队列内容不会复制。虽然该模式解决了项目组节点压力,但队列节点宕机直接导致该队列无法应用,只能等待重启,所以要想在队列节点宕机或故障也能正常应用,就要复制队列内容到集群里的每个节点,必须要创建镜像队列。
镜像队列是基于普通的集群模式的,然后再添加一些策略,所以你还是得先配置普通集群,然后才能设置镜像队列,我们就以上面的集群接着做。
通过 Admin-> Policies,添加策略
- Name:策略名称
- Pattern:匹配的规则,如果是匹配所有的队列,是^.
- Definition:使用 ha-mode(ha:high available) 模式中的 all,也就是同步所有匹配的队列。问号链接帮助文档。
5、HAProxy 实现负载均衡
HAProxy 提供高可用性、负载均衡以及基于 TCP 和 HTTP 应用的代理,支持虚拟主机,它是免费、快速并且可靠的一种解决方案,包括 Twitter,Reddit,StackOverflow,GitHub 在内的多家知名互联网公司在使用。HAProxy 实现了一种事件驱动、单一进程模型,此模型支持非常大的并发连接数。
5.1、安装 HAProxy
安装工具,提取码:tth4
// 下载依赖包
yum install gcc vim wget
// 用 xftp 上传 haproxy 源码包; -C 解压到指定的目录
tar -zxvf haproxy-1.6.5.tar.gz -C /usr/local
// 进入目录、进行编译、安装
cd /usr/local/haproxy-1.6.5
// make 表示编译;TARGET=linux31 表示CentOS7系统;PREFIX=/usr/local/haproxy指定安装路径
// TARGET=linux310,内核版本,使用uname -r查看内核,如:3.10.0-514.el7,此时该参数就为linux310;
make TARGET=linux310 PREFIX=/usr/local/haproxy
make install PREFIX=/usr/local/haproxy
mkdir /etc/haproxy
// 上传 haproxy.cfg 到 /etc/haproxy
// 添加用户组:-r 创建一个系统组;-g 组ID
groupadd -r -g 149 haproxy
// 添加用户:-g 新账户组的名称;-r 创建一个系统用户;-s 新用户的登录shell; -u 新账户的用户ID
useradd -g haproxy -r -s /sbin/nologin -u 149 haproxy
// 编辑 haproxy 配置文件
vim /etc/haproxy/haproxy.cfg
haproxy.cfg 介绍
#全局配置
global
#日志输出配置,所有日志都记录在本机,通过local0输出
log 127.0.0.1 local0 info
#最大连接数
maxconn 5120
#改变当前的工作目录
chroot /usr/local/haproxy
#以指定的UID运行haproxy进程
uid 99
#以指定的GID运行haproxy进程
gid 99
#以守护进程方式运行haproxy
daemon
quiet
nbproc 20
#当前进程PID文件
pidfile /var/run/haproxy.pid
#默认配置
defaults
#应用全局的日志配置
log global
#默认的模式mode{tcp|http|health}
mode tcp
#日志类别
option tcplog
#不记录检查检查日志信息
option dontlognull
#3次失败则认为服务不可用
retries 3
option redispatch
#每个进程可用的最大连接数
maxconn 2000
#连接超时
contimeout 5s
#客户端超时
clitimeout 60s
#服务端超时
srvtimeout 15s
#绑定配置
listen rabbitmq_cluster
#绑定 *(所有节点端口)到 5677,所以这个 5677 就相当于 Nginx 中的 80 端口。
bind *:5677
#配置TCP模式
mode tcp
#balance url_param userid
#balance url_param session_id check_post 64
#balance hdr(User-Agent)
#balance hdr(host)
#balance hdr(Host) use_domain_only
#balance rdp-cookie
#balance leastconn
#balance source //ip
#简单的轮询,其他自行查询
balance roundrobin
#server rabbit1 定义服务内部标识,
#127.0.0.1:5672 服务连接IP和端口,
#check inter 5000 定义每隔多少毫秒检查服务是否可用(心跳机制)
#rise 2 服务故障后需要多少次检查才能被再次确认可用(服务故障之后,如果检查了 2 次,都没问题才可再次使用)
#fall 2 经历多次失败的检查检查后,haproxy才会停止使用此服务
#weight 1 定义服务权重
#写自己虚拟主机的 ip
server rabbit1 192.168.137.118:5672 check inter 5000 rise 2 fall 2 weight 1
server rabbit2 192.168.137.118:5673 check inter 5000 rise 2 fall 2 weight 1
server rabbit3 192.168.137.118:5674 check inter 5000 rise 2 fall 2 weight 1
#haproxy监控页面地址
listen stats
#写自己虚拟主机的 ip 地址,端口不用改
bind 192.168.137.118:8100
mode http
option httplog
stats enable
stats uri /rabbitmq-stats
stats refresh 5s
启动 HAProxy 负载
/usr/local/haproxy/sbin/haproxy -f /etc/haproxy/haproxy.cfg
查看 HAProxy 进程
ps -ef | grep haproxy
访问如下地址对mq节点进行监控,自己虚拟主机的 ip
http://192.168.137.118:8100/rabbitmq-stats
监控页面
如何在 SpringBoot 项目中使用呢?
只需要在 yml 中,配置代理端口即可
spring:
rabbitmq:
host: 192.168.137.118
port: 5677
username: admin
password: admin
virtual-host: /
#当然我们不用配置 HAProxy 也可以实现,只需要将 host、port 换成如下即可
#addresses: 192.168.137.118:5672,192.168.137.118:5673,192.168.137.118:5674