文章目录
一. RabbitMQ 高级特性
1. 消息可靠投递
消息可靠投递针对的问题是消息在发送和接收过程中不被丢失。这涉及到 producer 发送到交换机的过程和 consumer 接收消息两部分。rabbitmq 均提供了应答机制来保证消息在投递完成后发送回执,如果投递失败则可自定义失败处理机制,从而保证消息可靠投递。
1. 生产者确认
生产者判断消息是否可靠投递到中间件有两种方式,confirmCallback 和 returnsCallback。
confirmCallback 在消息投递到交换机之后执行,可判断消息是否正确送达交换机。
- 配置文件配置应答模式
spring:
rabbitmq:
publisher-confirm-type: correlated #开启确认模式
- rabbitTemplate 重写 confirm 方法
@SpringBootTest
class BootproducerApplicationTests {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
void testConfirm() {
//投递完成后执行
rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
@Override
public void confirm(CorrelationData correlationData, boolean b, String s) {
System.out.println("执行confirm方法");
// b 为 true 表示交换机接收消息成功,否则失败,s 打印错误信息
if (b) {
System.out.println("交换机接收成功");
} else {
System.out.println("交换机接收失败:" + s);
}
}
});
rabbitTemplate.convertAndSend(MqConfig.EXCHANGE_NAME,
"errorLog", "this is test for boot rabbitmq producer");
}
}
returnsCallback 在交换机路由到队列失败后执行,默认处理方式为返回消息给生产者。
- 配置文件:
spring:
rabbitmq:
publisher-returns: true
- rabbitTemplate 重写 returnedMessage 方法:
@Test
public void testReturn() {
//路由失败后交换机处理模式,默认为true;设置为false则丢弃消息,returnsCallback 方法也不会执行
rabbitTemplate.setMandatory(true);
//路由失败后返回消息给生产者调用
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returnedMessage) {
System.out.println("消息路由失败:" + returnedMessage.getMessage());
}
});
rabbitTemplate.convertAndSend(MqConfig.EXCHANGE_NAME,
"errorLog111", "this is test for boot rabbitmq producer");
}
2. 消费者确认
消费者通过 ack 可以自动或者手动进行消息签收。(签收表示消息消费完成)
rabbitmq 消费者默认为自动签收,即 consumer 收到消息后就自动签收完成。consumer 还可以设置手动签收,当消息被处理完成后再进行签收;如果处理过程中出现异常,可以设置异常处理方式,比如消息重回队列等。
- consumer 配置文件配置手动签收
spring:
rabbitmq:
listener:
direct:
acknowledge-mode: manual
- consumer 监听队列,实现 ChannelAwareMessageListener 接口。
签收成功使用 basicAck(),签收失败使用 basicNack()。如果处理过程中异常未被捕获,未调用 basicAck 也等于签收失败,默认的处理方式是消息重回队列。
@Component
public class MqListener implements ChannelAwareMessageListener {
@Override
@RabbitListener(queues = "boot_direct_queue")
public void onMessage(Message message, Channel channel) throws Exception {
System.out.println("收到消息:" + new String(message.getBody()));
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
System.out.println("开始处理...");
System.out.println("处理完成");
//手动签收: 消息标签,是否允许签收多条消息
channel.basicAck(deliveryTag, true);
System.out.println("签收成功");
} catch (Exception e) {
//消息处理失败后签收:消息标签,是否允许签收多条消息,消息是否重回队列
channel.basicNack(deliveryTag, true, false);
System.out.println("签收失败: " + e.getMessage());
}
}
}
2. 消费者限流
当中间件上的消息过多,消费者一次性拉取过多的消息进行处理也可能导致系统崩溃,因此可以根据消费者的消息处理能力对消费者进行限流。
限流必要条件:
- 设置手动签收;
- 设置prefetch(表示一个消费者每次允许拉取的消息数量,待手动确认后再拉取下一条)
spring:
rabbitmq:
listener:
type: direct
direct:
acknowledge-mode: manual #ack机制为手动签收
prefetch: 1 #一个消费者每次从队列中拉取1条消息,待手动确认后再拉取下一条
注意如果使用 direct 模式,要显式设置 listener.type = direct,否则默认的 type 是 simple。
3. TTL 自动过期
rabbitmq 可以设置消息在一定时间内未消费就进行丢弃,具体有两种方式,一种是对整个队列设置统一的过期时间,一种是对单独的某个消息设置过期时间。当两者均进行了设置,以过期时间短的为准。
1. 队列统一过期
只需要在创建队列时设置 ttl 属性即可:
@Configuration
public class MqConfig {
public static final String TTL_EXCHANGE_NAME = "boot_ttl_exchange";
public static final String TTL_QUEUE_NAME = "boot_ttl_queue";
/**
* 配置交换机
* @return
*/
@Bean
public Exchange ttlExchange() {
return ExchangeBuilder.topicExchange(TTL_EXCHANGE_NAME).durable(true).build();
}
/**
* 配置队列,设置消息过期时间为10s
* @return
*/
@Bean
public Queue ttlQueue() {
return QueueBuilder.durable(TTL_QUEUE_NAME).ttl(10000).build();
}
/**
* 队列绑定交换机
* Qualifier 的作用是声明注入哪个对象,防止多个交换机队列不知道注入哪一个
* @param exchange
* @param queue
* @return
*/
@Bean
public Binding bindTTLQueueExchange(@Qualifier("ttlExchange") Exchange exchange,
@Qualifier("ttlQueue") Queue queue) {
return BindingBuilder.bind(queue).to(exchange).with("ttl.#").noargs();
}
}
2. 消息过期
设置消息属性 expiration。注意,当消息不在队首时,消息过期不会被移除队列,只有当消息处于队首才会进行判断并移除。
@Test
public void testTTL() {
for (int i = 0; i < 10; i++) {
MessagePostProcessor msgPostProcessor = new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
message.getMessageProperties().setExpiration("5000");
return message;
}
};
if (i == 5) {
rabbitTemplate.convertAndSend(MqConfig.TTL_EXCHANGE_NAME,
"ttl.message", "this is test for ttl", msgPostProcessor);
} else {
rabbitTemplate.convertAndSend(MqConfig.TTL_EXCHANGE_NAME,
"ttl.message", "this is test for ttl");
}
}
}
4. 死信队列(死信交换机)
DLX:dead letter exchange,存储死信的的队列。当消息成为死信后,消息会被正常队列路由到其绑定的死信交换机。死信队列与正常队列在创建时没有任何区别。
消息成为死信的几种情况:
- 队列超限,新的消息会变成死信;
- 消费者拒绝签收消息,并且 requeue = false;
- 消息过期。
队列如何绑定死信交换机:设置参数 x-dead-letter-exchange、x-dead-letter-routing-key。
正常队列绑定死信交换机代码:
@Configuration
public class MqConfig {
//正常队列和交换机
public static final String TTL_EXCHANGE_NAME = "boot_ttl_exchange";
public static final String TTL_QUEUE_NAME = "boot_ttl_queue";
//死信队列和交换机
public static final String DLX_EXCHANGE_NAME = "boot_dlx_exchange";
public static final String DLX_QUEUE_NAME = "boot_dlx_queue";
/**
* 配置交换机
* @return
*/
@Bean
public Exchange ttlExchange() {
return ExchangeBuilder.topicExchange(TTL_EXCHANGE_NAME).durable(true).build();
}
@Bean
public Exchange dlxExchange() {
return ExchangeBuilder.topicExchange(DLX_EXCHANGE_NAME).durable(true).build();
}
/**
* 配置队列,在这里正常队列绑定死信交换机并设置routingkey,注意 routingkey必须是死信队列的 routingkey 的一种情况
* 设置 ttl 和 maxLength 可以用于测试消息成为死信的两种情况
* @return
*/
@Bean
public Queue ttlQueue() {
return QueueBuilder.durable(TTL_QUEUE_NAME)
.ttl(100000)
.maxLength(10)
.deadLetterExchange(DLX_EXCHANGE_NAME)
.deadLetterRoutingKey("dlx.dlx")
.build();
}
@Bean
public Queue dlxQueue() {
return QueueBuilder.durable(DLX_QUEUE_NAME).build();
}
/**
* 队列绑定交换机
* Qualifier 的作用是声明注入哪个对象,防止多个交换机队列不知道注入哪一个
* @param exchange
* @param queue
* @return
*/
@Bean
public Binding bindTTLQueueExchange(@Qualifier("ttlExchange") Exchange exchange,
@Qualifier("ttlQueue") Queue queue) {
return BindingBuilder.bind(queue).to(exchange).with("ttl.#").noargs();
}
@Bean
public Binding bindDlxQueueExchange(@Qualifier("dlxExchange") Exchange exchange,
@Qualifier("dlxQueue") Queue queue) {
return BindingBuilder.bind(queue).to(exchange).with("dlx.#").noargs();
}
}
测试:生产者发送20条消息,由于队列长度为10,因此首先有10条消息会进入死信队列。之后启动消费者消费消息,由于出现异常,消息未被签收,且 requeue=false,因此该条消息会进入死信队列。假设消费者只消费了一条消息就停止了,剩余的 9 条消息将在到期后进入死信队列。
@Test
public void testDlx() {
for (int i = 0; i < 20; i++) {
rabbitTemplate.convertAndSend(MqConfig.TTL_EXCHANGE_NAME,
"ttl.message", "this is test for dlx");
}
}
@Component
public class TtlListener implements ChannelAwareMessageListener {
@Override
@RabbitListener(queues = "boot_ttl_queue")
public void onMessage(Message message, Channel channel) throws Exception {
System.out.println("收到消息:" + new String(message.getBody()));
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
System.out.println("开始处理...");
int i = 1 / 0;
System.out.println("处理完成");
//手动签收: 消息标签,是否允许签收多条消息
channel.basicAck(deliveryTag, true);
System.out.println("签收成功");
} catch (Exception e) {
//消息处理失败后签收:消息标签,是否允许签收多条消息,消息是否重回队列
channel.basicNack(deliveryTag, true, false);
System.out.println("签收失败: " + e.getMessage());
}
}
}
5. 延迟队列
延迟队列指让消息延迟一段时间再进行消费。RabbitMq 中不存在直接的延迟队列的实现,但是我们可以通过 TTL+死信队列 实现延迟队列的效果。此时要注意的是消费者监听的一定是死信队列,正常队列相当于延时器。
延迟队列的应用场景:
- 订单30分钟未支付自动取消;
- 新用户注册 7 天后自动发送短信问候。
实现方式:
- 定时器;(定时轮询进行判断,对数据库压力大,且有误差)
- 延迟队列。
实现代码与上述死信队列一致,只需要加上监听死信队列即可。
6. 补偿机制
如何保证消息的可靠投递?前面说到的,可以通过生产者确认和消费者确认两者结合来保证。但是如果生产者和消费者就是收不到确认消息,或者确认消息是失败该怎么办?
生产者投递消息时可以设置一个限定时间,在该时间范围内如果没有收到确认,重试超限后可以将消息写入数据库,再定时重发。
消费者确认失败的消息会进入死信队列,重试超限后写入数据库,再定时重发。
关键就是入库 + 重发。 网上还有更复杂的补偿机制,可以看看。
7. 消息幂等性
消息幂等性指的是消费一条和多条相同的消息时得到的结果应该是一样的。比如扣款消息,同一条扣款消息如果因为重发机制再次被消费,不应该扣款两次。
- 乐观锁
如何保障这一点呢,可以使用乐观锁。更新数据库时添加 version 字段,当数据库被更新过一次以后,version版本号自动加一,再次消费同一条扣款消息时更新数据库已经找不到之前的version了,也就不会扣两次款。
- 全局唯一ID + Redis
生产者在发送消息时,为每条消息设置一个全局唯一的messageId,消费者拿到消息后,使用setnx命令,将messageId作为key放到redis中:setnx(messageId,1),若返回1,说明之前没有消费过,正常消费;若返回0,说明这条消息之前已消费过,抛弃。
※ setnx命令,若给定的key不存在,执行set操作,返回1,若给定的Key已存在,不做任何操作,返回0。
二. 集群搭建
RabbitMq 的高可用特性通过创建集群实现,由于条件所限,我们在单台服务器上部署多个实例,用端口区分,来实现集群搭建。
1. 单服务器多实例部署
停止rabbitmq服务
[root@super sbin]# service rabbitmq-server stop
Stopping rabbitmq-server: rabbitmq-server.
启动第一个节点:
[root@super sbin]# RABBITMQ_NODE_PORT=5673 RABBITMQ_NODENAME=rabbit1 rabbitmq-server start
RabbitMQ 3.6.5. Copyright (C) 2007-2016 Pivotal Software, Inc.
## ## Licensed under the MPL. See http://www.rabbitmq.com/
## ##
########## Logs: /var/log/rabbitmq/rabbit1.log
###### ## /var/log/rabbitmq/rabbit1-sasl.log
##########
Starting broker...
completed with 6 plugins.
启动第二个节点:
web管理插件端口占用,所以还要指定其web插件占用的端口号。
[root@super ~]# RABBITMQ_NODE_PORT=5674 RABBITMQ_SERVER_START_ARGS="-rabbitmq_management listener [{port,15674}]" RABBITMQ_NODENAME=rabbit2 rabbitmq-server start
RabbitMQ 3.6.5. Copyright (C) 2007-2016 Pivotal Software, Inc.
## ## Licensed under the MPL. See http://www.rabbitmq.com/
## ##
########## Logs: /var/log/rabbitmq/rabbit2.log
###### ## /var/log/rabbitmq/rabbit2-sasl.log
##########
Starting broker...
completed with 6 plugins.
结束命令:
rabbitmqctl -n rabbit1 stop
rabbitmqctl -n rabbit2 stop
rabbit1操作作为主节点:
[root@super ~]# rabbitmqctl -n rabbit1 stop_app
Stopping node rabbit1@super ...
[root@super ~]# rabbitmqctl -n rabbit1 reset
Resetting node rabbit1@super ...
[root@super ~]# rabbitmqctl -n rabbit1 start_app
Starting node rabbit1@super ...
[root@super ~]#
rabbit2操作为从节点:
[root@super ~]# rabbitmqctl -n rabbit2 stop_app
Stopping node rabbit2@super ...
[root@super ~]# rabbitmqctl -n rabbit2 reset
Resetting node rabbit2@super ...
[root@super ~]# rabbitmqctl -n rabbit2 join_cluster rabbit1@'super' ###''内是主机名换成自己的
Clustering node rabbit2@super with rabbit1@super ...
[root@super ~]# rabbitmqctl -n rabbit2 start_app
Starting node rabbit2@super ...
查看集群状态:
[root@super ~]# rabbitmqctl cluster_status -n rabbit1
Cluster status of node rabbit1@super ...
[{nodes,[{disc,[rabbit1@super,rabbit2@super]}]},
{running_nodes,[rabbit2@super,rabbit1@super]},
{cluster_name,<<"rabbit1@super">>},
{partitions,[]},
{alarms,[{rabbit2@super,[]},{rabbit1@super,[]}]}]
每个实例端口的控制台都能看到两个节点:
但此时两个节点的队列和数据并没有同步,可在控制台添加同步策略:
此时,如果创建队列,则会默认同步到另外一个节点,而当一个实例不可用时,将不影响整个集群的访问:
2. 集群管理命令
rabbitmqctl join_cluster {cluster_node} [–ram]
将节点加入指定集群中。在这个命令执行前需要停止RabbitMQ应用并重置节点。
rabbitmqctl cluster_status
显示集群的状态。
rabbitmqctl change_cluster_node_type {disc|ram}
修改集群节点的类型。在这个命令执行前需要停止RabbitMQ应用。
rabbitmqctl forget_cluster_node [–offline]
将节点从集群中删除,允许离线执行。
rabbitmqctl update_cluster_nodes {clusternode}
在集群中的节点应用启动前咨询clusternode节点的最新信息,并更新相应的集群信息。这个和join_cluster不同,它不加入集群。考虑这样一种情况,节点A和节点B都在集群中,当节点A离线了,节点C又和节点B组成了一个集群,然后节点B又离开了集群,当A醒来的时候,它会尝试联系节点B,但是这样会失败,因为节点B已经不在集群中了。
rabbitmqctl cancel_sync_queue [-p vhost] {queue}
取消队列queue同步镜像的操作。
rabbitmqctl set_cluster_name {name}
设置集群名称。集群名称在客户端连接时会通报给客户端。Federation和Shovel插件也会有用到集群名称的地方。集群名称默认是集群中第一个节点的名称,通过这个命令可以重新设置。
3. HAProxy 实现负载均衡访问
构建完 RabbitMq 集群后,无论使用哪个实例的 IP 和端口访问都能访问到集群的整体信息,但是在实际的代码编写中,我们不应该指定访问某个实例的 IP 端口,因为如果该实例挂了,该端口将无法访问。所以一般会在集群前加一个负载均衡统一入口,使用该负载均衡进行访问。
这里不赘述 haproxy 的安装和启动了。配置可参考:
配置文件路径:/etc/haproxy/haproxy.cfg
#logging options
global
log 127.0.0.1 local0 info
maxconn 5120
chroot /usr/local/haproxy
uid 99
gid 99
daemon
quiet
nbproc 20
pidfile /var/run/haproxy.pid
defaults
log global
mode tcp
option tcplog
option dontlognull
retries 3
option redispatch
maxconn 2000
contimeout 5s
clitimeout 60s
srvtimeout 15s
#front-end IP for consumers and producters
listen rabbitmq_cluster
bind 0.0.0.0:5672
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 node1 127.0.0.1:5673 check inter 5000 rise 2 fall 2
server node2 127.0.0.1:5674 check inter 5000 rise 2 fall 2
listen stats
bind 192.168.xxx.xxx:8100
mode http
option httplog
stats enable
stats uri /rabbitmq-stats
stats refresh 5s