RabbitMQ
原理
在消息队列中,通常有生产者和消费者两个角色。生产者只负责发送数据到消息队列,谁从消息队列中取出数据处理,他不管。消费者只负责从消息队列中取出数据处理,他不管这是谁发送的数据。
- Broker:消息队列服务进程。此进程包括两个部分:Exchange和Queue。
- Exchange:消息队列交换机。按一定的规则将消息路由转发到某个队列。
- Queue:消息队列,存储消息的队列。
- Producer:消息生产者。生产方客户端将消息同交换机路由发送到队列中。
- Consumer:消息消费者。消费队列中存储的消息。
- 解耦。假设有系统B、C、D都需要系统A的数据,于是系统A调用三个方法发送数据到B、C、D。这时,系统D不需要了,那就需要在系统A把相关的代码删掉。假设这时有个新的系统E需要数据,这时系统A又要增加调用系统E的代码。为了降低这种强耦合,就可以使用MQ,系统A只需要把数据发送到MQ,其他系统如果需要数据,则从MQ中获取即可。
- 吞吐提升。一个客户端请求发送进来,系统A会调用系统B、C、D三个系统,同步请求的话,响应时间就是系统A、B、C、D的总和,也就是800ms。如果使用MQ,系统A发送数据到MQ,然后就可以返回响应给客户端,不需要再等待系统B、C、D的响应,可以大大地提高性能。对于一些非必要的业务,比如发送短信,发送邮件等等,就可以采用MQ。
- 削峰。如图所示。这其实是MQ一个很重要的应用。假设系统A在某一段时间请求数暴增,有5000个请求发送过来,系统A这时就会发送5000条SQL进入MySQL进行执行,MySQL对于如此庞大的请求当然处理不过来,MySQL就会崩溃,导致系统瘫痪。如果使用MQ,系统A不再是直接发送SQL到数据库,而是把数据发送到MQ,MQ短时间积压数据是可以接受的,然后由消费者每次拉取2000条进行处理,防止在请求峰值时期大量的请求直接发送到MySQL导致系统崩溃。
部署
先去拉去docker镜像文件,然后保存进行load -i mq.tar,然后运行
ini
docker run \
-e RABBITMQ_DEFAULT_USER=hwoss\
-e RABBITMQ_DEFAULT_PASS=1234 \ #登录管理页面的账号密码
--name mq \
--hostname mq1 \ #mq主机名
-p 15672:15672 \ #这里是管理端口
-p 5672:5672 \ #通信端口
-d rabbitmq:3-management
启动后访问管理页面
在上面基本可以看到mq的所有信息,以及用户创建,权限等信息,在引入一个概念Vistual Host(虚拟主机),每个用户都最好有一个虚拟主机,这样可以做到隔离。默认管理的虚拟主机是 /
集群部署
在RabbitMQ的官方文档中,讲述了两种集群的配置方式:
- 普通模式:普通模式集群不进行数据同步,每个MQ都有自己的队列、数据信息(其它元数据信息如交换机等会同步)。例如我们有2个MQ:mq1,和mq2,如果你的消息在mq1,而你连接到了mq2,那么mq2会去mq1拉取消息,然后返回给你。如果mq1宕机,消息就会丢失。
- 镜像模式:与普通模式不同,队列会在各个mq的镜像节点之间同步,因此你连接到任何一个镜像节点,均可获取到消息。而且如果一个节点宕机,并不会导致数据丢失。不过,这种方式增加了数据同步的带宽消耗。
首先,我们需要让3台MQ互相知道对方的存在。
分别在3台机器中,设置 /etc/hosts文件,添加如下内容:
192.168.150.101 mq1
192.168.150.102 mq2
192.168.150.103 mq3
并在每台机器上测试,是否可以ping通对方:
集群中的节点标示默认都是:rabbit@[hostname]
,因此以上三个节点的名称分别为:
- rabbit@mq1
- rabbit@mq2
- rabbit@mq3
获取cookie
RabbitMQ底层依赖于Erlang,而Erlang虚拟机就是一个面向分布式的语言,默认就支持集群模式。集群模式中的每个RabbitMQ 节点使用 cookie 来确定它们是否被允许相互通信。
要使两个节点能够通信,它们必须具有相同的共享秘密,称为Erlang cookie。cookie 只是一串最多 255 个字符的字母数字字符。
每个集群节点必须具有相同的 cookie。实例之间也需要它来相互通信。
我们先在之前启动的mq容器中获取一个cookie值,作为集群的cookie。执行下面的命令:
bash
docker exec -it mq cat /var/lib/rabbitmq/.erlang.cookie
接下来,停止并删除当前的mq容器,我们重新搭建集群。
bash
docker rm -f mq
准备集群配置
在/tmp目录新建一个配置文件 rabbitmq.conf:
bash
cd /tmp
# 创建文件
touch rabbitmq.conf
文件内容如下:
conf
loopback_users.guest = false
listeners.tcp.default = 5672
cluster_formation.peer_discovery_backend = rabbit_peer_discovery_classic_config
cluster_formation.classic_config.nodes.1 = rabbit@mq1
cluster_formation.classic_config.nodes.2 = rabbit@mq2
cluster_formation.classic_config.nodes.3 = rabbit@mq3
再创建一个文件,记录cookie
conf
cd /tmp
# 创建cookie文件
touch .erlang.cookie
# 写入cookie
echo "FXZMCVGLBIXZCDEMMVZQ" > .erlang.cookie
# 修改cookie文件的权限
chmod 600 .erlang.cookie
准备三个目录,mq1、mq2、mq3:
bash
cd /tmp
# 创建目录
mkdir mq1 mq2 mq3
然后拷贝rabbitmq.conf、cookie文件到mq1、mq2、mq3:
bash
# 进入/tmp
cd /tmp
# 拷贝
cp rabbitmq.conf mq1
cp rabbitmq.conf mq2
cp rabbitmq.conf mq3
cp .erlang.cookie mq1
cp .erlang.cookie mq2
cp .erlang.cookie mq3
创建一个网络:
lua
docker network create mq-net
运行命令
注意下面的name和hostname要去进行一个切换
diff
docker run -d --net mq-net \
-v ${PWD}/mq1/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v ${PWD}/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
-e RABBITMQ_DEFAULT_USER=itcast \
-e RABBITMQ_DEFAULT_PASS=123321 \
--name mq1 \
--hostname mq1 \
-p 8071:5672 \
-p 8081:15672 \
rabbitmq:3.8-management
镜像模式
普通的集群模式一当节点宕机那么就会丢失数据
默认情况下,队列只保存在创建该队列的节点上。而镜像模式下,创建队列的节点被称为该队列的主节点 ,队列还会拷贝到集群中的其它节点,也叫做该队列的镜像节点。
但是,不同队列可以在集群中的任意节点上创建,因此不同队列的主节点可以不同。甚至,一个队列的主节点可能是另一个队列的镜像节点。
用户发送给队列的一切请求,例如发送消息、消息回执默认都会在主节点完成,如果是从节点接收到请求,也会路由到主节点去完成。镜像节点仅仅起到备份数据作用。
当主节点接收到消费者的ACK时,所有镜像都会删除节点中的数据。
总结如下:
- 镜像队列结构是一主多从(从就是镜像)
- 所有操作都是主节点完成,然后同步给镜像节点
- 主宕机后,镜像节点会替代成新的主(如果在主从同步完成前,主就已经宕机,可能出现数据丢失)
- 不具备负载均衡功能,因为所有操作都会有主节点完成(但是不同队列,其主节点可以不同,可以利用这个提高吞吐量)
镜像模式的配置
镜像模式的配置有3种模式:
ha-mode | ha-params | 效果 |
---|---|---|
准确模式exactly | 队列的副本量count | 集群中队列副本(主服务器和镜像服务器之和)的数量。count如果为1意味着单个副本:即队列主节点。count值为2表示2个副本:1个队列主和1个队列镜像。换句话说:count = 镜像数量 + 1。如果群集中的节点数少于count,则该队列将镜像到所有节点。如果有集群总数大于count+1,并且包含镜像的节点出现故障,则将在另一个节点上创建一个新的镜像。 |
all | (none) | 队列在群集中的所有节点之间进行镜像。队列将镜像到任何新加入的节点。镜像到所有节点将对所有群集节点施加额外的压力,包括网络I / O,磁盘I / O和磁盘空间使用情况。推荐使用exactly,设置副本数为(N / 2 +1)。 |
nodes | node names | 指定队列创建到哪些节点,如果指定的节点全部不存在,则会出现异常。如果指定的节点在集群中存在,但是暂时不可用,会创建节点到当前客户端连接到的节点。 |
这里我们以rabbitmqctl命令作为案例来讲解配置语法。
语法示例:
exactly模式
json
rabbitmqctl set_policy ha-two "^two." '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
-
rabbitmqctl set_policy
:固定写法 -
ha-two
:策略名称,自定义 -
"^two."
:匹配队列的正则表达式,符合命名规则的队列才生效,这里是任何以two.
开头的队列名称 -
'{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
: 策略内容"ha-mode":"exactly"
:策略模式,此处是exactly模式,指定副本数量"ha-params":2
:策略参数,这里是2,就是副本数量为2,1主1镜像"ha-sync-mode":"automatic"
:同步策略,默认是manual,即新加入的镜像节点不会同步旧的消息。如果设置为automatic,则新加入的镜像节点会把主节点中所有消息都同步,会带来额外的网络开销
all模式
css
rabbitmqctl set_policy ha-all "^all." '{"ha-mode":"all"}'
-
ha-all
:策略名称,自定义 -
"^all."
:匹配所有以all.
开头的队列名 -
'{"ha-mode":"all"}'
:策略内容"ha-mode":"all"
:策略模式,此处是all模式,即所有节点都会称为镜像节点
nodes模式
json
rabbitmqctl set_policy ha-nodes "^nodes." '{"ha-mode":"nodes","ha-params":["rabbit@nodeA", "rabbit@nodeB"]}'
-
rabbitmqctl set_policy
:固定写法 -
ha-nodes
:策略名称,自定义 -
"^nodes."
:匹配队列的正则表达式,符合命名规则的队列才生效,这里是任何以nodes.
开头的队列名称 -
'{"ha-mode":"nodes","ha-params":["rabbit@nodeA", "rabbit@nodeB"]}'
: 策略内容"ha-mode":"nodes"
:策略模式,此处是nodes模式"ha-params":["rabbit@mq1", "rabbit@mq2"]
:策略参数,这里指定副本所在节点名称
运行下面的命令:
json
docker exec -it mq1 rabbitmqctl set_policy ha-two "^two." '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
创建一个新的队列注意要和正则表达式进行匹配就可以测试,当节点宕机也不会丢失数据
仲裁队列
从RabbitMQ 3.8版本开始,引入了新的仲裁队列,他具备与镜像队列类似的功能,但使用更加方便。
添加仲裁队列
在任意控制台添加一个队列,一定要选择队列类型为Quorum类型。
在任意控制台查看队列:
可以看到,仲裁队列的 + 2字样。代表这个队列有2个镜像节点。
因为仲裁队列默认的镜像数为5。如果集群有7个节点,那么镜像数肯定是5;而我们集群只有3个节点,因此镜像数量就是3.
集群扩容
1)启动一个新的MQ容器:
diff
docker run -d --net mq-net \
-v ${PWD}/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
-e RABBITMQ_DEFAULT_USER=itcast \
-e RABBITMQ_DEFAULT_PASS=123321 \
--name mq4 \
--hostname mq5 \
-p 8074:15672 \
-p 8084:15672 \
rabbitmq:3.8-management
2)进入容器控制台:
bash
docker exec -it mq4 bash
3)停止mq进程
rabbitmqctl stop_app
4)重置RabbitMQ中的数据:
perl
rabbitmqctl reset
5)加入mq1:
css
rabbitmqctl join_cluster rabbit@mq1
6)再次启动mq进程
rabbitmqctl start_app
增加仲裁队列副本
我们先查看下quorum.queue这个队列目前的副本情况,进入mq1容器:
bash
docker exec -it mq1 bash
执行命令:
arduino
rabbitmq-queues quorum_status "quorum.queue"
结果:
现在,我们让mq4也加入进来:
perl
rabbitmq-queues add_member "quorum.queue" "rabbit@mq4"
查看控制台,发现quorum.queue的镜像数量也从原来的 +2 变成了 +3
基本消息队列
首先建立链接工厂,通过链接工厂后去通道channel,利用channel去声明队列,在利用channe向队列发送信息
接收端同样的工作,获取到channel后也要进行一个声明队列,这样可以保证消费的时候有队列,默认情况是会消费队列的时候直接如果队列不存在是会报错的,因此在消费者也需要去声明队列,然后定义consumer的消费行为new DefaultConsumer 后去重写handleDelivery方法,利用channel将消费者和队列进行一个绑定。
生产者:
java
public class PublisherTest {
@Test
public void testSendMessage() throws IOException, TimeoutException {
// 1.建立连接工厂
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、virtualhost、用户名、密码
factory.setHost("192.168.37.128");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("hwoss");
factory.setPassword("1234");//后面用配置文件
// 1.2.通过连接工厂建立连接
Connection connection = factory.newConnection();
// 2.创建通道Channel
Channel channel = connection.createChannel();
// 3.声明队列
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);
// 4.发送消息
String message = "hello, rabbitmq!";
channel.basicPublish("", queueName, null, message.getBytes()); //基本消息发布
System.out.println("发送消息成功:【" + message + "】");
// 5.关闭通道和连接
channel.close();
connection.close();
}
}
消费者:
java
public class ConsumerTest {
public static void main(String[] args) throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("192.168.37.128");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("hwoss");
factory.setPassword("1234");
// 1.2.通过连接工厂建立连接
Connection connection = factory.n ewConnection();
// 2.创建通道Channel
Channel channel = connection.createChannel();
// 3.消费端也要声明队列
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);
// 4.订阅消息
channel.basicConsume(queueName, true, new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope,
AMQP.BasicProperties properties, byte[] body) throws IOException {
// 5.处理消息
String message = new String(body); //消息接受的是字节流,发送的时候也要字节传输,获取后转换
System.out.println("接收到消息:【" + message + "】");
}
});//异步的调用方法,因此下面的打印会优先
System.out.println("等待接收消息。。。。");
}
}
SpringAMQP
引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
配置信息:
yml
spring:
rabbitmq:
host: 192.168.37.128
port: 5672
virtual-host: /
username: itcast
password: 123321
利用RabbitTemplate发送信息:
这里需要注意的是convertAndSend方法是需要有队列才能生效的
java
@SpringBootTest
@RunWith(SpringRunner.class)
public class SpringAmqTest {
@Autowired
private RabbitTemplate RabbitTemplate;
@Test
public void Test(){
String queueName = "simple.queue";
String message = "hello world";
RabbitTemplate.convertAndSend(queueName, message);
System.out.println("send!");
}
}
接受信息:
java
@Component //这可以让spring建立的时候就直接运行,然后启动spring项目就行
public class ConsumerAmqListener {
@Autowired
private RabbitTemplate rabbitTemplate;
@RabbitListener(queues = "simple.queue") //这个就很重要的注解RabbitListener
public void Test(String msg) throws InterruptedException{
System.out.println("接受信息:"+msg); //会自动消费队列里面所有信息
}
}
WorkQueue
这个就是原有的队列不变,增加多几个消费者的模型,作用是缓解单个压力,意义不大,但是要引入一个概念要消息预取
它定义了在一个信道上,消费者允许的最大未确认的消息数量,并且多个消费者的预取机制是负载均衡的(体现为平均)
消息预取机制避免了RabbitMQ一直向消费端发送消息,导致消费者端出现缓存爆炸的问题
yaml
spring:
rabbitmq:
addresses: 101.14.111.15:5672
username: guest
password: guest
dynamic: true
listener:
simple:
acknowledge-mode: manual
prefetch: 2 #这里就是消费者对消息预取的设置,每次取两条
Fanout Exchange(广播)
这种类型的交换机需要将队列绑定到交换机上。一个发送到交换机的消息都会被转发到与该交换机绑定的所有队列上 。很像子网广播,每台子网内的主机都获得了一份复制的消息。简单点说就是发布订阅。但是不能缓存信息,如果路由失败信息丢失
这里对类的继承实现进行一个分析,可以看到Exchange的接口有了实现类后就分别对四种类型进行发呢别继承Exchange后编写自己的逻辑,从思路实现来说不算难
现在演示一个exhange去绑定两个队列
java
@Configuration
public class FanoutConfig {
// 声明交换机
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange("woss.fanout");
}
// 声明队列
@Bean
public Queue fanoutQueue1(){ //spring把当前方法名放进容器种,当然类型也有
return new Queue("fanout.queue1");
}
@Bean
public Queue fanoutQueue2(){
return new Queue("fanout.queue2");
}
// 绑定
@Bean
public Binding binding1(Queue fanoutQueue1,FanoutExchange fanoutExchange){
//这里获取的方法名和自动装配的属性名要一样
return BindingBuilder
.bind(fanoutQueue1)
.to(fanoutExchange);
}
@Bean
public Binding binding2(Queue fanoutQueue2,FanoutExchange fanoutExchange){
return BindingBuilder
.bind(fanoutQueue2)
.to(fanoutExchange);
}
}
可以看到两个队列被绑定到上面,那么接下来启动测试消费类:
java
@Test
public void Test1(){
String ExchangeName = "woss.fanout";
String message = "hello everyone";
RabbitTemplate.convertAndSend(ExchangeName,"", message);//这里的中间是routiningkey暂时不管
System.out.println("send!");
}
如果需要启动就先声明交换机和队列,去实现BeanPostProcessor
java
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
//启动项目即创建交换机和队列
rabbitAdmin.declareExchange(rabbitmqDemoFanoutExchange());
rabbitAdmin.declareQueue(fanoutExchangeQueueB());
rabbitAdmin.declareQueue(fanoutExchangeQueueA());
return null;
}
DirectExchange(路由)
直连交换机意思是此交换机需要绑定一个队列,要求该消息与一个特定的路由键完全匹配。简单点说就是点对点的发送。也即是说发送信息的时候会带一个bindingKey进行跟踪,那么路由exchange根据对应的key值分发到对应的queue,当然一个queue绑定的key可以多个,这样就可以去实现广播的功能
这里采用注解的方式去解决,对于@RabbitListener的参数用binding@QueueBinding,然后可以写对应的@Queue,Exchange,key等
java
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(name ="direct.queue1" ),
exchange = @Exchange(name = "woss.direct",type = ExchangeTypes.DIRECT),
key = {"red", "blue"}
))
public void Test4(String msg) throws InterruptedException{
System.out.println("接受信息direct.queue1:"+msg);
}
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(name ="direct.queue2" ),
exchange = @Exchange(name = "woss.direct",type = ExchangeTypes.DIRECT),
key = {"red", "yellow"}
))
public void Test3(String msg) throws InterruptedException{
System.out.println("接受信息direct.queue2:"+msg);
}
java
@Test
public void Test2(){
String ExchangeName = "woss.direct";
String message = "hello everyone";
RabbitTemplate.convertAndSend(ExchangeName,"blue", message); //这里就是路由键值
System.out.println("send!");
}
发送的时候只需要声明好携带的key就可以发送到拥有对应路由键的队列
Topic Exchange(通配符)
直接翻译的话叫做主题交换机,如果从用法上面翻译可能叫通配符交换机会更加贴切。这种交换机是使用通配符去匹配,路由到对应的队列。通配符有两种:"*" 、 "#"。需要注意的是通配符前面必须要加上"."符号。
*
符号:有且只匹配一个词。比如 a.*
可以匹配到"a.b"、"a.c",但是匹配不了"a.b.c"。
#
符号:匹配一个或多个词。比如"rabbit.#"既可以匹配到"rabbit.a.b"、"rabbit.a",也可以匹配到"rabbit.a.b.c"。
java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue1"),
exchange = @Exchange(name = "woss.topic",type = ExchangeTypes.TOPIC),
key = "rabbit.#"
))
public void Test5(String msg) throws InterruptedException{
System.out.println("接受信息topic.queue1:"+msg);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue2"),
exchange = @Exchange(name = "woss.topic",type = ExchangeTypes.TOPIC),
key = "rabbit.*"
))
public void Test6(String msg) throws InterruptedException{
System.out.println("接受信息topic.queue2:"+msg);
}
java
@Test
public void Test3(){
String ExchangeName = "woss.topic";
String message = "hello everyone";
RabbitTemplate.convertAndSend(ExchangeName,"rabbit.a", message); //在这里去写key到时会通过通配符匹配
System.out.println("send!");
}
按照上面的发送后两个都会收到,记得#是匹配多个,*是匹配一个就行了
HeadersExchange(请求)
这种相对前面三种用的不多,他是在是在匹配请求头中所带的键值进行路由,需要麻烦的在发送和接受的时候设置请求头数据
上面的就是请求头的json数据进行判断,然后走自己的匹配就,他有两种匹配,全部匹配和部分匹配
这里只贴一下代码
java
@Bean
public Binding bindHeadersA() {
Map<String, Object> map = new HashMap<>();
map.put("key_one", "java");
map.put("key_two", "rabbit");
//全匹配
return BindingBuilder.bind(headersQueueA())
.to(rabbitmqDemoHeadersExchange())
.whereAll(map).match();
}
@Bean
public Binding bindHeadersB() {
Map<String, Object> map = new HashMap<>();
map.put("headers_A", "coke");
map.put("headers_B", "sky");
//部分匹配
return BindingBuilder.bind(headersQueueB())
.to(rabbitmqDemoHeadersExchange())
.whereAny(map).match();
}
//另一个类只贴主要代码
@Override
public String sendMsgByHeadersExchange(String msg, Map<String, Object> map) throws Exception {
try {
MessageProperties messageProperties = new MessageProperties();
//消息持久化
messageProperties.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
messageProperties.setContentType("UTF-8");
//添加消息
messageProperties.getHeaders().putAll(map);
Message message = new Message(msg.getBytes(), messageProperties);
rabbitTemplate.convertAndSend(RabbitMQConfig.HEADERS_EXCHANGE_DEMO_NAME, null, message);
return "ok";
} catch (Exception e) {
e.printStackTrace();
return "error";
}
}
//...
public String headersSend(@RequestParam(name = "msg") String msg,
@RequestParam(name = "json") String json) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> map = mapper.readValue(json, Map.class);
return rabbitMQService.sendMsgByHeadersExchange(msg, map);
}
消息转换
对于发送的信息,原理上他只是支持字节流的传输,如果强行发送对象或者map他会直接序列化成数据,显然数据差并且安全不高容易注入,如果需要使用可以采用去fastjson的工具帮忙转换成json格式的数据
通过创建的消息转换器去替换掉原本序列化的转换器,这样在这样在传输的时候转换器就会把他变成json格式,这个需要的用的时候再去使用。
消息可靠性
生产者消息确认
RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。这种机制必须给每个消息指定一个唯一ID。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功。这个Id是作为信息的唯一标识,或者说这种确认机制是必须存在这个id
简单点来说就是消息到达交换机没到达队列返回ack,如果没到达交换机就返回nack。然后要区别好confirm和return的两种callback情况,前者是操作交换机是否到达的,后者是对队列的特殊操作。
返回结果有两种方式:
-
publisher-confirm,发送者确认
- 消息成功投递到交换机,返回ack
- 消息未投递到交换机,返回nack
-
publisher-return,发送者回执
- 消息投递到交换机了,但是没有路由到队列。返回ACK,及路由失败原因。
修改application.yml文件,添加下面的内容:
这里就要去添加两种回调函数的使用情况,一种是在return,另一种是异步回调的操作
yml
spring:
rabbitmq:
publisher-confirm-type: correlated
publisher-returns: true
template:
mandatory: true
说明:
-
publish-confirm-type
:开启publisher-confirm,这里支持两种类型:simple
:同步等待confirm结果,直到超时correlated
:异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
-
publish-returns
:开启publish-return功能,同样是基于callback机制,不过是定义ReturnCallback -
template.mandatory
:定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息
定义Return回调
每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目加载时配置
可以采用配置类去实现ApplicationContextAware的bean完成消息发送,获取到所有bean后去获取RabbitTemplate,这样子就可以保证是唯一的,切忌在函数 发送消息的时候去调用,然后设置他的setReturnCallback,里面是一个接口内部类的实现,所以可以采用lamba的方式可以快速实现
修改publisher服务,添加一个:
java
@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
// 获取RabbitTemplate
RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
// 设置ReturnCallback
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
// 投递失败,记录日志
log.info("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}",
replyCode, replyText, exchange, routingKey, message.toString());
});
}
}
定义ConfirmCallback
ConfirmCallback可以在发送消息时指定,因为每个业务处理confirm成功或失败的逻辑不一定相同。流程基本没什么区别就是获取后发送信息,但是其中增加一个步骤就是对CorrelationData的id和添加callback函数在这里
java
@Test
public void testSendMessage2SimpleQueue2() throws InterruptedException {
// 1.消息体
String message = "hello, spring amqp!";
// 2.全局唯一的消息ID,需要封装到CorrelationData中
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
// // 3.添加callback
correlationData.getFuture().addCallback(confirm -> {
if(confirm.isAck()){
// 3.1.ack,消息成功
log.debug("消息发送成功, ID:{}", correlationData.getId());
}else{
// 3.2.nack,消息失败
log.error("消息发送失败, ID:{}, 原因{}",correlationData.getId(), confirm.getReason());
}
}, throwable -> {
log.error("消息发送异常, ID:{}, 原因{}",correlationData.getId(),throwable.getMessage());
});
// 4.发送消息还需传入correlationData
rabbitTemplate.convertAndSend("task.direct", "task", message, correlationData);
}
}
消息持久化
生产者确认可以确保消息投递到RabbitMQ的队列中,但是消息发送到RabbitMQ以后,如果突然宕机,也可能导致消息丢失。
要想确保消息在RabbitMQ中安全保存,必须开启消息持久化机制。
- 交换机持久化
- 队列持久化
- 消息持久化
RabbitMQ中交换机默认是非持久化的,mq重启后就丢失。
SpringAMQP中可以通过代码指定交换机持久化:
java
@Bean
public DirectExchange simpleExchange(){
// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new DirectExchange("simple.direct", true, false);
return new DirectExchange("simple.direct");//默认持久化
}
java
@Bean
public Queue simpleQueue(){
// 使用QueueBuilder构建队列,durable就是持久化的
return QueueBuilder.durable("simple.queue").build();
return new Queue("simple.queue")//默认持久化
}
利用SpringAMQP发送消息时,可以设置消息的属性(MessageProperties),指定delivery-mode:
- 1:非持久化
- 2:持久化
用java代码通过一个MessgaeBuilder去设置消息体和消息模式,也就是工厂模式的构建
默认情况下,SpringAMQP发出的任何消息都是持久化的,不用特意指定。
消费者消息确认
RabbitMQ是阅后即焚机制,RabbitMQ确认消息被消费者消费后会立刻删除。
而RabbitMQ是通过消费者回执来确认消费者是否成功处理消息的:消费者获取消息后,应该向RabbitMQ发送ACK回执,表明自己已经处理消息。
而SpringAMQP则允许配置三种确认模式:
•manual:手动ack,需要在业务代码结束后,调用api发送ack。
•auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
•none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除
都是使用默认的auto即可。
修改consumer服务的application.yml文件:
yaml
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto #这里进行设置
消费失败重试机制
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者,然后再次异常,再次requeue,无限循环,导致mq的消息处理飙升,带来不必要的压力:
本地重试
可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。
修改consumer服务的application.yml文件,添加内容:
yaml
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000 # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval 类似一个等比数列
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试
重试达到最大次数后,Spring会返回ack,消息会被丢弃,因此消息还是会被丢弃。
失败策略
在之前的测试中,达到最大重试次数后,消息会被丢弃,这是由Spring内部机制决定的。
在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecovery接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。
在consumer服务中定义处理失败消息的交换机和队列
java
@Bean
public DirectExchange errorMessageExchange(){
return new DirectExchange("error.direct");
}
@Bean
public Queue errorQueue(){
return new Queue("error.queue", true);
}
@Bean
public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
}
定义一个RepublishMessageRecoverer,关联队列和交换机
java
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
死信交换机
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):
- 消息被消费者reject或者返回nack
- 消息是一个过期消息,超时无人消费
- 要投递的队列消息满了,无法投递
如果这个包含死信的队列配置了dead-letter-exchange
属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机(Dead Letter Exchange,检查DLX)。
一个消息被消费者拒绝了,变成了死信,因为simple.queue绑定了死信交换机 dl.direct,因此死信会投递给这个交换机,则消息最终会进入这个存放死信的队列:
利用死信交换机接收死信
在失败重试策略中,默认的RejectAndDontRequeueRecoverer会在本地重试次数耗尽后,发送reject给RabbitMQ,消息变成死信,被丢弃。
我们可以给simple.queue添加一个死信交换机,给死信交换机绑定一个队列。这样消息变成死信后也不会丢弃,而是最终投递到死信交换机,路由到与死信交换机绑定的队列。
我们在consumer服务中,定义一组死信交换机、死信队列:
java
// 声明普通的 simple.queue队列,并且为其指定死信交换机:dl.direct
@Bean
public Queue simpleQueue2(){
return QueueBuilder.durable("simple.queue") // 指定队列名称,并持久化
.deadLetterExchange("dl.direct") // 指定死信交换机
.build();
}
// 声明死信交换机 dl.direct
@Bean
public DirectExchange dlExchange(){
return new DirectExchange("dl.direct", true, false);
}
// 声明存储死信的队列 dl.queue
@Bean
public Queue dlQueue(){
return new Queue("dl.queue", true);
}
// 将死信队列 与 死信交换机绑定
@Bean
public Binding dlBinding(){
return BindingBuilder.bind(dlQueue()).to(dlExchange()).with("simple");
}
TTL
一个队列中的消息如果超时未消费,则会变为死信,超时分为两种情况:
- 消息所在的队列设置了超时时间
- 消息本身设置了超时时间
死信交换机:
在consumer服务的SpringRabbitListener中,定义一个新的消费者,并且声明 死信交换机、死信队列:
java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "dl.ttl.queue", durable = "true"),
exchange = @Exchange(name = "dl.ttl.direct"),
key = "ttl"
))
public void listenDlQueue(String msg){
log.info("接收到 dl.ttl.queue的延迟消息:{}", msg);
}
声明一个队列,并且指定TTL:
要给队列设置超时时间,需要在声明队列时配置x-message-ttl属性:
java
@Bean
public Queue ttlQueue(){
return QueueBuilder.durable("ttl.queue") // 指定队列名称,并持久化
.ttl(10000) // 设置队列的超时时间,10秒
.deadLetterExchange("dl.ttl.direct") // 指定死信交换机
.build();
}
注意,这个队列设定了死信交换机为dl.ttl.direct
声明交换机,将ttl与交换机绑定:
java
@Bean
public DirectExchange ttlExchange(){
return new DirectExchange("ttl.direct");
}
@Bean
public Binding ttlBinding(){
return BindingBuilder.bind(ttlQueue()).to(ttlExchange()).with("ttl");
}
发送消息,但是不要指定TTL:
java
@Test
public void testTTLQueue() {
// 创建消息
String message = "hello, ttl queue";
// 消息ID,需要封装到CorrelationData中
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
// 发送消息
rabbitTemplate.convertAndSend("ttl.direct", "ttl", message, correlationData);
// 记录日志
log.debug("发送消息成功");
}
因为队列的TTL值是10000ms,也就是10秒。可以看到消息发送与接收之间的时差刚好是10秒。
发送消息时,设定TTL:
在发送消息时,也可以指定TTL:
java
@Test
public void testTTLMsg() {
// 创建消息
Message message = MessageBuilder
.withBody("hello, ttl message".getBytes(StandardCharsets.UTF_8))
.setExpiration("5000") //设置延迟时间
.build();
// 消息ID,需要封装到CorrelationData中
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
// 发送消息
rabbitTemplate.convertAndSend("ttl.direct", "ttl", message, correlationData);
log.debug("发送消息成功");
}
当队列、消息都设置了TTL时,任意一个到期就会成为死信。
惰性队列
消息堆积
当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题。
从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的概念,也就是惰性队列。对比之前的默认情况占据内存40%的时候就会自动写入磁盘,那么写入磁盘的时候就会阻塞生产者消息的进来,容易丢失数据,惰性队列的特征如下:
- 接收到消息后直接存入磁盘而非内存
- 消费者要消费消息时才会从磁盘中读取并加载到内存
- 支持数百万条的消息存储
安装插件
官方的安装指南地址为:blog.rabbitmq.com/posts/2015/...
基于linux原生安装RabbitMQ,然后安装插件。
可以去对应的GitHu页面下载3.8.9版本的插件,地址为github.com/rabbitmq/ra...这个对应RabbitMQ的3.8.5以上版本。
需要先查看RabbitMQ的插件目录对应的数据卷。
设定的RabbitMQ的数据卷名称为mq-plugins
:
docker volume inspect mq-plugins
接下来,将插件上传到目录即可
需要进入MQ容器内部来执行安装。我的容器名为mq
,所以执行下面命令:
bash
docker exec -it mq bash
执行时,请将其中的 -it
后面的mq
替换为自己的容器名.
进入容器内部后,执行下面命令开启插件:
bash
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
结果
命令行设置
而要设置一个队列为惰性队列,只需要在声明队列时,指定x-queue-mode属性为lazy即可。可以通过命令行将一个运行中的队列修改为惰性队列:
json
rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"lazy"}' --apply-to queues
命令解读:
rabbitmqctl
:RabbitMQ的命令行工具set_policy
:添加一个策略Lazy
:策略名称,可以自定义"^lazy-queue$"
:用正则表达式匹配队列的名字'{"queue-mode":"lazy"}'
:设置队列模式为lazy模式--apply-to queues
:策略的作用对象,是所有的队列
Bean声明
通过构造器直接调用lazy方法就可以了
java
@Bean
public Queue lazyQueue(){
return QueueBuilder.durable().lazy().build();
}
RabbitListener声明
里面就是在注解里面增加
java
arguments = @Argument(name = "x-queue-mode",value = "lazy")
以上是对RabbitMQ学习的个人笔记记录。