RabbitMQ指南(五) 消费者
5.1 消息接收确认
5.1.1 消息接收手工确认
消息从队列推送至消费者后,消息被消费,并从队列中移除。若在消费者消费消息的过程中出现异常或回滚,当消费者从异常中恢复后,想要重新处理异常的消息,然而消息已经从队列中移除,无法再次获取。为处理该问题,避免消息的丢失,需利用消息接收的确认机制。
消息是在得到确认(Acknowledged,ACK)后,从队列中移除的。默认情况下,消息确认是自动进行的,消息在发送给消费者后立即确认。为避免消息丢失,使用手工确认,自行管理消息接收确认的时机。
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) {
String message = new String(body);
System.out.println("Received: " + message);
// 消息确认
try {
channel.basicAck(envelope.getDeliveryTag(), false);
} catch (IOException e) {
e.printStackTrace();
}
}
};
// 关闭自动消息确认,autoAck = false
channel.basicConsume("Queue_Java", false, consumer);
}
在消费者监听队列时,关闭自动确认,在回调方法中手工进行确认。可以将回调方法中,消息确认一段注释掉,运行程序观察效果,可以看到,若消费者接收到消息后未进行确认,则消息依然保存在队列中。
5.1.2 消息的批量确认
向信道的每次投递都带有一个投递标签(Delivery Tag),该投递标签是一个64位长的值,从1开始每次增加1,用于唯一标识信道的每次投递。
channel.basicAck()方法的第一个参数位投递标签,用于标识对哪次消息投递进行确认,第二个参数表示是否进行消息的批量确认。若确认消息时开启批量确认,则投递标签小于当前消息投递标签的所有消息也都会进行确认。
使用批量确认,可起到减少网络流量的作用。
示例:
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) {
String message = new String(body);
System.out.println("Received: " + message);
// 投递标签“10”之前的消息进行批量确认
if(10L == envelope.getDeliveryTag()) {
try {
channel.basicAck(envelope.getDeliveryTag(), true);
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
channel.basicConsume("Queue_Java", false, consumer);
}
程序中只对投递标签为“10”的消息进行确认,但使用批量确认。运行程序,向队列中连续发送20条消息。登录Web管理界面,查看队列,队列中有10条消息得到了确认,剩余的10条消息处于未确认(Unacked)状态。

批量确认消息参数置为false,重新创建队列、运行消费者,向其中发送20条消息后,队列中未确认的消息变为19条,仅投递标签为“10”的一条消息得到了确认。
5.1.3 消息的接收拒绝
若投递的消息数目已经超过消费者的处理能力,继续投递消息将会导致消息的积压。此时消费者可选择拒绝。
void basicNack(long deliveryTag, boolean multiple, boolean requeue):deliveryTag表示被拒绝的消息的投递标签;multiple表示是否批量拒绝,若是则所有投递标签小于当前消息且未确认的消息也都将被拒绝,若否则仅拒绝当前消息;requeue表示被拒绝的消息是否重新放回队列,若是则消息会重新回到队列并选择新的消费者进行投递,若否则该条消息会被丢弃。
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) {
String message = new String(body);
System.out.println("Received: " + message);
// 消息拒绝
try {
channel.basicNack(envelope.getDeliveryTag(), false, true);
} catch (IOException e) {
e.printStackTrace();
}
}
};
channel.basicConsume(QUEUE_NAME, false, consumer);
}
运行消费者,并向队列中发送消息,可以看到,由于消息始终被拒绝,并重回队列,消费者的控制台上会连续不断重复打印出队列的消息。
5.1.4 消息确认的进一步讨论
为解决消息自动确认后消费者异常,导致消息丢失的问题,使用了手工确认的方案,只有在消息处理成功后,才向RabbitMQ服务端返回确认。若在消息处理过程中出现异常,则消费者在恢复后,消息依然保留在队列中,可交由消费者重新处理,避免了消息的丢失。
但仅此还不够,若在消费者处理消息成功后、返回确认ACK之前出现异常,或在传输ACK消息的过程中,网络出现异常,导致ACK未发送给RabbitMQ服务端,会出现什么情况呢?这条消息将会作为未确认的消息留在队列中,并在信道断开后,交由其他消费者进行处理,这就造成同一个消息被处理2次,若这是一笔转账消息,后果很严重,这笔转账将会被转双倍的钱。
要解决以上问题,需要要求消费者对消息的处理具有幂等性,即消息处理一次与处理多次效果相同。通常在消费者端维护一个列表,记录被处理过的消息,消费者收到消息后,首先查询该列表,若消息已被处理则丢弃,否则才继续处理。
5.2 多消费者消息分配
前文的场景都是一个消费者监听一个队列,但当一个队列由多个消费者监听时,消息在多个消费者之间是如何分配的呢?
5.1.1 轮询分配
当有多个消费者同时监听一个队列时,RabbitMQ默认将消息逐一顺序分配给各消费者,该消息分配机制称为轮询(Round-Robin)。
为验证该机制,建立两个消费者,同时监听同一队列,消息生产者连续向队列中发送20条消息,查看消息的分配状况。
生产者——
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
for(int i = 0; i < 20; i++) {
byte[] message = ("message" + i).getBytes();
channel.basicPublish("", "Queue_Java", null, message);
}
channel.close();
connection.close();
}
消费者——
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) {
String message = new String(body);
System.out.println("Received: " + message);
try {
channel.basicAck(envelope.getDeliveryTag(), false);
} catch (Exception e) {
e.printStackTrace();
}
}
};
// 标识进程,第二个消费者将输出内容改为“Consumer2:”,再次运行程序即可
System.out.println("Consumer2:");
channel.basicConsume(QUEUE_NAME, false, consumer);
}
运行结果如下:

第一个消费者收到了所有偶数号的消息,第二个消费者收到了所有奇数号的消息,消息被顺序分配给了两个消费者。
5.1.2 消息预取
消息转发到队列后,分配是提前一次性完成的,即RabbitMQ尽可能快速地将消息推送至客户端,由客户端缓存本地,而并非在消息消费时才逐一确定。再加入新的消费者时,队列已经为空,即使前面的消费者未处理完消息,新加入的消费者也不会接收到。
为验证该结论,在消费者处理消息的方法中,加入线程休眠。首先启动2个消费者,生产者将20个消息发送完毕后,断开2号消费者,启动3号消费者,观察消息消费情况。
消费者——
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) {
String message = new String(body);
System.out.println("Received: " + message);
try {
Thread.sleep(5000); // 加入线程休眠
channel.basicAck(envelope.getDeliveryTag(), false);
} catch (Exception e) {
e.printStackTrace();
}
}
};
System.out.println("Consumer1:");
channel.basicConsume(QUEUE_NAME, false, consumer);
}
程序运行结果:

可见,中途断开的2号消费者所应消费的余下的奇数号消息,既未分配给新加入的3号消费者,也未交给发送消息前已建立连接的1号消费者。
5.1.3 公平分配
消息的轮询分配机制和尽可能快速推送消息的机制给实际使用带来困难。实际情况下,每个消费者处理消息的能力、每个消息处理所需时间可能都是不同的,若只是机械化地顺次分配,可能造成一个消费者由于处理的消息的业务复杂、处理能力低而积压消息,另一个消费者早早处理完所有的消息,处于空闲状态,造成系统的处理能力的浪费。且无法加入新的消费者以提高系统的处理能力。
希望达到的效果是每个消费者都根据自身处理能力合理分配消息处理任务,既无挤压也无空闲,新加入的消费者也能分担消息处理任务,使系统的处理能力能够平行扩展。
RabbitMQ客户端可通过Channel类的basicQos(int prefetchCount)设置消费者的预取数目,即消费者最大的未确认消息的数目。
假设prefetchCount=10,有两个消费者,两个消费者依次从队列中抓取10条消息缓存本地,若此时有新的消息到达队列,先判断信道中未确认的消息是否大于或等于20条,若是,则不向信道中投递消息,当信道中未确认消息数小于20条后,信道中哪个消费者未确认消息小于10条,就将消息投递给哪个消费者。
设置信道的预取数量为1,重复5.1.2节的测试。
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
channel.queueDeclare("Queue_Java", false, false, false, null);
// 设置预取数量为1
channel.basicQos(1);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) {
String message = new String(body);
System.out.println("Received: " + message);
try {
Thread.sleep(5000); // 加入线程休眠
channel.basicAck(envelope.getDeliveryTag(), false);
} catch (Exception e) {
e.printStackTrace();
}
}
};
System.out.println("Consumer1:");
channel.basicConsume(QUEUE_NAME, false, consumer);
}
程序运行结果(消费者2在打印5号消息后、返回确认前被停止):

在停止消费者2、加入消费者3后,消息被平均分配给消费者1和3,达到了所需的效果。
还可将两个消费者的休眠时间设为不同值(代表不同的处理消息耗时),观察运行情况。消息将会按照消息处理速度的比例分配给两个消费者,达到了消息均衡分配的效果。
5.1.4 预取数量的优化
channel.basicQos()中设置的预取数量多少合适,是一个颇有讲究的问题。我们希望充分利用消费者的处理能力,因此不宜设置过小,否则在消费者处理消息后,RabbitMQ收到确认消息后才会投递新的消息,导致此期间消费者处于空闲状态,浪费消费者的处理能力;但设置过大,又可能使消息积压在消费者的缓存里,我们希望对于来不及处理的消息,应保留在队列中,便于加入新的消费者或空闲出来的消费者分担消息处理任务。
RabbitMQ官网的一篇文章详细讨论了预取数量的设置问题:
https://www.rabbitmq.com/blog/2012/05/11/some-queuing-theory-throughput-latency-and-bandwidth/
文章大致内容如下。
假设从RabbitMQ服务端队列取消息、传输消息到消费者耗时为50ms,消费者消费消息耗时4ms,消费者传输确认消息到服务端耗时为50ms。若网络状况、消费者处理速度稳定,则预取数量的最优数值为:(50 + 4 + 50)/4=26个。

最初服务端将向客户端发送26条消息,并缓存在客户端本地,当消费者处理好第一个消息后,向服务端发送确认消息并取本地缓存的第二个消息,确认消息由客户端传送到服务端耗时50ms,服务端收到确认后发送新的消息经过50ms又到达了客户端,而余下的25个消息被消费耗时为25×4=100ms,所以当新的消息达到时,第一轮的26个消息恰好全部处理完。依次类推,之后,每当处理完一个旧有的消息时,恰好会到达一个新的消息。既不会发生消息积压,消费者也不会空闲。
但实际情况是,网络的传输状况、消费者处理消息的速度都不会是恒定的,会时快时慢,造成消息积压或消费者空闲,这就要求预取数量要与网络和消费者的状况实时改变。
新近发表的一个称作“Controlled Delay”(控制延迟?)算法(参见https://queue.acm.org/detail.cfm?id=2209336),能够较好地解决此问题。作者实现了其Java版本:
https://gist.github.com/2658712
文章中说明了其中的参数,有兴趣者可自行研究。
5.3 消息订阅与轮询
前文的示例皆使用向队列注册消费者,当RabbitMQ服务端的队列接收到消息后推送给客户端,这种方式为消息订阅模式。
RabbitMQ客户端也可通过主动查询的方式,从服务端获取消息。使用主动查询的消费者示例如下。
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
// 设置服务端的地址、端口、用户名和密码...
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
// 从队列中获取消息,不自动确认
GetResponse response = channel.basicGet("Queue_Java", false);
if(null != response) {
byte[] body = response.getBody();
String message = new String(body);
System.out.println("Received: " + message);
// 手工确认
long deliveryTag = response.getEnvelope().getDeliveryTag();
channel.basicAck(deliveryTag, false);
}
channel.close();
connection.close();
}
在示例程序中,消费者主动向服务端请求一条消息,并在输出控制台后手工确认。在获取消息的那段代码外层加上循环,连续不断向服务端队列获取消息,这就是获取消息的第二种方式——轮询。可从以下几方面对比订阅和轮询两种获取消息的方式。
1.订阅方式需服务端维护消息的传输状态,失败需重试,轮询则需客户端对传输失败进行处理;
2.订阅方式当消息队列有消息时即可得到推送,实时性较好,轮询的实时性依赖于轮询间隔;
3.订阅方式需服务端针对各消费者的处理能力做流量控制,使用轮询方式时,消费者可依照自身处理能力决定是否获取新的消息。