RPC实现
RPC,即Remote Procedure Call,即远程过程调用,它是一种通过网络从远程计算机上请求服务,而不需要了解底层网络的技术。RPC的主要功能是让构建分布式计算更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。
通俗地说就是两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。
为什么要使用RPC呢?就是无法在一个进程内,甚至一个计算机内通过本地调用的方式完成的需求,比如不同的系统间的通讯,甚至不同的组织间的通讯。由于计算能力需要横向扩展,需要在多台机器组成的集群上部署应用,
RPC的协议有很多,比如最早的CORBA,Java RMI,Web Service的RPC风格,Hessian,Thrift,甚至Rest API。
RabbitMQ怎么实现RPC调用?一般在RabbitMQ中进行RPC是很简单的,客户端发送请求消息,服务端回复响应的消息,为了接受响应的消息,我们需要在请求消息中发送一个回调队列。可以使用默认的队列,示例代码如下:
callbackQueueName = channel.queueDeclare().getQueue();
BasicProperties props = new BasicProperties.Builder().replyTo(callbackQueueName).build();
channel.basicPublish("", "rpc_queue",props,message.getBytes());
// then code to read a response message from the callback_queue...
AMQP协议为消息预定义了一组14个属性:
private String contentType;
private String contentEncoding;
private Map<String,Object> headers;
private Integer deliveryMode;
private Integer priority;
private String correlationId;
private String replyTo;
private String expiration;
private String messageId;
private Date timestamp;
private String type;
private String userId;
private String appId;
private String clusterId;
其中大部分的属性是很少使用的,除了以下几种:
- deliveryMode:标记消息传递模式,2-消息持久化,其他值-瞬态
- contentType:内容类型,用于描述编码的mime-type,例如经常为该属性设置JSON编码
- replyTo:应答,通用的回调队列名称
- correlationId:关联ID,方便RPC响应(response)与请求(request)关联。
在上述方法中,为每个RPC请求创建一个回调队列。这是很低效的。幸运的是,一个解决方案:可以为每个客户端创建一个单一的回调队列。
新的问题被提出,队列收到一条回复消息,但是不清楚是那条请求的回复。这是就需要使用correlationId属性了。我们要为每个请求设置唯一的值。然后,在回调队列中获取消息,查看这个属性,关联response和request就是基于这个属性值的。如果我们看到一个未知的correlationId属性值的消息,可以放心的无视它——它不是我们发送的请求。
为什么要忽略回调队列中未知的信息,而不是当作一个失败?这是由于在服务器端竞争条件的导致的。虽然不太可能,但是如果RPC服务器在发送给回调队列,并且在确认接收到请求后的消息(rpc_queue中的消息)后就挂掉了,这有可能会发送未知correlationId属性值的消息。如果发生了这种情况,重启RPC服务器将会重新处理该请求,即RPC服务会重新消费rpc_queue队列中的消息,这样就不会出现RPC服务端未处理请求的情况。这里的回调队列可能会收到重复消息的情况。因此这要求客户端必须很好的处理重复响应,并且RPC请求本身应该是幂等的。
注意,消费者消费消息一般是先处理业务逻辑,再使用Basic.Ack确认已接收到消息以防止消息不必要地丢失。如下图所示:

RPC的处理流程如下:
- 首先,服务端先创建好队列rpc_queue,等待客户端发送请求Request
- 当客户端启动时,创建一个匿名的回调队列(名称由RabbitMQ自动创建,图中的回调队列为amq.gen-Xa2…OXA)。
- 客户端为RPC请求设置2个属性:replyTo用来告知RPC服务端回复请求时的目的队列,即回调队列;correlationId用来标记一个请求。
- 请求被发送到了rpc_queue队列中
- RPC服务端监听rpc_queue队列中的请求,当请求到来时,服务端会处理并把带有结果的消息发送给客户端,接收的队列就是replyTo设定的回调队列。
- 客户端监听回调队列,当有消息时,检查correlationId属性,如果与请求匹配,那就是结果了,如果不匹配,则将响应消息直接丢掉。
代码演示:客户端远程调用RPC服务端的斐波那契数列函数,并等待返回结果。
服务端代码:
package RPC;
import com.rabbitmq.client.*;
import utils.RabbitMQUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeoutException;
public class RPCServer {
private static final String RPC_QUEUE_NAME = "rpc_queue";
public static void main(String[] args) throws IOException {
Connection connection = RabbitMQUtils.getConnection();
try (Channel channel = connection.createChannel()) {
// 声明队列,队列名,不进行持久化,不互斥,不自动删除,无其他参数
channel.queueDeclare(RPC_QUEUE_NAME, false, false, false, null);
// 清除给定队列的内容
channel.queuePurge(RPC_QUEUE_NAME);
// 公平分配
channel.basicQos(1);
System.out.println(" [x] Awaiting RPC requests");
Object monitor = new Object();
// 返回给客户端参数
DeliverCallback deliverCallback = (consumerTag, delivery) -> {
AMQP.BasicProperties replyProps = new AMQP.BasicProperties
.Builder()
.correlationId(delivery.getProperties().getCorrelationId())
.build();
String response = "";
try {
String message = new String(delivery.getBody(), StandardCharsets.UTF_8);
int n = Integer.parseInt(message);
System.out.println(" [.] fib (" + message + ")");
response += fib(n);
} catch (RuntimeException e) {
System.out.println(" [.] " + e.toString());
} finally {
channel.basicPublish("", delivery.getProperties().getReplyTo(), replyProps, response.getBytes(StandardCharsets.UTF_8));
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
synchronized (monitor) {
monitor.notify();
}
}
};
channel.basicConsume(RPC_QUEUE_NAME, false, deliverCallback, (consumerTag -> {
}));
// 等到所有的请求处理完成后,再把数据返回给客户端
while (true) {
synchronized (monitor) {
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
} catch (TimeoutException e) {
e.printStackTrace();
}
}
private static int fib(int n) {
if (n == 0 || n == 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
}
客户端代码:
package RPC;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.BlockedCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import utils.RabbitMQUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class RPCClient {
private static final String requestQueueName = "rpc_queue";
/**
* 1. 客户端发起RPC请求,request请求中会发送两个参数replyTo和correlationId
* replyTo:同步互斥队列,也就是该请求对应的队列
* correlationId:唯一标识
* 2. 请求存入rpc队列,采用的是有界数组阻塞队列(ArrayBlockingQueue)
* 3. 消息接收端(也就是服务端)接收到请求之后,利用replyTo中的携带的数据,处理任务并返回结果,返回结果中携带correlationId和具体结果
*
* @param args
* @throws IOException
*/
public static void main(String[] args) {
// 1. 创建连接和通道
Connection connection = RabbitMQUtils.getConnection();
Channel channel = null;
try {
channel = connection.createChannel();
// 2. 发起请求
for (int i = 0; i < 32; i++) {
String i_str = Integer.toString(i);
System.out.println(" [x] Resquesting fir(" + i_str + ")");
String response = call(i_str, channel);
System.out.println(" [.] Got '" + response + "'");
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
RabbitMQUtils.closeConnectionAndChannel(channel, connection);
}
}
private static String call(String message, Channel channel) throws IOException, InterruptedException {
// 使用UUID作为唯一键
final String corrId = UUID.randomUUID().toString();
String replyQueueName = channel.queueDeclare().getQueue();
// 将correlationId和replyTo作为属性参数发送给服务器
AMQP.BasicProperties props = new AMQP.BasicProperties
.Builder()
.correlationId(corrId)
.replyTo(replyQueueName)
.build();
// 采用的是工作模式
channel.basicPublish("", requestQueueName, props, message.getBytes(StandardCharsets.UTF_8));
// 有界阻塞队列大小为1
final BlockingQueue<String> response = new ArrayBlockingQueue<>(1);
String cTag = channel.basicConsume(replyQueueName, true, (consumerTag, delivery) -> {
if (delivery.getProperties().getCorrelationId().equals(corrId)) {
// 入队
response.offer(new String(delivery.getBody(), StandardCharsets.UTF_8));
}
}, consumerTag -> {
});
String result = response.take();
channel.basicCancel(cTag);
return result;
}
}
持久化
RabbitMQ的持久化分为三个部分:
- 交换器的持久化:通过在声明交换器时将durable参数设置为true实现,如未设置交换器元数据会丢失,消息不会丢失,但是不能将消息发送到这个交换器中了,一般建议设置为持久化
- 队列的持久化:通过在声明队列时将durable参数设置为true实现,如果队列不设置持久化,那么RabbitMQ服务器重启之后,相关队列的元数据会丢失,数据也会丢失。队列的持久化可以保证本身的元数据不会因为异常丢失,但并不能保证内部的消息不丢失,除非将消息的投递模式中的deliveryMode属性设置为2
- 消息的持久化:可以通过将消息的投递模式中的deliveryMode属性设置为2进行持久化
一般来说队列的持久化和消息的持久化需要配合使用,否则是没有意义的。但是将所有的消息全部设置为持久化会严重影响RabbitMQ的性能,对于可靠性要求不高的消息可以在可靠性和吞吐量之间做权衡。
将交换器、队列和消息设置为持久化之后并不能保证百分百数据不丢失。如果消费者收到消息后未处理就宕机,同样会造成数据的丢失,所以一般情况下将autoAck参数设置为false,并进行手动确认。另外,在持久化的消息正确存入RabbitMQ之后,还需要一段时间才能存入磁盘。RabbitMQ并不是为每条数据进行同步存盘,而是先存在系统缓存中,再调用内核的fsync方法进行批量的存储,如果在这期间发生宕机同样会造成消息的丢失。这时,RabbitMQ可以通过镜像队列机制为其配置副本,如果主节点(Master)挂掉后从节点可以自动切换顶上,虽然仍不能保证数据百分比不丢失,但已经相对靠谱的多。实际生产环境上,关键业务队列一般都会设置镜像队列。
还可以在发送端引入事务机制或者发送方确认机制来保证消息已经正确地发送并存储至RabbitMQ中,前提是还要保证在调用channel.basicPublish方法的时候交换器能够将消息正确路由到响应的队列之中。
生产者确认
当消息的生产者将消息发送出去后,再不进行特殊配置的情况下,是无法知道消息是否到达服务器的,如果发生丢失,即使设置了持久化也无法保证数据的到达,针对这种情况,RabbitMQ提供了两种解决方式:①事务机制;②发送方确认(publisher confirm)机制
事务机制
RabbitMQ中与事务机制相关的方法有三个:channel.txSelect、channel.txCommit和channel.txRollback。channel.txSelect用于将当前信道设置成事务模式,channel.txCommit用于提交事务,channel.txRollback用于事务回滚。
- 通过channel.txSelect方法开启事务之后,发布消息给RabbitMQ;
- 如果事务提交成功,则消息一定到达了RabbitMQ中,如果在事务提交执行之前由于RabbitMQ异常崩溃或者其他原因抛出异常了,这个时候可以将其捕获
- 通过执行channel.txRollback方法来实现事务回滚。
关键代码如下:
channel.txSelect();
channel.basicPublish(EXCHANGE_NAME, ROUTING_KEY, MessageProperties.PERSISTENT_TEXT_PLAIN, "transaction message".getBytes());
channel.txCommit();

- 客户端发送Tx.Select. 将信道置为事务模式;
- Broker 回复Tx. Select-Ok. 确认己将信道置为事务模式:
- 在发送完消息之后,客户端发送Tx.Commit 提交事务;
- Broker 回复Tx. Commi t-Ok. 确认事务提交
事务回滚:当发生异常未能执行channel.txCommit();执行channel.txRollback(),即可回滚

代码如下:
try {
channel.txSelect();
channel.basicPublish(EXCHANGE_NAME,ROUTING_KEY,false, properties, message.getBytes());
int i = 1/0;
channel.txCommit();
} catch (Exception e) {
e.printStackTrace();
channel.txRollback();
}
- 发送多条消息时,只要将channel.txSelect()、channel.txCommit()等方法包裹在循环体内即可
- 只有消息成功被RabbitMQ 接收,事务才能提交成功,否则便可在捕获异常之后进行事务回滚,与此同时可以进行消息重发。
- 但是使用事务机制会"吸干" RabbitMQ的性能,所以RabbitMQ提供了一个改进方案,即发送方确认机制。
发送方确认机制
生成者不知道消息是否真正到达broker,随后通过AMQP协议层面为我们提供了事务机制解决了这个问题,但是采用事务机制实现会降低RabbitMQ的消息吞吐量,因此这里引入了一种轻量级的方式,即发送方确认机制(publisher confirm)。
发送端确认模式的实现原理
生产者将信道设置成confirm(确认)模式,一旦信道进入confirm模式,所有在该信道上发布的消息都会被指派一个唯一的ID(从1开始),一旦消息被投递到所匹配的队列之后,RabbitMQ就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一ID) ,这就使得生产者知晓消息已经正确地到达目的地了。
如果消息和队列是持久化的,那么确认会在消息写入磁盘之后发出。RabbitMQ回传给生产者的确认消息中的deliveryTag包含了确认消息的序号,此外RabbitMQ也可以设置channel.basicAck方法的multiple参数,表示到这个序号之前的所有消息都已经得到了处理。
事务机制在一条消息发送之后会使得发送端阻塞,以等待RabbitMQ的回应,之后才能继续发送下一条消息,相比之下,发送方确认机制最大的好处在于它是异步的,一旦发布一条消息生产者程序可以在等待信道返回确认的同时继续发布下一条消息,当消息得到最终确认之后,生产者程序可以通过回调方法来处理该确认消息,如果RabbitMQ因自身原因导致消息丢失,就会发送一条nack命令,生产者应用程序同样可以在回调方法中处理该nack消息。
开启确认模式的方法
生产者通过调用channel.confirmSelect()将信道设置confirm模式,如果没有设置no-wait标志的话,RabbitMQ会返回Confirm.Select-OK命令表示同意发送者将当前的信道设置为confirm模式,所有被发送的后续消息都会被ack或者nack一次,不会出现一条消息即被ack又被nack的情况,并且RabbitMQ也没有对消息被confirm的快慢做任何保证。
下面是publisher confirm机制的示例代码:
try {
channel.confirmSelect();// 将信道设置为publisher.confirm模式
channel.basicPublish("exchange", "routingKey", null, "publisher comfirm test".getBytes());
if (!channel.waitForConfirms()) {
System.out.println("send message failed");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
如果发送多条消息,只需要将channel.basicPublish和channel.waitForConfirms方法包裹在循环体里边即可。
对于channel.waitForConfirms,在RabbitMQ客户端中它有4中同类的方法:
public boolean waitForConfirms() throws InterruptedException
public boolean waitForConfirms(long timeout) throws InterruptedException, TimeoutException
public void waitForConfirmsOrDie() throws IOException, InterruptedException
public void waitForConfirmsOrDie(long timeout) throws IOException, InterruptedException, TimeoutException
如果没有开启publisher.confirm模式,则调用任何waitForConfirms方法都会报出ava.lang.IllegalStateException。对于没有参数的waitForConfirms方法来说,其返回的条件是客户端收到了响应的Basic.Ack/.Nack或者被中断。参数timeout表示超时时间,一旦等待RabbitMQ回应超时就会抛出java.util.concurrent.TimeoutException的异常。两个waitForConfirmsOrDie方法在接收到RabbitMQ返回的Basic.Nack之后会抛出java.io.IOException。业务代码可以根据实际情况灵活运行这四种方法来保障消息的可靠发送。
注意:
- **事务机制和publisher.confirm机制是互斥的,不能共存。**如果企图将已开启事务模式的信道再设置为publisher confirm 模式,RabbitMQ 会报错: {amqp_ error, precondition_failed,“cannot switch from tx to confirm mode”, ‘confirm.select’}; 或者如果企图将已开启publisher confirm 模式的信道再设置为事务模式,RabbitMQ 也会报错:{amqp_ error, precondition_ failed, “cannot switch from confirm to txmode”,’ tx.select ’ } 。
- 事务机制和publisher.confirm机制确保的是消息能够正确地发送至RabbitMQ,具体含义是指消息被正确地发送至RabbitMQ的交换器,如果此交换器没有匹配的队列,那么消息会丢失,所以在使用这两种机制的时候要确保所涉及的交换器能够有匹配的队列。更进一步地讲,发送方要配合mandatory参数或者备份交换器一起使用来提高消息传输的可靠性。
publisher confirm的优势在于并不一定需要同步确认。这里我们改进了一下使用方式,总结有如下两种:
- 批量confirm方法:每发送一批消息后,调用channel . waitForConfirms方法,等待服务器的确认返回。
- 异步confirm方法:提供一个回调方法,服务端确认了一条或者多条消息后客户端会回调这个方法进行处理。
在批量confirm方法中,客户端程序需要定期或者定量(达到多少条),亦或者两者结合起来调用channel.waitForConfirms来等待RabbitMQ的确认返回。相比于前面示例中的普通confirm方法,批量极大地提升了confirm 的效率,但是问题在于出现返回Basic.Nack或者超时情况时,客户端需要将这一批次的消息全部重发,这会带来明显的重复消息数量,并且当消息经常丢失时,批量confirm的性能应该是不升反降的。
三种实现方式
对于固定消息体大小和线程数,如果消息持久化,生产者confirm(或者采用事务机制),消费者ack那么对性能有很大的影响.
消息持久化的优化没有太好方法,用更好的物理存储(SAS, SSD, RAID卡)总会带来改善。生产者confirm这一环节的优化则主要在于客户端程序的优化之上。归纳起来,客户端实现生产者confirm有三种编程方式:
- 普通confirm模式:每发送一条消息后,调用waitForConfirms()方法,等待服务器端confirm。实际上是一种串行confirm了。
- 批量confirm模式:每发送一批消息后,调用waitForConfirms()方法,等待服务器端confirm。
- 异步confirm模式:提供一个回调方法,服务端confirm了一条或者多条消息后Client端会回调这个方法。
从编程实现的复杂度上来看:
第一种:普通confirm模式最简单,publish一条消息后,等待服务器端confirm,如果服务端返回false或者超时时间内未返回,客户端进行消息重传。
关键代码如下:
try {
channel.confirmSelect();// 将信道设置为publisher.confirm模式
channel.basicPublish("exchange", "routingKey", null, "publisher comfirm test".getBytes());
if (!channel.waitForConfirms()) {
System.out.println("send message failed");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
第二种:批量confirm模式稍微复杂一些,客户端程序需要定期(每隔多少秒)或者定量(达到多少条)或者两则结合起来publish消息,然后等待服务器端confirm, 相比普通confirm模式,批量极大提升confirm效率,但是问题在于一旦出现confirm返回false或者超时的情况时,客户端需要将这一批次的消息全部重发,这会带来明显的重复消息数量,并且,当消息经常丢失时,批量confirm性能应该是不升反降的。
关键代码如下:
// 批量confirm模式
int BATCH_COUNT = 10;
try {
channel.confirmSelect();
int MesCount = 0;
while (true) {
channel.basicPublish("EXCHANGE_NAME", "ROUTINGKEY", null, "batch confirm test".getBytes());
// 将发送出去的消息存储缓存中,缓存可以是一个ArrayList或者BlockingQueue之类的
if (++MesCount >= BATCH_COUNT) {
MesCount = 0;
try {
if (channel.waitForConfirms()) {
//将缓存中的消息清空
}
//将缓存中的消息重新发送
} catch (InterruptedException e) {
e.printStackTrace();
//将缓存中的消息重新发送
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
第三种:异步confirm模式的编程实现最复杂。在客户端Channel接口中提供的addConfirmListener 方法可以添加ConfirmListener这个回调接口,这个ConfirmListener接口包含两个方法: handleAck 和handleNack, 分别用来处理RabbitMQ回传的Basic.Ack 和Basic.Nack。 在这两个方法中都包含有一个参数deliveryTag (在publisher confirm 模式下用来标记消息的唯一有序序号)。 我们需要为每一个信道维护一个“unconfirm”的消息序号集合,每发送一条消息, 集合中的元素加1。
**每当调用ConfirmListener 中的handleAck方法时,“unconfirm”集合中删掉相应的一条(multiple设置为false)或者多条(multiple 设置为true)记录。**从程序运行效率.上来看,这个“unconfirm”集合最好采用有序集合SortedSet的存储结构。事实上,Java 客户端SDK中的waitForConfirms方法也是通过SortedSet维护消息序号的。下列代码为我们演示了异步confirm的编码实现,其中的confirmSet就是一个SortedSet类型的集合。
SortedSet<Long> confimSet = new TreeSet<>();
//异步confirm
channel.confirmSelect();
channel.addConfirmListener(new ConfirmListener() {
@Override
public void handleAck(long deliverTay, boolean multiple) throws IOException {
if (multiple) {
confimSet.headSet(deliverTay - 1).clear();
} else {
confimSet.remove(deliverTay);
}
}
@Override
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
System.out.println("Nack,SeqNo:" + deliveryTag + ",multiply: " + multiple);
if (multiple) {
confimSet.headSet(deliveryTag - 1).clear();
} else {
confimSet.remove(deliveryTag);
}
//注意这里需要添加处理消息重发的场景
}
});
//下面演示一直发送消息的场景
while (true) {
long nextSeqNo = channel.getNextPublishSeqNo();
channel.basicPublish(EXCHANGE_NAME, ROUTING_KEY, MessageProperties.PERSISTENT_TEXT_PLAIN, "sending".getBytes());
confimSet.add(nextSeqNo);
}
消费者要点介绍
消费端可以通过拉模式或者推模式的方法获取并消费消息,消费者处理完消息后需要手动确认消息已被接受,RabbitMQ才能把消息从队列中标记清除,如果因为某些原因无法处理接受到的信息,可以使用channel.basicNack或者channel.basicReject来拒绝掉。对于消费端来说有几点需要注意:①消息分发;②消息顺序性;③弃用QueueingConsumer
消息分发
当RabbitMQ队列拥有多个消费者时,队列收到的消息会以轮询的方式分发给消费者,每条消息只会发送给订阅列表里的一个消费者。如果负载家中,则只需要创建更多的消费者来消费处理掉消息即可。
但是,如果不同消费者处理消息的能力差异较大,就会造成部分消费者一直处于忙碌状态,另一部分消费者处于空闲状态,这个时候我们可以使用channel.basicQos方法来限制信道上消费者所能保持的最大未确认消息的数量。如果达到了所设定的上限,则RabbitMQ就不会向这个消费者再发送任何消息,直到消费者确认了某条消息之后,RabbitMQ将相应的计数减1之后消费者才能重新接收消息。需要注意的是Basic.Qos对拉模式的消费方式是无效的。
channel.basicQos有三种类型的重载方法:
public void basicQos(int prefetchCount) throws IOException
public void basicQos(int prefetchCount, boolean global) throws IOException
public void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException
- prefetchCount表示允许限制信道上的消费者所能保持的最大未确认消息的数量,设置为0时表示没有上限。
- prefetchSize表示消费者所能接收未确认消息的总体大小的上限,单位为B,设置为0表示没有上限。
对于一个信道来说,它可以同时消费多个队列,当设置了prefetchCount大于0时,这个信道需要和各个队列协调以确保发送的消息都没有超过所限定的prefetchCount的值,这样会使RabbitMQ的性能降低。尤其是这些队列分散在集群中的多个Broker节点之中。
因此,RabbitMQ为了提升性能,在AMQP协议之上重新定义了global参数,具体含义如下:
| global参数 | AMQP 0-9-1 | RabbitMQ |
|---|---|---|
| false | 信道上所有的消费者都需要遵从prefetchCount的限定值 | 信道上新的消费者需要遵从prefetchCount的限定值 |
| true | 当前通信链路(Connection)上所有的消费者都需要遵从prefetchCount的限定值 | 信道上所有的消费者都需要遵从prefetchCount的限定值 |
对于同一个信道上的多个消费者,如果设置了prefetchCount的值,那么都会生效:
//两个消费者,各自能接收到的未确认消息的上限都为10
Consumer consumer1 = new DefaultConsumer(channel);
Consumer consumer2 = new DefaultConsumer(channel);
channel.basicQos(10);//Per consumer limit
channel.basicConsume("my-queue1", false, consumer1);
channel.basicConsume("my-queue1", false, consumer2);
**如果在订阅消息之前,既设置了global为true的限制,又设置了global 为false的限制,那么哪个会生效呢? RabbitMQ会确保两者都会生效。**举例说明,当前有两个队列queue1和queue2:queue1有10条消息,分别为1到10;queue2也有10条消息,分别为11到20。有两个消费者分别消费这两个队列:
//两个消费者,各自能接收到的未确认消息的上限都为10
Consumer consumer1 = new DefaultConsumer(channel);
Consumer consumer2 = new DefaultConsumer(channel);
channel.basicQos(3, false);//Per consumer limit
channel.basicQos(5, true);//Per channel limit
channel.basicConsume("my-queue1", false, consumer1);
channel.basicConsume("my-queue2", false, consumer2);
那么这里每个消费者最多只能收到3个未确认的消息,两个消费者能收到的未确认的消息个数之和的上限为5。在未确认消息的情况下,如果consumer1接收到了消息1、2和3,那么consumer2至多只能收到11和12。如果像这样同时使用两种global的模式,则会增加RabbitMQ的负载,因为RabbitMQ需要更多的资源来协调完成这些限制。如无特殊需要,最好只使用global为false的设置,这也是默认的设置。
消息顺序性
消息的顺序性是指消费者消费到的消息和发送者发布的消息的顺序是一致的。举个例子,不考虑消息重复的情况,如果生产者发布的消息分别为msg1、msg2、msg3,那么消费者必然也是按照msg1、msg2、msg3 的顺序进行消费的。
目前很多资料显示RabbitMQ的消息能够保障顺序性,这是不正确的,或者说这个观点有很大的局限性。在不使用任何RabbitMQ的高级特性,也没有消息丢失、网络故障之类异常的情况发生,并且只有一个消费者的情况下,最好也只有一个生产者的情况下可以保证消息的顺序性。如果有多个生产者同时发送消息,无法确定消息到达Broker的前后顺序,也就无法验证消息的顺序性。
那么哪些情况下RabbitMQ的消息顺序性会被打破呢?下面介绍几种常见的情形。
- 如果生产者使用了事务机制,在发送消息之后遇到异常进行了事务回滚,那么需要重新补偿发送这条消息,如果补偿发送是在另一个线程实现的,那么消息在生产者这个源头就出现了错序。同样,如果启用publisher confirm时,在发生超时、中断,又或者是收到RabbitMQ的Basic.Nack命令时,那么同样需要补偿发送,结果与事务机制一样会错序。或者这种说法有些牵强,我们可以固执地认为消息的顺序性保障是从存入队列之后开始的,而不是在发送的时候开始。
- 考虑另一种情形,如果生产者发送的消息设置了不同的超时时间,并且也设置了死信队列,整体上来说相当于一个延迟队列,那么消费者在消费这个延迟队列的时候,消息的顺序必然不会和生产者发送消息的顺序一致。
- 再考虑一种情形,如果消息设置了优先级,那么消费者消费到的消息也必然不是顺序性的。如果一个队列按照前后顺序分有msg1、msg2、msg3、msg4这4个消息,同时有ConsumerA和ConsumerB这两个消费者同时订阅了这个队列。队列中的消息轮询分发到各个消费者之中,ConsumerA中的消息为msg1和msg3,ConsumerB 中的消息为msg2、msg4。ConsumerA 收到消息msg1之后并不想处理而调用了Basic.Nack/.Reject将消息拒绝,与此同时将requeue设置为true, 这样这条消息就可以重新存入队列中。消息msg1之后被发送到了ConsumerB中,此时ConsumerB已经消费了msg2、 msg4,之,后再消费msgl,这样消息顺序性也就错乱了。或者消息msg1又重新发往ConsumerA中,此时ConsumerA已经消费了msg3,那么再消费msg1,消息顺序性也无法得到保障。同样可以用在Basic. Recover这个AMQP命令中。
包括但不仅限于以上几种情形会使RabbitMQ 消息错序。如果要保证消息的顺序性,需要业务方使用RabbitMQ之后做进一步的处理,比如在消息体内添加全局有序标识(类似SequenceID)来实现。
弃用QueueingConsumer
首先看QueueingConsumer的用法:
//弃用的QueueConsumer
QueueingConsumer consumer = new QueueingConsumer(channel);
channel.basicQos(64);//使用QueueingConsumer的时候一定要添加
channel.basicConsume(QUEUE_NAME, false, "consumer", consumer);
while (true) {
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(delivery.getBody());
System.out.println("[X] Rceived '" + message + "'");
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}
QueueingConsumer本身有几个大缺陷,需要读者在使用时特别注意。首当其冲的就是内存溢出的问题,如果由于某些原因,队列之中堆积了比较多的消息,就可能导致消费者客户端内存溢出假死,于是发生恶性循环,队列消息不断堆积而得不到消化。
内存溢出的问题可以使用Basic.Qos来得到有效的解决,Basic.Qos可以限制某个消费者所保持未确认消息的数量,也就是间接地限制了QueueingConsumer中的LinkedBlockingQueue的大小。注意一定要在调用Basic.Consume之前调用Basic.Qos才能生效。
QueueingConsumer还包含(但不仅限于)以下一些缺陷:
- QueueingConsumer会拖累同一个Connection下的所有信道,使其性能降低;
- 同步递归调用QueueingConsumer会产生死锁;
- RabbitMQ的自动连接恢复机制(automatic connection recovery) 不支持QueueingConsumer的这种形式;
- QueueingConsumer不是事件驱动的。
为了避免不必要的麻烦,建议在消费的时候尽量使用继承DefaultConsumer的方式。
消息传输保障
消息可靠传输一般是业务系统接入消息中间件时首要考虑的问题,一般消息中间件的消息传输保障分为三个层级:
- At most once:最多一次。 消息可能会丢失,但绝不会重复传输。
- At least once:最少一次。消息绝不会丢失,但可能会重复传输。
- Exactly once:恰好一次。每条消息肯定会被传输一次且仅传输一次。
RabbitMQ支持其中的“最多一次”和“最少一次”。其中**“最少一次”投递实现需要考虑以下这个几个方面**的内容:
- 消息生产者需要开启
事务机制或者publisher confirm机制,以确保消息可以可靠地传输到RabbitMQ中。 - 消息生产者需要配合使用
mandatory参数或者备份交换器来确保消息能够从交换器路由到队列中,进而能够保存下来而不会被丢弃。 - 消息和队列都需要进行
持久化处理,以确保RabbitMQ服务器在遇到异常情况时不会造成消息丢失。 - 消费者在消费消息的同时需要将
autoAck设置为false, 然后通过手动确认的方式去确认已经正确消费的消息,以避免在消费端引起不必要的消息丢失。
“最多一次”的方式就无须考虑以上那些方面,生产者随意发送,消费者随意消费,不过这样很难确保消息不会丢失。
“恰好一次”是RabbitMQ目前无法保障的。考虑这样一种情况,消费者在消费完一条消息之后向RabbitMQ发送确认Basic.Ack命令,此时由于网络断开或者其他原因造成RabbitMQ并没有收到这个确认命令,那么RabbitMQ 不会将此条消息标记删除。在重新建立连接之后,消费者还是会消费到这一条消息,这就造成了重复消费。再考虑一种情况,生产者在使用publisher confirm机制的时候,发送完一条消息等待RabbitMQ返回确认通知,此时网络断开,生产者捕获到异常情况,为了确保消息可靠性选择重新发送,这样RabbitMQ中就有两条同样的消息,在消费的时候,消费者就会重复消费。
那么RabbitMQ有没有去重的机制来保证“恰好一次”呢?答案是并没有,不仅是RabbitMQ, .目前大多数主流的消息中间件都没有消息去重机制,也不保障“恰好一次”。去重处理一般是在业务客户端实现,比如引入GUID (Globally Unique Identifier)的概念。针对GUID,如果从客户端的角度去重,那么需要引入集中式缓存,必然会增加依赖复杂度,另外缓存的大小也难以界定。建议在实际生产环境中,业务方根据自身的业务特性进行去重,比如业务消息本身具备幂等性,或者借助Redis等其他产品进行去重处理。
总结
提升数据可靠性有以下一些途径:
- 设置mandatory参数或者备份交换器( immediate参数已被淘汰);
- 设置publisher confirm机制或者事务机制;
- 设置交换器、队列和消息都为持久化;
- 设置消费端对应的autoAck参数为false并在消费完消息之后再进行消息确认。
参考资料:
- 《RabbitMQ实战指南》 朱忠华 著
- https://blog.youkuaiyun.com/u013256816/article/details/55218595
- https://www.cnblogs.com/Jscroop/p/14274440.html#_label0_2
- https://blog.youkuaiyun.com/u013385673/article/details/108858157
本文详细介绍了RabbitMQ的RPC实现,通过设置回调队列和correlationId实现请求与响应的对应。同时,讨论了消息的持久化策略,包括交换器、队列和消息的持久化,以及生产者确认机制,包括事务和发送方确认。此外,提到了消费者端的确认机制和消息分发,强调了在保证消息顺序性和传输保障方面的注意事项。最后,文章总结了提升消息可靠性的一系列方法。
1万+

被折叠的 条评论
为什么被折叠?



