1. 工作队列
Work Queues— 工作队列 (又称任务队列) 的主要思想是避免立即执行资源密集型任务,而不得不等待它完成。
我们把任务封装为消息并将其发送到队列,在后台运行的工作进程将弹出任务并最终执行作业。当有多个工作线程时,这些工作线程将一起处理这些任务
2. 轮询的消息分发方式:
消费者消费消息的方式默认是采用轮询的消费方式。
在这个案例中我们会启动两个工作线程消费,一个生产线程生产,我们来看看他们两个工作线程是如何工作的:
2.1 消费者代码:
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
service.submit(() -> {
try {
final Channel channel = MqConnectUtil.getChannel();
log.debug(Thread.currentThread().getName() + "线程," + channel.getChannelNumber() + "信道,启动等待消费");
channel.basicConsume(MqConnectUtil.QUEUE_NAME,
(consumerTag, message) -> {
log.debug(Thread.currentThread().getName() + "线程," + channel.getChannelNumber() + "信道,接收到的消息: {}", new String(message.getBody()));
},
(consumerTag) -> {
log.debug(consumerTag, "消费者取消消费接口回调逻辑");
});
service.shutdown();
log.debug("finish");
} catch (Exception e) {
e.printStackTrace();
}
});
log.debug("finish2");
}
}
2.2 生产者代码:
public static void main(String[] args) throws Exception {
Channel channel = MqConnectUtil.getChannel();
channel.queueDeclare(MqConnectUtil.QUEUE_NAME, false, false, false, null);
for (int i = 0; i < 5; i++) {
channel.basicPublish("", MqConnectUtil.QUEUE_NAME, null, String.valueOf(i).getBytes());
log.debug("生产了:" + i);
Thread.sleep(1000);
}
}
2.3 测试:
启动生产者,接着立即启动消费者,消费者结果:
00:22:37.921 [main] DEBUG worker02 - finish2
00:22:37.927 [main] DEBUG worker02 - finish2
00:22:38.208 [pool-1-thread-2] DEBUG worker02 - pool-1-thread-2线程,1信道,启动等待消费
00:22:38.208 [pool-1-thread-1] DEBUG worker02 - pool-1-thread-1线程,1信道,启动等待消费
00:22:38.230 [pool-1-thread-1] DEBUG worker02 - finish
00:22:38.230 [pool-1-thread-2] DEBUG worker02 - finish
00:22:45.142 [pool-2-thread-4] DEBUG worker02 - pool-2-thread-4线程,1信道,接收到的消息: 0
00:22:46.159 [pool-3-thread-4] DEBUG worker02 - pool-3-thread-4线程,1信道,接收到的消息: 1
00:22:47.167 [pool-2-thread-5] DEBUG worker02 - pool-2-thread-5线程,1信道,接收到的消息: 2
00:22:48.175 [pool-3-thread-5] DEBUG worker02 - pool-3-thread-5线程,1信道,接收到的消息: 3
00:22:49.186 [pool-2-thread-6] DEBUG worker02 - pool-2-thread-6线程,1信道,接收到的消息: 4
对于结果,我感到奇怪,为什么会有这么多的线程,以为是代码写的有问题,稍作修改,重启生产者和消费者:
00:23:53.123 [main] DEBUG worker02 - finish2
00:23:53.127 [main] DEBUG worker02 - finish2
00:23:53.344 [pool-1-thread-2] DEBUG worker02 - pool-1-thread-2线程,1信道,启动等待消费
00:23:53.344 [pool-1-thread-1] DEBUG worker02 - pool-1-thread-1线程,1信道,启动等待消费
00:23:53.362 [pool-1-thread-1] DEBUG worker02 - finish
00:23:53.365 [pool-1-thread-2] DEBUG worker02 - finish
00:23:53.366 [pool-3-thread-4] DEBUG worker02 - pool-3-thread-4线程,1信道,接收到的消息: 0
00:23:53.369 [pool-3-thread-4] DEBUG worker02 - pool-3-thread-4线程,1信道,接收到的消息: 1
00:23:53.369 [pool-3-thread-4] DEBUG worker02 - pool-3-thread-4线程,1信道,接收到的消息: 2
00:23:53.369 [pool-3-thread-4] DEBUG worker02 - pool-3-thread-4线程,1信道,接收到的消息: 3
00:23:53.369 [pool-3-thread-4] DEBUG worker02 - pool-3-thread-4线程,1信道,接收到的消息: 4
00:24:00.340 [pool-3-thread-5] DEBUG worker02 - pool-3-thread-5线程,1信道,接收到的消息: 0
00:24:01.363 [pool-2-thread-4] DEBUG worker02 - pool-2-thread-4线程,1信道,接收到的消息: 1
00:24:02.377 [pool-3-thread-6] DEBUG worker02 - pool-3-thread-6线程,1信道,接收到的消息: 2
00:24:03.389 [pool-2-thread-5] DEBUG worker02 - pool-2-thread-5线程,1信道,接收到的消息: 3
00:24:04.397 [pool-3-thread-7] DEBUG worker02 - pool-3-thread-7线程,1信道,接收到的消息: 4
接下来我更加奇怪了,很多问题展示出来:
- 为什么在之前已经消费的消息被重新消费了?
- 为什么有这么多的线程,而不是thread01和thread02执行
- 为什么信道的nunber都是1?
2.4 问题的排查与解决:
2.4.1 问题一:为什么在之前已经消费的消息被重新消费了?
经过代码排查,发现在消费者消费消息的时候的方法调用错了。
我的调用:
String basicConsume(String var1, DeliverCallback var2, CancelCallback var3) throws IOException;
可以看到,只有三个参数,少了配置消费后是否需要自动应答的参数,导致消费过的参数没有被应答:(图中的Unacked表示未应答的意思) 而未应答的消息,在断开连接,宕机等情况,消息状态从unacked重回ready,也就是未应答状态(以后文章会讲到,这里不细说)。
解决方案:使用如下接口,并设置第二个参数为true
String basicConsume(String var1, boolean var2, DeliverCallback var3, CancelCallback var4) throws IOException;
2.4.2 问题二:为什么有这么多的线程,而不是thread01和thread02执行
经过排查,发现这些线程的pool的编号并不是我创建的pool1,而是pool3和pool4,也就是说这些线程的线程池并不是我所创建的线程池。
关于pool编号的解释:
排查线程池被创建的原因: 查看到创建连接的源码:可以看出利用了线程池的接口
public Connection newConnection(ExecutorService executor, List<Address> addrs) throws IOException, TimeoutException {
return this.newConnection(executor, (List)addrs, (String)null);
}
我的理解是消费者消费mq中的消息的时候,是通过此线程池创建连接去进行消费的,所以这里是pool3和pool2,而正因为pool3和pool2是一隔一个有序出现,所以这里也能证明,消费者消费消息是通过轮询的方式。
而线程名称相同,是因为是不同的线程池创建的线程,所以线程名称相同: 测试:
public static void main(String[] args) throws IOException, TimeoutException {
ExecutorService s = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
s.submit(() -> {
ExecutorService service = Executors.newFixedThreadPool(2);
for (int k = 0; k < 2; k++) {
service.submit(() -> {
System.out.println(Thread.currentThread().getName());
});
}
});
}
}
结果:
pool-2-thread-1
pool-3-thread-2
pool-2-thread-2
pool-3-thread-1
恍然大悟:其实线程名称并不同,只是thread-1相同,全名称是不同的,前面的pool标记也要带上
2.4.3 问题三:为什么信道的nunber都是1
通过查看rabbitmq的客户端,可以看到,产生了三个连接,一个生产者连接,2个消费者连接,所以这里我的理解是,每个连接单独产生了一个信道,然后这个信道在连接中的number都是1。
此处还是要详细研究下connection和channel。
2.5 其他方式展示轮询的消费方式:
2.5.1 消费者代码:
public static void main(String[] args) throws Exception {
Channel channel = RabbitMqUtils.getChannel();
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
String receivedMessage = new String(delivery.getBody());
System.out.println("接收到消息:" + receivedMessage);
};
CancelCallback cancelCallback = (consumerTag) -> {
System.out.println(consumerTag + "消费者取消消费接口回调逻辑");
};
System.out.println("C1 消费者启动等待消费.................. ");
channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
}
2.5.2 实现2个消费者线程:
选中 Allow multiple instances : 启动2次消费者main函数:
2.5.3 生产者代码:
public static void main(String[] args) throws Exception {
Channel channel = RabbitMqUtils.getChannel();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String message = scanner.next();
channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
System.out.println("消息发送完成:" + message);
}
}
2.5.4 结果:
通过程序执行发现生产者总共发送 4 个消息,消费者 1 和消费者 2 分别分得两个消息,并且是按照有序的一个接收一次消息。
3. 消息应答
消费者完成一个任务可能需要一段时间,如果其中一个消费者处理一个长的任务并仅只完成了部分突然它挂掉了,会发生什么情况。
RabbitMQ 一旦向消费者传递了一条消息,便立即将该消息标记为删除。在这种情况下,突然有个消费者挂掉了,我们将丢失正在处理的消息,以及后续发送给该消费者的消息,因为它无法接收到。
为了保证消息在发送过程中不丢失,引入消息应答机制,消息应答就是:消费者在接收到消息并且处理该消息之后,告诉 rabbitmq 它已经处理了,rabbitmq 可以把该消息删除了
消息应答的两种方式:
- 自动应答
- 手动应答
3.1 自动应答:
消息发送后立即被认为已经传送成功。
这种模式需要在高吞吐量和数据传输安全性方面做权衡,因为这种模式如果消息在接收到之前,消费者那边出现连接或者 channel 关闭,那么消息就丢失了。
当然另一方面这种模式消费者那边可以传递过载的消息,没有对传递的消息数量进行限制,当然这样有可能使得消费者这边由于接收太多还来不及处理的消息,导致这些消息的积压,使得内存耗尽,最终这些消费者线程被操作系统杀死,所以这种模式仅适用在消费者可以高效并以某种速率能够处理这些消息的情况下使用
3.2 手动应答:
手动应答的三个方法:
- Channel.basicAck (用于肯定确认):RabbitMQ 已知道该消息成功被处理,可以将其丢弃了。
- Channel.basicNack (用于否定确认)
- Channel.basicReject (用于否定确认):与 Channel.basicNack 相比少一个参数,不处理该消息了直接拒绝,可以将其丢弃了。
3.3 批量应答:
手动应答的好处是可以批量应答并且减少网络拥堵
批量应答的设置通过:
- true 代表批量应答 channel 上未应答的消息:比如说队列往信道中放了4个消息 5、6、7、8, 当前消息是 8 那么此时 5-8 的这些还未应答的消息都会被确认收到消息应答。
- false 同上面相比只会应答8的消息, 5、6、7 这三个消息依然不会被确认收到消息应答。
3.4 消息自动重新入队
如果消费者由于某些原因失去连接 (其通道已关闭,连接已关闭或 TCP 连接丢失),导致消息未发送 ACK 确认,RabbitMQ 将了解到消息未完全处理,并将对其重新排队。
如果此时其他消费者可以处理,它将很快将其重新分发给另一个消费者。这样,即使某个消费者偶尔死亡,也可以确保不会丢失任何消息。
所以这里要注意消息重复消费的问题做好事务回滚。
3.5 消息手动应答代码
默认消息采用的是自动应答,所以我们要想实现消息消费过程中不丢失,需要把自动应答改为手动应答。
消费者在上面代码的基础上增加了以下内容:
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
3.5.1 生产者代码:
public static void main(String[] args) throws Exception {
Channel channel = MqConnectUtil.getChannel();
channel.queueDeclare(MqConnectUtil.QUEUE_NAME, false, false, false, null);
Scanner sc = new Scanner(System.in);
System.out.println("请输入信息");
while (sc.hasNext()) {
String message = sc.nextLine();
channel.basicPublish("", MqConnectUtil.QUEUE_NAME, null, message.getBytes("UTF-8"));
System.out.println("生产者发出消息" + message);
}
}
3.5.2 两个消费者代码:
public static void main(String[] args) throws Exception{
final Channel channel = MqConnectUtil.getChannel();
log.debug("C1启动等待消费,执行时间较短");
channel.basicConsume(MqConnectUtil.QUEUE_NAME, false,
(consumerTag, message) -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("C1接收到的消息: {}", new String(message.getBody()));
channel.basicAck(message.getEnvelope().getDeliveryTag(),false);
},
(consumerTag) -> {
log.debug(consumerTag, "消费者取消消费接口回调逻辑");
});
}
第二个复制一份,将睡眠时间改为5s
3.5.3 演示结果:
正常情况下消息发送方发送两个消息,C1 和 C2 分别接收到消息并进行处理 在发送者发送消息 dd,发出消息之后的把 C2 消费者停掉,按理说该 C2 来处理该消息,但是由于它处理时间较长,在还未处理完,也就是说 C2 还没有执行 ack 代码的时候,C2 被停掉了,此时会看到消息被 C1 接收到了,说明消息 dd 被重新入队,然后分配给能处理消息的 C1 处理了
3.5.4 未应答消息重新执行:
修改C2的执行时间为20s,生产者生产3个消息,aa,bb,cc;可以看到C1处理了aa和cc,C2处理了bb但是执行时间较长,此时将C2关闭,可以看到C1又处理了bb:
10:27:26.352 [main] DEBUG worker03 - C1启动等待消费,执行时间较短
10:27:49.997 [pool-1-thread-4] DEBUG worker03 - C1接收到的消息: aa
10:28:01.471 [pool-1-thread-5] DEBUG worker03 - C1接收到的消息: cc
10:28:05.435 [pool-1-thread-6] DEBUG worker03 - C1接收到的消息: bb
因为C2虽然正常处理bb,但是并没有给出应答,此时C2突然宕机,消息自动重新入队,同时被C1接收并消费。
在之前问题的排查与解决中的问题一已经发现并解决过此问题。
4. 持久化
当 RabbitMQ 服务停掉以后,消息生产者发送过来的消息不丢失要如何保障?默认情况下 RabbitMQ 退出或由于某种原因崩溃时,它忽视队列和消息,除非告知它不要这样做。
确保消息不会丢失需要做两件事:我们需要将队列和消息都标记为持久化
4.1 队列持久化
之前创建的队列都是非持久化的,rabbitmq 如果重启的话,该队列就会被删除掉,如果要队列实现持久化需要在声明队列的时候把 durable 参数设置为持久化
boolean durable = true;
channel.queueDeclare(TASK_QUEUE_NAME, durable, false, false, null);
注意:如果之前声明的队列不是持久化的,需要把原先队列先删除,或者重新创建一个持久化的队列,不然就会出现错误 以下为控制台中持久化与非持久化队列的 UI 显示区:
4.2 消息持久化
虽然持久化了队列,还是如果消息不进行持久化,消息还是会丢失,所以我们同样需要持久化消息。
消息实现持久化需要在消息生产者修改代码,MessageProperties.PERSISTENT_TEXT_PLAIN 添加这个属性
将消息标记为持久化并不能完全保证不会丢失消息。
尽管它告诉 RabbitMQ 将消息保存到磁盘,但是这里依然存在当消息刚准备存储在磁盘的时候但是还没有存储完,消息还在缓存的一个间隔点。此时并没有真正写入磁盘。持久性保证并不强,但是对于我们的简单任务队列而言,这已经绰绰有余了
5. 不公平分发:
问题:
在最开始的时候我们学习到 RabbitMQ 分发消息采用的轮询分发,但是在某种场景下这种策略并不是很好,比方说有两个消费者在处理任务,其中有个消费者 1 处理任务的速度非常快,而另外一个消费者 2 处理速度却很慢,这个时候我们还是采用轮询分发的化,就会发现处理速度快的这个消费者很大一部分时间处于空闲状态,而处理慢的那个消费者一直在干活,这种分配方式在这种情况下其实就不太好,但是 RabbitMQ 并不知道这种情况它依然很公平的进行分发。
为了避免这种情况,在消费者中消费之前,我们可以设置参数 channel.basicQos(1);
int prefetchCount = 1;
channel.basicQos(prefetchCount);
boolean autoAck = false;
channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, cancelCallback);
意思就是如果这个任务我还没有处理完或者我还没有应答你,你先别分配给我,我目前只能处理一个任务,然后 rabbitmq 就会把该任务分配给没有那么忙的那个空闲消费者。
当然如果所有的消费者都没有完成手上任务,队列还在不停的添加新任务,队列有可能就会遇到队列被撑满的情况,这个时候就只能添加新的 worker 或者改变其他存储任务的策略
演示:
修改C1和C2的代码,将他们修改为不公平分发,结果: C1: C2: 可以看到C2只完成了一个任务,而C1完成了5个任务
6. 预取值分发:
6.1 预取值的含义:
通俗点讲:
假如我现在队列中产生了7条消息,如果C1设置了预期值为2,C2设置了预期值为5,那么前几点条消息,按照轮询的方式分发,直到C1的信道上有2条消息,而C2有5条消息。
如果再来了第8,第9条消息,就按照不公平分发的方式去分发。
6.2 带权的消息分发:
本身消息的发送就是异步发送的,所以在任何时候,channel 上肯定不止只有一个消息,另外来自消费者的手动确认本质上也是异步的。因此这里就存在一个未确认的消息缓冲区,因此希望开发人员能限制此缓冲区的大小,以避免缓冲区里面无限制的未确认消息问题。这个时候就可以通过使用 basic.qos 方法设 置 “预取计数” 值来完成的。
该值定义通道上允许的未确认消息的最大数量。一旦数量达到配置的数量, RabbitMQ 将停止在通道上传递更多消息,除非至少有一个未处理的消息被确认,例如,假设在通道上有未确认的消息 5、6、7,8,并且通道的预取计数设置为 4,此时 RabbitMQ 将不会在该通道上再传递任何消息,除非至少有一个未应答的消息被 ack。比方说 tag=6 这个消息刚刚被确认 ACK,RabbitMQ 将会感知 这个情况到并再发送一条消息。消息应答和 QoS 预取值对用户吞吐量有重大影响。
通常,增加预取值将提高向消费者传递消息的速度。虽然自动应答传输消息速率是最佳的,但是,在这种情况下已传递但尚未处理的消息的数量也会增加,从而增加了消费者的 RAM 消耗。应该小心使用具有无限预处理的自动确认模式或手动确认模式,消费者消费了大量的消息如果没有确认的话,会导致消费者连接节点的内存消耗变大,所以找到合适的预取值是一个反复试验的过程,不同的负载该值取值也不同 100 到 300 范 围内的值通常可提供最佳的吞吐量,并且不会给消费者带来太大的风险。
预取值为 1 是最保守的。当然这将使吞吐量变得很低,特别是消费者连接延迟很严重的情况下,特别是在消费者连接等待时间较长的环境 中。对于大多数应用来说,稍微高一点的值将是最佳的
6.3 测试:
设置C1的预取值为2并且执行时间为1s,C2的预取值为5,并且执行时间为30s:
int prefetchCount = 5;
channel.basicQos(prefetchCount);
6.3.1 测试结果:
- 启动2个消费者和一个生产者:可以看到prefetch一个为2一个为5
- 生产11个消息:
请输入信息
1
生产者发出消息1
2
生产者发出消息2
3
生产者发出消息3
4
生产者发出消息4
5
生产者发出消息5
6
生产者发出消息6
7
生产者发出消息7
8
生产者发出消息8
9
生产者发出消息9
10
生产者发出消息10
11
生产者发出消息11
- 查看客户端数据:
- 查看执行情况:
13:47:07.431 [main] DEBUG worker03 - C1启动等待消费,执行时间较短
13:48:45.390 [pool-1-thread-4] DEBUG worker03 - C1接收到的消息: 2
13:48:47.555 [pool-1-thread-5] DEBUG worker03 - C1接收到的消息: 4
13:48:49.916 [pool-1-thread-6] DEBUG worker03 - C1接收到的消息: 6
13:48:52.173 [pool-1-thread-7] DEBUG worker03 - C1接收到的消息: 8
13:48:55.083 [pool-1-thread-8] DEBUG worker03 - C1接收到的消息: 10
13:48:56.342 [pool-1-thread-9] DEBUG worker03 - C1接收到的消息: 11
13:47:04.432 [main] DEBUG worker04 - C2启动等待消费,执行时间较长
13:49:13.370 [pool-1-thread-4] DEBUG worker04 - C2接收到的消息: 1
- C2最终结果:
13:47:04.432 [main] DEBUG worker04 - C2启动等待消费,执行时间较长
13:49:13.370 [pool-1-thread-4] DEBUG worker04 - C2接收到的消息: 1
13:49:43.378 [pool-1-thread-5] DEBUG worker04 - C2接收到的消息: 3
13:50:13.390 [pool-1-thread-5] DEBUG worker04 - C2接收到的消息: 5
13:50:43.393 [pool-1-thread-5] DEBUG worker04 - C2接收到的消息: 7
13:51:13.405 [pool-1-thread-5] DEBUG worker04 - C2接收到的消息: 9
从结果我们可以看出,消息在分发时,先按照轮询,满足C1的信道中有2条消息,C2的信道中有5条消息,再之后就按照不公平分发处理。
|