RabbitMQ高级特性
内容:如何保证消息可靠性,如何发送延迟消息(基于死信队列和DelayExchange插件),使用惰性队列解决消息堆积问题,MQ高可用集群
消息可靠性
RabbitMQ的消息模型中,可能有以下部分发生消息丢失
- 消息在传输路径上丢失,比如发送者到
exchanger
、exchanger
到queue
、queue
到consumer
- 由于MQ宕机,导致
queue
上的消息丢失 consumer
尚未消费消息时宕机
Step1. 生产者确认
类似网络中可靠数据传输协议所采用的方法来解决消息在传输路径上丢失的问题,采用ack和nack来代表消息的成功或失败确认。
当生产者发送一条消息时,根据该消息的实际情况,它会接到RabbitMQ发回的结果:
publisher-confirm
:- 消息已经投送给交换机,返回
ack
- 消息未投送到给交换机,返回
nack
- 消息已经投送给交换机,返回
publisher-return
:- 消息已经投递给交换机,但路由到队列的过程中失败了,返回
ack
和路由失败原因
- 消息已经投递给交换机,但路由到队列的过程中失败了,返回
考虑当一个消息发送给交换机成功了,但发送给队列失败了,此时消息生产者接收到两条消息,一个是
publisher-confirm
的ack
,一个是publisher-return
的ack
类似TCP用seq number
来确认ACK是在回复哪个报文段,RabbitMQ允许为每个消息设置一个唯一的全局ID来确认这是哪一个消息的回执
使用Spring-AMQP实现生产者确认
接收并处理publisher-confirm
publisher-confirm
代表着消息是否被成功发送到交换机上,首先要配置以下publisher-confirm-type
,这里我们使用correlated
spring:
rabbitmq:
# ... other configurations ...
publisher-confirm-type: correlated
然后,我们要给RabbitTemplate
设置ConfirmCallback
,由于RabbitTemplate
是全局唯一的,所以ConfirmCallback
也是全局唯一的,不过你也可以为每一条消息设置自己的Callback。
这里,我们通过实现ApplicationContextAware
,在ApplicationContext初始化完成后获取RabbitTemplate
,并调用其setConfirmCallback
方法为它设置了一个确认回调,来监测消息是否到达了交换机。
@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
RabbitTemplate template = applicationContext.getBean(RabbitTemplate.class);
template.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String correlationId = correlationData == null ? "null" : correlationData.getId();
Message correlationReturnMessage = correlationData == null ? null : correlationData.getReturnedMessage();
if (ack) {
log.info("消息到达交换机,消息ID => {}", correlationId);
} else {
log.info("消息未到达交换机, 消息ID => {},原因:{},消息内容:{}", correlationData.getId(), cause, correlationReturnMessage);
}
}
});
}
}
在上面的代码中,我首先对correlationData
进行了一系列的判空操作,这是因为correlationData
可能为null,只要外界发送消息时没有传递一个CorrelationData
对象时,我们的回调中correlationData
参数就为空。因为这时代表用户并没有绑定一个CorrelationData
到发送的消息上。
发送时可以这样绑定该对象:
// 生成唯一ID
String id = UUID.randomUUID().toString();
CorrelationData correlationData = new CorrelationData(id);
rabbitTemplate.convertAndSend("amq.topic", "a.simple.test", message, correlationData);
当消息发送正常时,可以看到如下日志:
当你尝试向一个不存在的交换机发送消息时,可以看到如下日志:
消息未到达交换机, 消息ID => c7623b95-1174-45f4-b494-b95031e0c631,原因:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'bamq.topic' in vhost '/', class-id=60, method-id=40),消息内容:null
除此之外,你还可以在每条消息发送时通过CorrelationData
对象设置独立的publisher-confirm
回调,在这里你可以编写重试的逻辑:
CorrelationData correlationData = new CorrelationData(id);
correlationData.getFuture().addCallback(
(CorrelationData.Confirm result) -> {
if (!result.isAck())
// 重试
rabbitTemplate.convertAndSend("amq.topic", "a.simple.test", message, correlationData);
},
(Throwable ex) -> {
log.warn("发送时抛出异常...");
}
);
接收并处理publisher-return
publisher-return
会在消息成功发送到交换机但发送到队列失败时收到。
首先是配置:
spring:
rabbitmq:
# ...
# publisher-returns: true
template:
mandatory: true
publisher-returns
代表启用接收publisher-return
,template.mandatory
代表开启强制消息投递,如果为false则当失败时直接丢弃消息,为true则调用ReturnCallback
。template.mandatory
的优先级要高,所以设置它后你可以不设置publisher-returns
。
然后,我们要给RabbitTemplate
设置Return
,由于RabbitTemplate
是全局唯一的,所以ReturnCallback
也是全局唯一的。
@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
RabbitTemplate template = applicationContext.getBean(RabbitTemplate.class);
template.setReturnCallback(new RabbitTemplate.ReturnCallback() {
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
log.warn("Send message faild, msg => [{}]. Retrying..." , replyText);
template.convertAndSend(exchange, routingKey, message.getBody());
}
});
}
}
这时,当你尝试使用错误的routingKey
发送消息时,你会接到publisher-return
消息
使用生产者确认,可以保证消息不会莫名其妙的丢失,丢失一定会通知我们,我们可以进行重发或其它逻辑。
Step2. 消息持久化
在Spring-AMQP中,默认情况下,交换机、队列和消息都是持久的,不用做任何设置
为了避免宕机后消息丢失,需要将消息持久化到磁盘中。
注意,下面的图片中,Feature
一栏中,我们自己创建的my.exchange
交换机没有D
这个标识,这个标识代表“持久的”,即该Exchange在关机后不会消失。
你在创建交换机和队列时是能够选择是否持久化的,Durable
代表持久的,Transient
代表瞬时的。
在Java代码中,所有交换机的父类AbstractExchange
有一个构造器:
这代表我们在创建交换机时可以通过第二个参数为true
来控制它是否是持久的。
在创建队列时,我们也可以用QueueBuilder
来完成这点(Queue的构造函数里也可以设置)
QueueBuilder.durable("simple.queue").build();
上面只是将交换机和队列持久化了,并不是将里面的数据持久化,重启后,虽然交换机和队列还在,但里面的数据不在了。所以,如果要将消息持久,还要在发送消息时设置持久化。
Message message = MessageBuilder.withBody("hello, spring".getBytes(StandardCharsets.UTF_8))
// 设置消息传送模式,持久化
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.build();
Step3. 消费者消息确认
为了确保消费者正常消费后MQ再删除消息,RabbitMQ提供消费者确认机制
- 消费者成功消费,向MQ发送ACK,MQ删除消息
- 消费者消费失败,向MQ发送NACK,MQ重发消息
- 消费者宕机,MQ没有接到任何确认,等待消费者重启后重发消息
Spring-AMQP提供了三种消息确认模式:
- Manual:手动发送确认消息
- Auto:Spring监测Listener方法,自动发送确认消息
- None:关闭此功能,MQ不管消费者是否接到消息,发送消息后立即删除消息
配置:
spring: yaml
rabbitmq:
listener:
simple:
prefetch: 1
acknowledge-mode: auto
Step4. 失败重试机制
在Step3
中有一个循环调用,即如果消费者消费失败,MQ会重新发送消息,而一般情况下如果是代码逻辑引起的失败,则即使重发也不会好,就会造成消费者一直报告NACK,MQ一直重发的局面。
为了避免MQ的压力过大,我们可以采用本地重试机制,即在本地重新计算
spring:
rabbitmq:
listener:
simple:
prefetch: 1
acknowledge-mode: auto
retry:
# 开启本地重试
enabled: true
# 重试重试间隔 ms
initial-interval: 1000
# 重试间隔因子,new_interval = last_interval * multipiler
multiplier: 1
# 最大尝试次数
max-attempts: 3
# 是否无状态,无状态不保持事务,有状态保持事务
stateless: true
配置如上代码后,Spring-AMQP会在消息处理失败后尝试在本地重试3次,每次间隔1s,等三次后,Spring-AMQP会给MQ服务器发送reject,表示该消息被放弃,服务器可以删除该消息了。
下图是重试三次后的一条日志,显示重试次数耗尽
你也可以设置重试次数耗尽后的策略:
RejectAndDontRequeueRecoverer
:直接rejectImmediateRequeueMessageRecoverer
:返回nack,重新入队RepublishMessageRecoverer
:将消息投递到指定交换机
配置MessageRecoverer
,覆盖原始的reject策略,在重试次数耗尽时将消息发送给error.direct
这个交换机,并使用error
这个Routingkey。
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
然后,当dl.queue
发送消息的重试次数耗尽后,消息被重发到了error.queue
上
消息可靠性总结
- 可以使用生产者确认机制定制在消息在发送路径上失败时的策略
publisher-confirm
可以确认消息是否发送到交换机publisher-return
可以确认消息是否发送到queue
- 可以使用持久化技术来将队列、交换机和消息持久化,当MQ宕机时,消息也不会丢失。Spring-AMQP默认开启了所有的持久化
- 可以使用消费者确认机制来定制当消费者处理消息失败时,MQ的策略
none
,MQ直接删除消息auto
,自动监测listener,执行完成发送ack,执行失败发送nackmanual
,手动监测listener,发送ack和nack
- 如果代码逻辑有误,很可能陷入消费者不断nack,MQ不断重发的状态,可以使用本地重试机制来解决或缓解这一问题
RejectAndDontRequeueRecoverer
,重试次数耗尽后直接rejectImmediateRequeueMessageRecoverer
,重试次数耗尽后发送nack,消息重新入队RepublishMessageRecoverer
,重试次数耗尽后重新将消息发送到指定交换机
发送延迟消息
基于死信交换机
当一个队列中的消息满足下列情况之一时,可以称为死信
- 被消费者reject或nack,并且消息的requeue参数设置为false
- 消息超时无人消费
- 消息队列满了,当有新消息,最早的消息被丢弃
如果一个队列配置了dead-letter-exchange
后,它其中所有的死信都会被投递到这个交换机中,该交换机称为死信交换机。
与Spring-AMQP中的
RepublishMessageRecoverer
不同,虽然结果都是将消息投递到另一个交换机中,但死信交换机是由产生死信的队列投递,而前者是由消费者重新投递
TTL到期产生的死信
由于第一种死信产生方式我们已经在前面看到过了,所以这里直接从消息超时开始。TTL就是Time-To-Live,也就是消息的存活时间。RabbitMQ中,可以给队列设置统一的TTL时间,也可以给消息单独设置TTL时间。如果消息和队列都有TTL,则以更小的为准。
死信交换机实现延迟消息
当你为一个消息设置ttl,并且不监听它所在的消息队列,而是监听死信交换机路由到的队列,这样就实现了消息的延迟发送。消息经历ttl后才被消费者消费。
在创建Queue时,可以使用QueueBuilder
来添加死信交换机和死信RoutingKey
public Queue ttlQueue() {
return QueueBuilder.durable("ttl.queue")
.ttl(10000)
.deadLetterExchange("dl.direct")
.deadLetterRoutingKey("dl")
.build();
}
这样,我们创建了一个ttl为10000ms,死信交换机为dl.direct
,私信routingkey为dl
的队列。
定义死信交换机、Routingkey以及它所绑定的队列,并进行监听:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "dl.queue", durable = "true"),
exchange = @Exchange(name = "dl.direct"),
key = "dl"
))
public void listenDlQueue(String msg) {
log.info("消费者接收到了dl.queue的延迟消息");
}
接下来,向ttl.queue
发送消息,10秒后,dl.queue
会接收到消息。
在创建消息时,也可以通过MessageBuilder
为消息设置超时时间
Message message = MessageBuilder
.withBody("hello, ttl messsage".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
// 设置超时时间
.setExpiration("5000")
.build();
DelayExchange插件
安装
去Release页面下载这个插件,上传到你的虚拟机
修改DockerCompose文件,将插件挂载到容器的/plugins
目录下:
services:
rabbitmq1:
image: rabbitmq:3-management
ports:
- 5672:5672
- 15672:15672
volumes:
# 启动脚本
- ./startup.sh:/startup.sh
# 插件
- ./rabbitmq_delayed_message_exchange-3.10.2.ez:/plugins/rabbitmq_delayed_message_exchange-3.10.2.ez
command:
# 运行启动脚本
/bin/bash -c /startup.sh
启动脚本里包含启用RabbitMQ插件的命令和启动RabbitMQ服务器的命令,因为自定义command时原先的command会被替换,所以你必须手动启动服务器。你也可以选择使用&
而不是启动脚本。启动脚本的内容如下:
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
rabbitmq-server
记得给脚本执行权限:
chmod a+x startup.sh
其实在rabbitmq的官方docker镜像文档中有一个更优雅的启动插件的方法,这里就不研究了。
重启,插件已经安装成功:
使用
DelayExchange
插件的原理就是提供一个新的交换机类型,这个交换机可以暂存消息,等到时间后将消息转发给队列。
可以看到安装插件后,我们已经可以创建x-delay-message
类型的exchange了
DelayExchange
实际上是对官方交换机的装饰器,它只提供消息的暂存,所以你还要为它指定一个底层使用的官方交换机。使用x-delayed-type
参数指定
然后我们装模做样的将它绑定到之前的dl.queue
现在就要对它发消息了,它是通过给消息添加一个x-delay
头来指定延迟的时间的
Message message = MessageBuilder
.withBody("hello, ttl messsage".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.setHeader("x-delay", 5000)
.build();
使用Spring-AMQP创建延迟队列,注解方式:
只需要添加
delayed=true
属性即可
Bean方式:
使用ExchangeBuilder的
delayed
方法
记得之前的publisher-return
吗,如果你向x-delayed-message
类型的exchange
发送消息,它会默认认为消息发送到了交换机但没到队列,所以ReturnCallback会被回调,如果你在里面配置了重发,那么该消息可能会被重发多次,你要处理好这种情况。
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
// 判断,如果消息具有接收延迟,放行它
if (message.getMessageProperties().getReceivedDelay() > 0) return;
log.warn("Send message faild, msg => [{}]. Retrying..." , replyText);
template.convertAndSend(exchange, routingKey, message.getBody());
}
惰性队列
消息堆积问题
队列容量有限,当消费者消费速度跟不上生产者生产速度时,队列迟早会满,之后,再来新的消息时,最早到达的消息会成为死信,被发到死信交换机或者被丢弃。
解决消息堆积的几种思路
- 提高消费速度(增加消费者个数)
- 提高单个消费者处理速度(开启线程池)
- 扩大队列容量,提高堆积上限
惰性队列
惰性队列从第三点上解决这一问题,由于内存容量有限,所以惰性队列有如下特性:
- 接收消息后直接写入磁盘(非内存存储)
- 消费者消费消息时才从磁盘写入内存
- 支持百万消息的存储
相当于牺牲一部分性能换得存储更多的消息,在需要处理大量消息时可以使用
在运行中修改队列为惰性队列:
在Spring-AMQP中声明队列为惰性队列:
// Bean方式
@Bean
public Queue lazyQueue() {
return QueueBuilder.durable("lazy.queue")
.lazy()
.build();
}
// 注解方式
@RabbitListener(queuesToDeclare = @Queue(
name = "delay.queue",
durable = "true",
// 通过添加队列参数设置
arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
当向惰性队列中发送大量消息时,会看到它们都被pageout
到磁盘中了,内存中实际上什么都没有
MQ集群
RabbitMQ的集群类型:
- 普通集群:将队列分散到集群中的各个节点上,节点中保存不同的数据,提高整个集群并发能力(当节点故障,其中的消息丢失)
- 镜像集群:是一种主从集群,将主节点的数据备份到从节点上,提高可用性(主从同步非强一致)
- 仲裁队列:用来代替镜像集群,保证强一致性
普通集群
- 集群节点间共享交换机、队列元信息,但不共享队列以及其中的消息
- 当访问集群中某节点时,如果访问的队列不在该节点,请求会被传递到队列所在节点并返回
- 队列所在节点宕机,队列数据丢失
首先,创建RabbitMQ的配置文件rabbitmq.conf
,在里面指定集群中的节点地址,节点地址统一用rabbit@hostname
的格式。后面我们会用docker compose创建这些节点,并给它们的hostname命名为mq1~3
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
然后,RabbitMQ集群通信需要一个共同的cookie,我们创建.erlang.cookie
文件并随便写入一个Cookie
PFXHSGXZINBKKBNIDRJM
然后,我们修改它的权限,让它只能被root读写:
sudo chown root:root .erlang.cookie
sudo chmod 600 .erlang.cookie
然后编辑docker compose文件
# normal-cluster.docker-compose.yaml
services:
mq1:
image: rabbitmq:3-management
hostname: mq1
ports:
- 5672:5672
- 15672:15672
volumes:
- ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
- ./.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie
mq2:
image: rabbitmq:3-management
hostname: mq2
ports:
- 5673:5672
- 15673:15672
volumes:
- ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
- ./.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie
mq3:
image: rabbitmq:3-management
hostname: mq3
ports:
- 5674:5672
- 15674:15672
volumes:
- ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
- ./.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie
up服务
docker compose -f normal-cluster.docker-compose.yaml up
连接任意节点的控制台,已经可以看到集群的状态
现在我们在mq1创建队列并写入数据,发现在其它节点也能看到,停掉mq1后,其它节点的这个队列显示为stopped
状态
这也印证了,队列的元信息在集群间共享,但队列本身不共享
镜像集群
镜像集群通过主从模式来将节点上的数据在集群间备份,实现高可用
不过它和传统的主从模式还有些区别,有点像ElasticSearch的分片集群。其主要区别是主节点可以作为其它节点的镜像节点,就当作从节点就是主节点的一个备份,然后多个节点之间建立主从关系相互备份。下图中,节点1做了节点3的镜像节点,节点2做了节点1的镜像节点,节点3做了节点2的镜像节点。
- 主从关系是基于队列讨论的,你可以说,节点1是队列1的主节点,是队列3的从节点
- 主节点操作完成同步给从节点
- 一个队列所在的主节点宕机后,它的丛节点会做新的主节点
镜像模式
- exactly:你可以指定一个
count
,它代表队列的副本数。当它为1时,队列在集群中有一个副本,即它所在的主节点;当它为2时,队列在集群中有两个副本,那就意味着它必须选择一个集群中的其它节点做该队列的镜像节点。 - all:队列在集群中的所有节点间进行镜像(不推荐,会带来高昂的磁盘和网络压力)
- nodes:手动指定队列镜像到哪些节点
# 设置policy,名字为`ha-two`,匹配所有以`two.`开头的队列,将它们设置为`exactly`模式,指定count为2,同步模式为自动
rabbitmqctl set_policy ha-two "^two\." '{"ha-mode": "exactly", "ha-params": 2, "ha-sync-mode": "automatic"}'
# 匹配所有以`all.`开头的队列,将它们设置为`all`模式
rabbitmqctl set_policy ha-all "^all\." '{"ha-mode": "all"}'
# 匹配所有以`nodes.`开头的队列,将它们设置为`nodes`模式,并且指定镜像节点
rabbitmqctl set_policy ha-nodes "^nodes\." '{"ha-mode": "nodes", "ha-params": ["rabbit@nodeA", "rabbit@nodeB"]}'
我们想要新建一个excatly
模式的policy,那就直接进入到集群中任意一个节点中,执行命令就行:
在控制台上也能看见策略被创建了
此时我们在mq1上创建一个two.queue
,就能看到它同步到了集群中的一个其它节点上,现在集群中有两个该队列的副本
当我们写入数据后,把mq1停掉,访问mq2,发现mq2变成了这个队列的主节点,mq3变成了镜像节点
所以,exactly
镜像模式的目标就是保证集群中永远有count
份队列的副本,当count
小于集群中节点数量,它的效果和all
类似。
仲裁队列
- 和镜像模式相同,支持主从数据同步
- 使用简单,没有复杂配置
- 基于Raft协议,保证强一致性
使用仲裁队列,只需要创建队列时指定类型为Quorum
,并且选择Node
即可,选中的Node作为该队列的主节点,其它Node作为该队列的从节点。(这不是镜像模式的all吗)
关于使用Spring-AMQP创建仲裁队列,我都不想说了,只要你了解了Spring-AMQP的API风格,就知道该怎么做了
new QueueBuilder()
.durable("quorum.queue")
.quorum()
.build();
不过集群模式需要修改Spring的配置了,需要在连接时指定要连接的集群中的所有节点:
spring:
rabbitmq:
# host: localhost
# port: 5672 # 端口
addresses: localhost:5672,localhost:5673,localhost:5674
最后的总结
本篇文章基于黑马程序员的网课SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,史上最全面的springcloud微服务...中的高级篇day05,主要介绍了以下内容
- 对于MQ消息的可靠性如何保证
- 发布者确认
- 消息持久化
- 接收者确认
- 失败重试
- 如何发送延迟消息
- 通过死信交换机
- 通过DelayExchange插件
- 如何使用惰性队列解决消息堆积问题
- MQ的高可用集群搭建
- 普通集群
- 镜像集群
- 仲裁队列