RabbitMQ 核心
一、Hello World
- 发送单个消息的生产者 和 接收消息并打印出来的消费者。
- 在图中,
P
是我们的生产者,C
是我们的消费者。- 中间是一个队列,RabbitMQ 代表保留的消息缓冲区。
1. 消费者
/**
* @author: wy
* describe: 消费者
*/
public class Consumer {
private static final String QUEUE_NAME = "hello";
public static void main(String[] args) {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("192.168.137.155");
factory.setUsername("guest");
factory.setPassword("guest");
try {
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
/**
* 消费者消费消息
* 1. 消费那个队列
* 2. 消费成功之后,是否自动应答(false手动应答)
* 3. 消费者成功消费的回调
* 4. 消费者取消消费的回调
*/
channel.basicConsume(QUEUE_NAME, true,
// 接收的消息
(consumerTag, message) -> {
System.out.println(message);
System.out.printf("接收到消息: %s\r\n", new String(message.getBody()));
},
// 取消的消息
consumerTag -> {
System.out.printf("中断消费消息: %s\r\n", consumerTag);
});
System.out.println("等待接收消费...");
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
}
}
2. 生产者
/**
* @author: wy
* describe: 生产者
* 1. Hello World
*/
public class Producer {
// 队列名称
private static final String QUEUE_NAME = "hello";
public static void main(String[] args) {
// 创建一个连接工厂
ConnectionFactory factory = new ConnectionFactory();
// 工厂IP,连接RabbitMQ的队列
factory.setHost("192.168.137.155");
// 用户名
factory.setUsername("guest");
// 密码
factory.setPassword("guest");
try {
// 创建连接
Connection connection = factory.newConnection();
// 获取信道
Channel channel = connection.createChannel();
/**
* 声明一个队列
* 1. 队列名称
* 2. 队列里面的消息是否持久化(默认消息存储在内存中)
* 3. 该队列是否进行共享,否则只供一个消费者进行消费(true可以多个消费者消费)
* 4. 最后一个消费者断开连接以后,该队列是否自动删除(true自动删除)
* 5. 其他参数(延迟消息、死信队列)
*/
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
// 消息
String message = "Hello World!";
/**
* 生产者发送一个消息
* 1. 发送到那个交换机(""代表默认交换机)
* 2. 路由的key是那个(队列名称)
* 3. 其他的参数信息
* 4. 发送的消息体
*/
channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
System.out.println("消息发送完毕");
} catch (IOException | TimeoutException e) {
e.printStackTrace();
}
}
}
二、工作队列(Work Queues
)绑定同一个队列
- 工作队列(又称任务队列)的主要思想是:避免立即执行资源密集型任务,而不得不等待它完成。
- 相反我们安排任务在之后执行。
- 把任务封装为消息并将其发送到队列。
- 在后台运行的工作线程,将弹出的任务最终执行消费。
- 当有多个工作线程时,这些工作线程将一起处理这些任务。
1. 消费者
消费者一 和 消费者二。
/**
* @author: wy
* describe: 消费者
* 1. 轮询分发消息
*/
public class Consumer {
private static final String QUEUE_NAME = "hello";
public static void main(String[] args) throws IOException, TimeoutException {
Channel channel = RabbitMQUtil.getChannel();
// 消费者消费消息
channel.basicConsume(QUEUE_NAME, true,
// 接收的消息
(consumerTag, message) -> {
System.out.printf("接收到消息: %s\r\n", new String(message.getBody()));
},
// 取消的消息
consumerTag -> {
System.out.printf("中断消费消息: %s\r\n", consumerTag);
});
System.out.println("Consumer, 2,等待接收消息...");
}
}
2. 生产者
/**
* @author: wy
* describe: 生产者
* 1. 轮询分发消息
*/
public class Producer {
private static final String QUEUE_NAME = "hello";
public static void main(String[] args) throws IOException, TimeoutException {
// 获取信道
Channel channel = RabbitMQUtil.getChannel();
// 声明一个队列
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
// 从控制台接收消息
Scanner scanner = new Scanner(System.in);
System.out.println("输入消息: ");
while (scanner.hasNext()) {
String message = scanner.next();
channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
System.out.printf("发送消息: %s\r\n", message);
}
}
}
3. 启动两个消费者,默认轮训分发(prefetchCount = 0
)
- 在这个案例中,我们会启动 两个消息消费线程 和 一个消息发送线程,看看两个工作线程是如何工作的。
4. 不公平分发(prefetchCount = 1
)
- 在最开始的时候我们知道 RabbitMQ 分发消息采用的 轮训分发,但是在某种场景下这种策略并不是很好。
- 比方说有两个消费者在处理任务,其中有个 消费者一 处理任务的速度非常快,而另外一个 消费者二 处理任务速度却很慢,这个时候我们还是采用 轮训分发 的话,就会导致处理速度快的这个消费者很大一部分时间处于空闲状态,而处理速度慢的那个消费者却一直在干活,这种分配方式在这种情况下其实就不太好,但是 RabbitMQ 并不知道这种情况它依然很公平的进行分发。
/*
4. 不公平分发:
prefetchCount = 0 轮询(默认)
prefetchCount = 1 不公平分发,能者多劳
prefetchCount = 2 预取值
*/
int prefetchCount = 1;
// int prefetchCount = 2;
channel.basicQos(prefetchCount);
- 意思就是如果这个任务我还没有处理完,或者我还没有应答你,你先别分配给我新任务,我目前只能处理一个任务,然后 RabbitMQ 就会把新任务分配给其他空闲的消费者。
- 当然如果所有的消费者都没有完成手上任务,队列还在不停的添加新任务,队列有可能就会遇到队列被撑满的情况,这个时候就只能添加新的 Worker 消费者 或者改变其他存储任务的策略。
6. 预取值(prefetchCount = 预取值
)
- 本身消息就是异步发送的,所以在任何时候 Channel 上肯定不止只有一个消息,另外来自消费者的手动确认本质上也是异步的。
- 因此这里就存在一个 未确认的消息缓冲区,因此希望开发人员能 限制此缓冲区的大小(
预取值就是缓冲区大小
),以避免缓冲区无限制添加未确认消息的问题。- 这个时候就可以通过使用
basicQos
方法设置 预取值 来完成的。- 该值定义通道上允许的未确认消息的最大数量。
- 一旦数量达到配置的数量,RabbitMQ 将停止在通道上传递更多消息,除非有未处理的消息被确认。
- 例如,假设在通道上有未确认的消息 5、6、7,8,并且通道的 预取值 为 4,此时 RabbitMQ 将不会在该通道上再传递任何消息,除非至少有一个未应答的消息被 ACK。
- 比方说 tag=6 这个消息刚刚被确认 ACK,RabbitMQ 将会感知这个情况到并再发送一条消息。
- 消息应答 和 Qos 预取值 对用户吞吐量有重大影响。
- 通常增加预取值将提高向消费者传递消息的速度。
- 虽然自动应答传输消息速率是最佳的,但是,在这种情况下已传递但尚未处理的消息数量也会增加,从而增加了消费者的 RAM 消耗(随机存取存储器),应该小心使用具有 无限预处理的自动确认模式 或 手动确认模式,消费者消费了大量的消息如果没有确认的话,会导致消费者连接节点的内存消耗变大,所以找到合适的预取值是一个反复试验的过程,不同的负载预取值的取值也不同,100 到 300 范围内的值通常可提供最佳的吞吐量,并且不会给消费者带来太大的风险。
- 预取值为 1 是最保守的。
- 当然这将使吞吐量变得很低,特别是消费者连接延迟很严重的情况下,在消费者连接等待时间较长的环境中。
- 对于大多数应用来说,稍微高一点的值将是最佳的。
三、发布订阅(Fanout
交换机)
- 在上一节中,我们创建了一个工作队列,假设的是工作队列背后,每个任务都恰好交付给一个消费者(工作进程)。
- 在这一部分中,我们将做一些完全不同的事情,将消息传达给多个消费者。这种模式称为 发布订阅(广播)。
- 为了说明这种模式,我们将构建一个简单的日志系统。
- 它将由两个程序组成:第一个程序是生产日志消息,第二个程序是消费日志消息。
- 其中我们会启动两个消费者,其中一个消费者接收到消息后把日志存储在磁盘。
- 另外一个消费者接收到消息后把日志打印在控制台上。
- 事实上第一个程序发出的消息将 广播 给所有消费者。
四、路由(Direct
交换机)
五、主题(Topic
交换机)
六、发布确认
1. 发布确认原理
- 生产者将信道设置成 Confirm 模式,一旦信道进入 Confirm 模式,所有在该信道上面发布的消息都将会被指派一个唯一的 ID(从 1 开始)。
- 一旦消息被投递到所有匹配的队列之后,Broker 就会发送一个确认给生产者(包含消息的唯一 ID)。
- 这就使得生产者知道消息已经正确到达目的队列了。
- 如果 消息 和 队列 是可持久化的,那么确认消息会在将消息写入磁盘之后发出,Broker 回传给生产者的确认消息中
deliveryTag
域包含了确认消息的序列号。- 此外 Broker 也可以设置
basicAck 的 multiple
域,表示到这个序列号之前的所有消息都已经得到了处理。
- Confirm 模式 最大的好处在于他是异步的,一旦发布一条消息,生产者应用程序就可以在等信道返回确认的同时,继续发送下一条消息,当消息最终得到确认之后,生产者应用便可以通过回调方法来处理该确认消息。
- 如果 RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条
nack
消息,生产者应用程序同样可以在回调方法中处理该nack
消息。
2. 开启发布确认的方法
- 发布确认默认是没有开启的,如果要开启需要调用方法
confirmSelect
。- 每当你要想使用发布确认,都需要在 Channel 上调用该方法。
// 开启发布确认
channel.confirmSelect();
3. 发布确认策略
3.1 单个确认发布
- 这是一种简单的确认方式,它是一种 同步确认发布 的方式,也就是发布一个消息之后只有它被确认发布,后续的消息才能继续发布。
waitForConfirmsOrDie(long)
这个方法只有在消息被确认的时候才返回,如果在指定时间范围内这个消息没有被确认,那么它将抛出异常。
这种确认方式有一个最大的缺点就是:发布速度特别的慢,因为如果没有确认发布的消息就会阻塞所有后续消息的发布,这种方式最多提供每秒不超过数百条发布消息的吞吐量。当然对于某些应用程序来说这可能已经足够了。
/**
* 1. 单个确认发布(同步确认发布)
*/
private static void publishMessageIndividually() throws IOException, TimeoutException, InterruptedException {
Channel channel = RabbitMQUtil.getChannel();
// 队列声明
String queueName = UUID.randomUUID().toString();
channel.queueDeclare(queueName, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
// 开始时间
long begin = System.currentTimeMillis();
for (int i = 0; i < MESSAGE_COUNT; i++) {
String message = String.format("单个确认发布: %d", i);
channel.basicPublish("", queueName, null, message.getBytes("UTF-8"));
// 单个消息确认
boolean flag = channel.waitForConfirms();
if (flag) {
System.out.printf("%s, 确认\r\n", message);
}
}
System.out.printf("%d个消息,单个确认发布,耗时: %s毫秒", MESSAGE_COUNT, System.currentTimeMillis() - begin).println();
}
3.2 批量确认发布
- 上面那种方式非常慢,与单个等待确认消息相比,先发布一批消息然后一起确认可以极大地提高吞吐量。
- 当然这种方式的缺点就是:当发生故障导致发布出现问题时,不知道是哪个消息出现问题了,我们必须将整个批处理保存在内存中,以记录重要的信息而后重新发布消息。
- 当然这种方案仍然是同步的,也一样阻塞消息的发布。
/**
* 2. 批量确认发布
*/
private static void publishMessageBatch() throws IOException, TimeoutException, InterruptedException {
Channel channel = RabbitMQUtil.getChannel();
// 队列声明
String queueName = UUID.randomUUID().toString();
channel.queueDeclare(queueName, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
// 批量确认消息数量
int batchSize = 100;
// 开始时间
long begin = System.currentTimeMillis();
for (int i = 0; i < MESSAGE_COUNT; i++) {
String message = String.format("批量确认发布: %d", i);
channel.basicPublish("", queueName, null, message.getBytes("UTF-8"));
// 判断发送消息达到100条,批量确认一次
if (i % batchSize == 0) {
channel.waitForConfirms();
System.out.printf("%s, 确认\r\n", message);
}
}
System.out.printf("%d个消息,批量确认发布,耗时: %s毫秒", MESSAGE_COUNT, System.currentTimeMillis() - begin).println();
}
3.3 异步确认发布
- 异步确认虽然编程逻辑比上两个要复杂,但是性价比最高,无论是可靠性还是效率都没得说。
- 他是利用回调函数来达到消息可靠性传递的,这个中间件也是通过函数回调来保证是否发送成功。
/**
* 3. 异步确认发布
*/
public static void publishMessageAsync() throws IOException, TimeoutException, InterruptedException {
Channel channel = RabbitMQUtil.getChannel();
// 队列声明
String queueName = UUID.randomUUID().toString();
channel.queueDeclare(queueName, true, false, false, null);
// 开启发布确认
channel.confirmSelect();
/*
线程安全有序的一个哈希表,适用于高并发
1. 将序号与消息关联
2. 批量删除条目,只要给序号
3. 支持高并发(多线程)
*/
ConcurrentSkipListMap<Long, String> map = new ConcurrentSkipListMap<>();
// 消息确认监听器(异步通知)
channel.addConfirmListener(
// 监听那些消息成功
(deliveryTag, multiple) -> {
System.out.printf("成功的消息: %s", deliveryTag).println();
// 2. 删除已确认的消息
if (multiple) {
// 批量清除
ConcurrentNavigableMap<Long, String> concurrentNavigableMap = map.headMap(deliveryTag);
concurrentNavigableMap.clear();
} else {
map.remove(deliveryTag);
}
},
// 监听那些消息失败
(deliveryTag, multiple) -> {
System.out.printf("失败的消息: %s", deliveryTag).println();
System.out.println(map.get(deliveryTag));
});
// 开始时间
long begin = System.currentTimeMillis();
for (int i = 0; i < MESSAGE_COUNT; i++) {
String message = String.format("异步确认发布: %d", i);
channel.basicPublish("", queueName, null, message.getBytes("UTF-8"));
// 1. 记录所有发送的消息
map.put(channel.getNextPublishSeqNo(), message);
}
System.out.printf("%d个消息,异步确认发布,耗时: %s毫秒", MESSAGE_COUNT, System.currentTimeMillis() - begin).println();
}
3.3.1 处理异步未确认的消息
- 最好的解决的解决方案,就是把未确认的消息放到一个基于内存的能被发布线程访问的队列。
- 比如说用
ConcurrentLinkedQueue
这个队列在confirm callbacks
与发布线程之间进行消息的传递。
3.4 三种发布确认对比
发布确认 | 优缺点 |
---|---|
单独发布消息 | 同步等待确认,简单,但吞吐量非常有限 |
批量发布消息 | 批量同步等待确认,简单,合理的吞吐量,一旦出现问题但很难推断出是那条消息出现了问题 |
异步处理 | 最佳性能和资源使用,在出现错误的情况下可以很好地控制,但是实现起来稍微难些 |
七、发布确认高级
- 在生产环境中由于一些不明原因,导致 RabbitMQ 重启,在 RabbitMQ 重启期间生产者消息投递失败,导致消息丢失,需要手动处理和恢复。
- 于是,我们开始思考,如何才能进行 RabbitMQ 的消息可靠投递呢?
- 特别是在这样比较极端的情况,RabbitMQ 集群不可用的时候,无法投递的消息该如何处理呢?
1. 交换机确认机制方案
1.1 架构图
1.2 application.properties
# 发布消息成功到交换机后会触发回调方法
spring.rabbitmq.publisher-confirm-type=correlated
参数 | 解释 |
---|---|
NONE | 禁用发布确认模式(默认值) |
CORRELATED | 发布消息成功到交换器后会触发回调方法 |
SIMPLE | 经测试有两种效果,其一效果和 CORRELATED 值一样会触发回调方法,其二在发布消息成功后使用 rabbitTemplate 调用 waitForConfirms 或 waitForConfirmsOrDie 方法,等待 Broker 节点返回发送结果,根据返回结果来判定下一步的逻辑,要注意的点是 waitForConfirmsOrDie 方法如果返回 false 则会关闭 Channel,则接下来无法发送消息到 Broker |
1.3 Config
/**
* @author: wy
* describe: 发布确认高级
*/
@Configuration
public class ConfirmBackupConfig {
// 确认交换机
public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
// 确认队列
public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
// 确认RoutingKey
public static final String CONFIRM_ROUTING_KEY = "key1";
/**
* 直接交换机
*/
@Bean
public DirectExchange confirmExchange() {
// return new DirectExchange(CONFIRM_EXCHANGE_NAME);
// 但指定了备份交换机就不返回了(备份交换机优先级高)
return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME)
// 是否持久化
.durable(true)
// 指定备份交换机
.withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME).build();
}
@Bean
public Queue confirmQueue() {
return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
}
@Bean
public Binding confirmQueueBindingConfirmExchange(@Qualifier("confirmQueue") Queue confirmQueue,
@Qualifier("confirmExchange") DirectExchange confirmExchange) {
return BindingBuilder.bind(confirmQueue).to(confirmExchange).with(CONFIRM_ROUTING_KEY);
}
}
1.4 消费者
/**
* @author: wy
* describe: 确认消费者
*/
@Slf4j
@Component
public class ConfirmConsumer {
@RabbitListener(queues = ConfirmBackupConfig.CONFIRM_QUEUE_NAME)
public void receiveMsg(Message message) {
log.info("确认消费者接收到消息: {}", new String(message.getBody()));
}
}
1.5 回调接口
/**
* @author: wy
* describe: 确认回调
* 1. 交换机确认回调接口RabbitTemplate.ConfirmCallback
* 1.1 需要配置`spring.rabbitmq.publisher-confirm-type=correlated`发布消息成功到交换机后会触发回调方法
*/
@Slf4j
@Component
public class MyCallback implements RabbitTemplate.ConfirmCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 1. 实例化MyCallBack
* 2. 注入RabbitTemplate
* 3. 调用init方法
*/
@PostConstruct
public void init() {
// 将当前回调类注入RabbitTemplate
rabbitTemplate.setConfirmCallback(this);
}
/**
* 交换机确认回调方法
* 1. 交换机成功接收到消息
* 1.1 correlationData: 保存回调消息的Id及相关信息
* 1.2 ack: true代表交换机收到消息
* 1.3 cause: 失败原因(成功为null)
* 2. 交换机没有接收到消息
* 2.1 correlationData: 保存回调消息的Id及相关信息
* 2.2 ack: false代表交换机收到消息
* 2.3 cause: 失败原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String id = correlationData != null ? correlationData.getId() : "";
if (ack) {
log.info("交换机已收到Id为: {}的消息", id);
} else {
log.error("交换机未收到Id为: {}的消息,原因: {}", id, cause);
}
}
}
1.6 生产者
/**
* @author: wy
* describe: 发布确认高级
*/
@Slf4j
@RestController
@RequestMapping("/confirm")
public class ProduceController {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 3. 发布确认高级
* http://127.0.0.1:8080/confirm/sendMsg/%E4%BD%A0%E5%A5%BD%E5%91%80
*/
@GetMapping("/sendMsg/{msg}")
public String sendMsg(@PathVariable String msg) {
log.info("发送消息: {}", msg);
CorrelationData correlationData = new CorrelationData("1");
// 1. 成功确认消息
// rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME, ConfirmConfig.CONFIRM_ROUTING_KEY, msg, correlationData);
// 2. 未知的交换机
// rabbitTemplate.convertAndSend(ConfirmConfig.CONFIRM_EXCHANGE_NAME + "123", ConfirmConfig.CONFIRM_ROUTING_KEY, msg, correlationData);
// 3. 未知的routingKey
rabbitTemplate.convertAndSend(ConfirmBackupConfig.CONFIRM_EXCHANGE_NAME, ConfirmBackupConfig.CONFIRM_ROUTING_KEY + "123", msg, correlationData);
return "success";
}
}
- 可以看到,发送了两条消息,第一条消息的 RoutingKey 为
key1
,第二条消息的 RoutingKey 为key1 + 123
,两条消息都成功被交换机接收,也收到了交换机的确认回调。- 但消费者只收到了一条消息,因为第二条消息的 RoutingKey 与队列的 BindingKey 不一致,也没有其它队列能接收这个消息,所有第二条消息被直接丢弃了。
2. 回退消息
2.1 Mandatory
参数
- 在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息。
- 如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。
- 那么如何让无法被路由的消息,帮我想办法处理一下?
- 最起码通知我一声,我好自己处理啊。通过设置
mandatory
参数可以在当消息传递过程中,不可达目的地时将消息返回给生产者。
2.2 回调接口
/**
* @author: wy
* describe: 确认回调
* 1. 交换机确认回调接口RabbitTemplate.ConfirmCallback
* 1.1 需要配置`spring.rabbitmq.publisher-confirm-type=correlated`发布消息成功到交换机后会触发回调方法
* 2. 消息不可达回退接口RabbitTemplate.ReturnCallback
* 2.1 需要配置`spring.rabbitmq.publisher-returns=true`当消息传递过程中"不可达目的地"时将消息"返回给生产者",成功不返回
*/
@Slf4j
@Component
public class MyCallback implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 1. 实例化MyCallBack
* 2. 注入RabbitTemplate
* 3. 调用init方法
*/
@PostConstruct
public void init() {
// 将当前回调类注入RabbitTemplate
rabbitTemplate.setConfirmCallback(this);
/**
* true: 交换机无法将消息进行路由时,会将该消息返回给生产者
* false: 如果发现消息无法进行路由,则直接丢弃
*/
rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnCallback(this);
}
/**
* 交换机确认回调方法
* 1. 交换机成功接收到消息
* 1.1 correlationData: 保存回调消息的Id及相关信息
* 1.2 ack: true代表交换机收到消息
* 1.3 cause: 失败原因(成功为null)
* 2. 交换机没有接收到消息
* 2.1 correlationData: 保存回调消息的Id及相关信息
* 2.2 ack: false代表交换机收到消息
* 2.3 cause: 失败原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String id = correlationData != null ? correlationData.getId() : "";
if (ack) {
log.info("交换机已收到Id为: {}的消息", id);
} else {
log.error("交换机未收到Id为: {}的消息,原因: {}", id, cause);
}
}
/**
* 当消息传递过程中"不可达目的地"时将消息"返回给生产者",成功不返回
* 但指定了备份交换机就不返回了(备份交换机优先级高)
*/
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
log.error("消息: {}, 被{}交换机退回, 退回原因: {}, RoutingKey: {}",
new String(message.getBody()), exchange, replyText, routingKey);
}
}
3. 备份交换机
- 有了 Mandatory 参数 和 回退消息,我们获得了对无法投递消息的感知能力,有机会在生产者的消息无法被投递时发现并处理。
- 但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。
- 而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。
- 而且设置
mandatory
参数会增加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。- 如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?
- 前面在设置死信队列的文章中,我们提到,可以为队列设置 死信队列 来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。
- 在 RabbitMQ 中,有一种 备份交换机 的机制存在,可以很好的应对这个问题。什么是备份交换机呢?
- 备份交换机可以理解为 RabbitMQ 中 交换机的备胎,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理。
- 通常 备份交换机 的类型为 Fanout ,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。
- 当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。
3.1 架构图
/**
* @author: wy
* describe: 发布确认高级
*/
@Configuration
public class ConfirmBackupConfig {
// 确认交换机
public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
// 确认队列
public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
// 确认RoutingKey
public static final String CONFIRM_ROUTING_KEY = "key1";
/**
* 直接交换机
*/
@Bean
public DirectExchange confirmExchange() {
// return new DirectExchange(CONFIRM_EXCHANGE_NAME);
// 但指定了备份交换机就不返回了(备份交换机优先级高)
return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME)
// 是否持久化
.durable(true)
// 指定备份交换机
.withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME).build();
}
@Bean
public Queue confirmQueue() {
return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
}
@Bean
public Binding confirmQueueBindingConfirmExchange(@Qualifier("confirmQueue") Queue confirmQueue,
@Qualifier("confirmExchange") DirectExchange confirmExchange) {
return BindingBuilder.bind(confirmQueue).to(confirmExchange).with(CONFIRM_ROUTING_KEY);
}
// 备份交换机
public static final String BACKUP_EXCHANGE_NAME = "backup.exchange";
// 备份队列
public static final String BACKUP_QUEUE_NAME = "backup.queue";
// 报警队列
public static final String WARNING_QUEUE_NAME = "warning.queue";
/**
* 扇出交换机
*/
@Bean
public FanoutExchange backupExchange() {
return new FanoutExchange(BACKUP_EXCHANGE_NAME);
}
@Bean
public Queue backupQueue() {
return QueueBuilder.durable(BACKUP_QUEUE_NAME).build();
}
@Bean
public Queue warningQueue() {
return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
}
@Bean
public Binding backupQueueBindingBackupExchange(@Qualifier("backupQueue") Queue backupQueue,
@Qualifier("backupExchange") FanoutExchange backupExchange) {
return BindingBuilder.bind(backupQueue).to(backupExchange);
}
@Bean
public Binding warningQueueBindingBackupExchange(@Qualifier("warningQueue") Queue warningQueue,
@Qualifier("backupExchange") FanoutExchange backupExchange) {
return BindingBuilder.bind(warningQueue).to(backupExchange);
}
}
3.2 报警消费者
/**
* @author: wy
* describe: 报警消费者
*/
@Slf4j
@Component
public class WarningConsumer {
@RabbitListener(queues = ConfirmBackupConfig.WARNING_QUEUE_NAME)
public void receiveMsg(Message message) {
log.warn("报警消费者接收到消息: {}", new String(message.getBody()));
}
}
4. 测试注意事项
- 问题:Mandatory参数 与 备份交换机 可以一起使用的时候,如果两者同时开启,消息究竟何去何从?谁优先级高?
- 结果:经过上面结果显示答案是 备份交换机优先级高。
八、其他
1. 消息应答
- 消费者完成一个任务可能需要一段时间,如果其中一个消费者处理一个很长的任务,并且只完成了部分任务突然就挂掉了,会发生什么情况。
- RabbitMQ 一旦向消费者传递了一条消息,便立即将该消息标记为删除。
- 在这种情况下,突然有个消费者挂掉了,我们将丢失正在处理的消息。
- 以及后续再发送给该消费者的消息,它都无法接收到。
- 为了保证消息在发送过程中不丢失,RabbitMQ 引入了 消息应答机制。
- 消息应答机制就是:消费者在接收到消息,并且处理该消息之后,告诉 RabbitMQ 它已经处理,RabbitMQ 可以把该消息删除了。
1.1 自动应答
- 消息发送后立即被认为已经传送成功,这种模式需要在 高吞吐量 和 数据传输安全性方面 做权衡,因为这种模式如果消息在接收到之前,消费者那边出现 连接 或 Channel 关闭,那么消息就丢失了,当然另一方面这种模式消费者那边可以传递过载的消息,没有对传递的消息数量进行限制,当然这样有可能使得消费者这边由于接收太多还来不及处理的消息,导致这些消息的积压,最终使得内存耗尽,最终这些消费者线程被操作系统杀死。
- 所以这种模式仅适用在消费者可以高效,并以某种速率能够处理这些消息的情况下使用。
1.2 手动应答
- 默认消息采用的是 自动应答,所以我们要想实现消息消费过程中不丢失。
- 需要把 自动应答 改为 手动应答,修改消费者代码。
1.2.1 手动应答的方法
// 1. false手动应答
boolean autoAck = false;
// 用于肯定确认(告诉 RabbitMQ 该消息成功的被处理,可以将其丢弃了)
void basicAck(long deliveryTag, boolean multiple);
// 用于否定确认
void basicNack(long deliveryTag, boolean multiple, boolean requeue);
// 用于拒绝消息(不处理该消息了直接拒绝,可以将其丢弃了)
void basicReject(long deliveryTag, boolean requeue);
- 手动应答的好处是:可以批量应答并且减少网络拥堵。
- multiple 的 true 和 false 代表不同意思。
- true 代表批量应答 Channel 上未应答的消息。
比如说 Channel 上有传送 tag 的消息 5, 6, 7, 8 当前 tag 是 8,那么此时 5-8 的这些还未应答的消息都会被批量确认收到消息应答。- false 同上面相比。
只会应答 tag=8 的消息,5, 6, 7 这三个消息依然不会被确认收到消息应答;
1.2.2 消息自动重新入队
- 如果消费者由于某些原因失去连接(通道已关闭、连接已关闭 或 TCP 连接丢失),导致消息未发送 ACK 确认,RabbitMQ 将了解到消息未完全处理,并将对其重新排队。
- 如果此时其他消费者可以处理,它将很快将其重新分发给另一个消费者。
- 这样,即使某个消费者偶尔死亡,也可以确保不会丢失任何消息。
1.2.3 消费者
- 默认消息采用的是 自动应答。
- 所以我们要想实现消息消费过程中不丢失,需要把自动应答改为 手动应答。
// 1. false手动应答
boolean autoAck = false;
/**
* 消息应答
* 1. 消息标记
* 2. 是否批量应答(false代表只应答接收到的那个消息,true代表应答信道中的所有消息包括接收到的消息)
*/
channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
/**
* @author: wy
* describe: 消费者
* 1. 手动应答
* 4. 不公平分发
*/
public class Consumer1 {
private static final String QUEUE_NAME = "ack_queue";
public static void main(String[] args) throws IOException, TimeoutException {
System.out.println("Consumer1,睡1秒");
// 获取信道
Channel channel = RabbitMQUtil.getChannel();
/*
4. 不公平分发:
prefetchCount = 0 轮询(默认)
prefetchCount = 1 不公平分发,能者多劳
prefetchCount = 2 预取值
*/
// int prefetchCount = 1;
int prefetchCount = 2;
channel.basicQos(prefetchCount);
// 1. false手动应答
boolean autoAck = false;
channel.basicConsume(QUEUE_NAME, autoAck,
// 接收消息
(consumerTag, message) -> {
try {
// 睡1秒
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.printf("接收到消息: %s\r\n", new String(message.getBody(), "UTF-8"));
/**
* 消息应答
* 1. 消息标记
* 2. 是否批量应答(false代表只应答接收到的那个消息,true代表应答信道中的所有消息包括接收到的消息)
*/
channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
},
// 取消消息
consumerTag -> {
System.out.printf("中断消费消息: %s\r\n", consumerTag);
});
}
}
1.2.4 生产者
/**
* @author: wy
* describe: 生产者
* 1. 消息应答
* 2. 队列持久化
* 3. 消息持久化
* 4. 不公平分发
* 4.1 prefetchCount = 0 轮询(默认)
* 4.2 prefetchCount = 1 不公平分发,能者多劳
* 4.3 prefetchCount = 2 预取值
*/
public class Producer {
private static final String QUEUE_NAME = "ack_queue";
public static void main(String[] args) throws IOException, TimeoutException {
// 获取信道
Channel channel = RabbitMQUtil.getChannel();
// 2. 队列持久化
boolean durable = true;
channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
// 从控制台接收消息
Scanner scanner = new Scanner(System.in);
System.out.println("输入消息: ");
while (scanner.hasNext()) {
String message = scanner.next();
// 3. 消息持久化
AMQP.BasicProperties props = MessageProperties.PERSISTENT_TEXT_PLAIN;
channel.basicPublish("", QUEUE_NAME, props, message.getBytes("UTF-8"));
System.out.printf("发送消息: %s\r\n", message);
}
}
}
2. RabbitMQ 持久化
- 刚刚我们已经看到了如何 处理任务不丢失 的情况,但是如何保障当 RabbitMQ 服务停掉以后,消息生产者发送过来的 消息不丢失。
- 默认情况下 RabbitMQ 退出或由于某种原因崩溃时,它忽视队列和消息,除非告知它不要这样做。
- 确保消息不会丢失需要做两件事:我们需要将 队列 和 消息 都标记为持久化。
2.1 队列持久化(Features = D
)
- 之前我们创建的队列都是非持久化的,RabbitMQ 如果重启的话,该队列就会被删除掉。
- 如果要队列实现持久化,需要在声明队列的时候把
durable
参数设置为持久化。
// 2. 队列持久化
boolean durable = true;
channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
- 以下为控制台中 持久化 与 非持久化 队列的 UI 显示区。
- 这个时候即使重启 RabbitMQ 队列也依然存在。
2.2 消息持久化
- 要想让消息实现持久化,需要在消息生产者端添加这个属性:
MessageProperties.PERSISTENT_TEXT_PLAIN
。
// 3. 消息持久化
AMQP.BasicProperties props = MessageProperties.PERSISTENT_TEXT_PLAIN;
channel.basicPublish("", QUEUE_NAME, props, message.getBytes("UTF-8"));
- 将消息标记为持久化,并不能完全保证消息不会丢失。
- 尽管它告诉 RabbitMQ 将消息保存到磁盘,但是这里依然存在当消息刚准备存储在磁盘的时候,但是还没有存储完,消息还在缓存的一个间隔点。
- 此时并没有真正写入磁盘。
- 持久性保证并不强,但是对于我们的简单任务队列而言,这已经绰绰有余了。
- 如果需要更强有力的持久化策略,参考 发布确认。