RabbitMQ java 客户端使用com.rabbitmq.client作为顶级包名,关键是Class和Interface有Channel,Connection,ConnectionFactory,Consumer等,AMQP协义层面的操作通过Channel接口实现,Connection是用来开启Channel信道的,可以注册事件处理器,也可以在应用结束时关闭连接,RabbitMQ相关的开发工作,基本是是围绕Connection和Channel这两个类展开的下面主要是连接,交换器,队列的创建与绑定,发送消息,消费消息,消费消息确认和关闭连接。
ConnectionFactory connectionFactory = new ConnectionFactory();
connectionFactory.setHost(RabbitConstant.HOST);//设置注解
connectionFactory.setPort(RabbitConstant.PORT);//设置端口
connectionFactory.setVirtualHost(RabbitConstant.VHOST);//设置虚拟及
connectionFactory.setUsername(RabbitConstant.USERNAME);
connectionFactory.setPassword(RabbitConstant.PASSWORD);
Connection connection = connectionFactory.newConnection();
也可以使用URI的方式来实现
public class Producer { public static void main(String[] args) throws IOException, Exception { //1 创建连接工厂 ConnectionFactory connectionFactory = new ConnectionFactory(); //2 设置参数, uri为amqp://userName:password@ipAddress:portNumber/virtualHost //【注意】当virtualHost为 / 时 // amqp://guest:guest@localhost:5672/ 这样写会导致: // Caused by: com.rabbitmq.client.ShutdownSignalException: connection error; protocol method: #method(reply-code=530, // reply-text=NOT_ALLOWED - access to vhost '' refused for user 'guest', class-id=10, method-id=40) // 或 // amqp://guest:guest@localhost:5672// 这 // Exception in thread "main" java.lang.IllegalArgumentException: Multiple segments in path of AMQP URI: // // 写者会抛出异常 connectionFactory.setUri("amqp://guest:guest@localhost:5672"); //3 创建连接 Connection connection = connectionFactory.newConnection(); //4 创建channel Channel channel = connection.createChannel(); channel.exchangeDeclare("producer-customer-test", BuiltinExchangeType.DIRECT, true); //5 创建队列Queen /**参数介绍: * queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete, Maparguments) * queue:队列名称 durable:是否持久化 exclusive是否独占,只能有一个消费者监听队列。 * queue:队列名称 durable:是否持久化 exclusive是否独占,只能有一个消费者监听队列。 * autoDelete: 当没有consumer时候是否删除队列 arguments:配置的基本参数 */ channel.queueDeclare("hello_world", true, false, false, null); channel.queueBind("hello_world", "producer-customer-test", "routing_key_1"); String constant = "大家好这是在测试"; for (int i = 0; i < 3; i++) { String body = constant + i; channel.basicPublish("producer-customer-test", "routing_key_1", null, body.getBytes()); } //7 释放连接资源 channel.close(); connection.close(); } }
注意要点:
Connection可以用来创建多个Channel实例,但是Channel实例不能在线程间共享,应用程序应该为每一个线程开辟一个Channel,某些情况下Channel的操作可以并发运行,但是在其他情况下会导致在网络上出现错误的通信帧交错,同时也会影响发送方确认(publisher confirm)机制运行,所以多线程间共享Channel实例是非线程安全的。
Channel或者Connection中有个isOpen方法可以用来检测其是否处理开启状态,但是并不推荐在生产环境代码上使用isOpen方法,这个方法的返回值依赖于shutdownCause的存在,有可能产生竞争。
isOpen方法源码
public boolean isOpen(){
synchronized(this.monitor){
return this.shutdownCause == null;
}
}
错误的使用isOpen方法
if(channel.isOpen()){
…
channel.basicQos(1);
}
上述代码可能会存在线程问题,也就是在调用basicQos方法之前,isOpen的状态改变了。
通常情况下,在调用createXXX或者newXXX方法之后,我们可以简单的认为Connection或者Channel己经成功的处理开启状态,而并不会在代码中使用isOpen这个检测方法,如果在使用Channel的时候己经处理关闭状态,那么程序会抛出一个com.rabbitmq.client.ShutdownSingalException,我们只需要捕获这个异常即可,当然同时也要试着捕获IOException或者SocketException,以防止Connection意外关闭掉。
try{
…
channel.basicQos(1);
}catch(ShutdownSignalException sse){
可能会导致线程关闭,在这个时候,我们根据自己的业务需求做处理
}catch(IOException ioe){
检查连接是否关闭
}
交换器和队列是AMQP中较高层次的构建模块,应用程序需要确保在使用它们的时候就己经存在了,在使用之前需要先声明(declare)它们。
channel.exchangeDeclare(exchangeName,“direct”,true);
String queueName = channel.queueDeclare().getQueue();
channel.queueBind(queueName,exchangeName,routingKey);
上面创建了一个持久化,非自动删除的,绑定类型为direct的交换器,同时也创建了一个非持久化的,排他的,自动删除的队列(此队列由RabbitMQ自动生成),这里交换器和队列也是没有设置特殊的参数。
上面的代码也展示了如何使用路由键将队列和交换器绑定起来,上面声明了队列具备如下特性,只对当应用中同一个Connection层面可用,同一个Connection的不同Channel可共用,并且也会在应用连接断开时自动删除。
如果在应用中共享一个队列,可以做如下声明。
channel.exchangeDeclare(exchangeName,“direct”,true);
channel.queueDeclare(queueName,true,false,false,null);
channel.queueBind(queueName,exchangeName,routingKey);
这里的队列被声明为持久化的,非排他的,非自动删除的,而且也被分配另一个确定的已知的名称(由客户端分配而非RabbitMQ自动生成)
注意:Channel的API方法都是可以重载的,比如:exchangeDeclare,queueDeclare。根据参数的不同,可以有不同的重载形式,根据自身的需要进行调用。
生产者和消费者都可以声明一个交换器或者队列,如果尝试声明一个己经存在的交换器或者队列,只需要声明参数完全匹配现在的交换器或者队列,RabbitMQ就可以什么都不做,并成功返回,如果声明的参数不匹配则会抛出异常。
exchangeDelare有多个重载方法,这些重载方法都由下面的这个方法中缺省的某些参数构成的。
Exchange.DeclareOk exchangeDeclare(String exchange,
String type,
boolean durable,
boolean autoDelete,boolean internal,
Map
这个方法的返回值是Exchange.DeclareOK,用来标识成功声明一个交换器。
各个参数详细说明如下所述:
这个方法返回值是Exchange.DeclareOK,用来标识成功声明一个交换器。
各个参数详细说明如下:
exchangeDeclare的其他重载方法:
与此对应的,将第二个参数String type换成BuiltInExchangeType type 的对应的重载方法。
与exchangeDeclare师出同门还是几个方法,比如exchangeDeclareNoWait方法,具体定义如下
这个exchangeDeclareNoWait比exchangeDeclare多设置了一个nowait参数,这个nowait参数是指AMQP中的Exchange.Declare命令的参数,意思是不需要服务器返回,注意这个方法返回的是void,而普通的exchangeDeclare方法返回什么呢?EXchange.DeclareOK,意思是在客户端声明一个交换器之后,需要等待服务器的返回(服务器返回Exchange.Declare-OK这个AMQP)
针对 "exchangeDeclareNoWait"不需要服务器任何返回值这一点,考虑这样的情况,在声明完一个交换器之后(实际服务器还并未完成交换器的创建),那么此时客户端紧接着使用这个交换器,必然会发生异常,如果没有特殊的缘由和应用场景,并不建义使用这个方法。
这里还有另一个方法
Exchange.DeclareOk exchangeDeclarePassive(String name) throws IOException;
这个方法在实际应用过程中还是非常有用的,它主要用来检测相应的交换器是否存在。如果存在则正常返回,如果不存在,则抛出异常,404 chanel exception ,同时Channel 也会被关闭。
在声明交换器的方法,当然也有删除交换器的方法,相应的方法如下:
queueDeclare相对于exchangeDeclare方法而言,重载方法的个数少很多,它只有两个重载方法:
不带任何参数queueDeclare方法默认创建一个由RabbitMQ命名的(类似于这种amq-gen-LhQzlgv3GhDOv8PIDabOXA名称),这种队列也称为匿名队列,排他的,自动删除的,非持久化的队列,方法的参数详细说明如下:
注意要点:
生产者和消费者都能够使用queueDeclare来声明一个队列,但是如果消费者在同一个信道是订阅了另一个队列,就无法再声明队列了,必需先取消费者订阅,然后将信道设置为“传输”模式,之后才能声明队列了。
exclusive为true时,抛出Caused by: com.rabbitmq.client.ShutdownSignalException: channel error; protocol method: #method
对应的exchange
方法的返回值也是void,表示不需要服务端任何返回,同样也需要注意,在调用完queueDeclareNoWait方法之后,紧接着使用声明的队列同时可能会发生异常。
同样还有一个queueDeclarePassive的方法,也比较常用,这个方法用来检测相应的队列是否存在,如果存在,则正常返回,如果不存在,则抛出404 channel异常,同时Channel也会被关闭,方法定义如下:
Queue.DeclareOk queueDeclarePassive(String queue) throws IOException;
与交换器对应,关于队列也有删除的相应方法。
其中queue表示队列的名称,ifUnused 为true,表示如果队列正在被消费者订阅,不允许删除,ifEmpty设置为true表示在队列为空(队列里面没有任何消息堆积)的情况下才能够删除。
与队列相关的还有一个有意思的方法-queuePurge,区别在于queueDelete,这个方法来清空队列中的所有内容,而不是删除队列本身
Queue.PurgeOk queuePurge(String queue) throws IOException;
将队列和交换器绑定的方法如下:
方法中涉及的参数详解 :
不仅可以将队列和交换器绑定起来,也可以将己经绑定的队列和交换器进行解绑
exchangeBind 方法详解
我们不仅可以将交换器与队列绑定,也可以将交换器与交换器绑定,后者和前者方法如出一辙,相应的方法如下:
方法中的参数exchangeDeclare方法,绑定之后,消息从source交换器转发到destination交换器,某种程度上来说destination交换器可以看作是一个队列。
channel.exchangeDeclare(“source”,“direct”,false,true,null);
channel.exchangeDeclare(“destination”,“fanout”,false,true,null)
channel.exchangeBind(“destination”,“source”,“exKey”);
channel.queueDeclare(“queue”,“destination”,"");
channel.queueBind(“queue”,“destination”,"");
channel.basicPublish(“source”,“exKey”,null,“exToExDemo”.getBytes());
生产者发送消息到交换器source中,交换器source根据路由键找到与其匹配的另一个交换器destination,并把消息转发到destination中,进而存储在destination绑定的队列queue中。
RabbitMQ的消息是存储到队列中,交换器的使用并不是真正的耗费服务器的性能,而队列会,如果要衡量RabbitMQ当前的QPS,只需要看队列即可,在实际业务应用中,需要对所创建的队列的流量,内存占用及网卡hiet有一个清晰的认知,预估其平均值和峰值,以便在固定的硬件资源的情况下能够进行合理有效的分配。
按照RabbitMQ的官方建义,生产者和消费者都应该尝试创建(这里是指声明操作)队列,这是一个很好的建义,但是不适用于所有的情况,如果业务本身在架构设置之初己经充分的预估了队列的使用情况,完全是可以在业务程序上线前在服务器上创建好,(比如通过页面管理,RabbitMQ命令或者更好的从配置中心下发),这样业务程序也可以免去声明的过程,直接使用即可。
预先创建好资源还有一个好处就是,可以确保交换器和队列之间正确的绑定了匹配,很多时候由于人的因素,代码缺陷,发送消息到交换器并没有绑定任何队列,那么消息将会丢失,或者交换器绑定了某个队列,但是发送消息时的路由键无法与现在的队列匹配,那么消息也会丢失,当配合mandatory参数或者备份交换器,来提高程序的健壮性。
与此同时,预估好的队列的使用情况非常重要, 如果在后期运行过程中超过预定的阈值,可以根据实际情况对当前集群进行扩容或者将相应的队列迁移到其他集群,迁移过程也可以对业务程序完全透明,此种方法也更有利于开发和运维分工,便于相应的资源管理。
如果集群资源充足,而即将使用的队列所占用的资源又是在可控范围之内,为了增加业务程序的灵活性,也完全可以在业务程序中声明队列。
至于是使用预先分析创建资源的静态方式还是动态的创建方式,需要从业务的逻辑本身,公司运维体系和公司硬件资源去考虑 。
如果要发送一个消息,可以使用Channel类的basicPublish方法,比如发送一条内容“hello World”的消息
channel.basicPublish(exchangeName,routingKey,null,“hello”.getBytes());
为了更好的控制发送,可以使用mandatory这个参数,或者可以发送一些特定的属性信息。
channel.basicPublish(exchangeName,routingKey,mandatory,MessageProperties.PERSISTENT_TEXT_PLAIN,“hello”.getBytes());
上面这行代码发送了一条消息,这条消息的投递模式(delivery mode)设置为2,即消息会被持久化(即存储磁盘)在服务器中,同时这条消息的优先级(priority)设置为1,content-type为“text/plain”,可以自己设置消息的属性。
channel.basicPublish(“exchangName”,“routingKey”,new AMQP.BasicProperties().builder()
.contentType(“text/plain”)
.deliveryMode(2)
.priority(1)
.userId(“hidden”)
.build(),
“hello”.getBytes());
也可以发送一条带有heades的消息:
Map
headers.put(“localtion”, “here”);
headers.put(“time”, “today”);
channel.basicPublish(“exchangeName”,“routingKey”,
new AMQP.BasicProperties().builder()
.headers(headers).build()
,“hello”.getBytes());
还可以发送一条带有过期(expiration)的消息:
channel.basicPublish(“exchangeName”,“routingKey”,
new AMQP.BasicProperties().builder()
.expiration(“60000”).build()
,“hello”.getBytes());
以下有几个举例:
对应的具体参数解释如下所述
RabbitMQ的消费模式分两种,推(push)和拉(pull)模式,推模式采用Basic.Consume进行消费,而拉模式则是调用Basic.Get进行消费。
在推模式中,可以通过持续订阅的方式来消费消息,使用到相关类有。
import com.rabbitmq.client.Consumer;
import com.rabbitmq.client.DefaultConsumer;
接收消息一般通过实现Consumer接口或者继承DefaultConsumer类来实现,当调用Consumer相关的API方法时,不同的订阅采用不同的消费标签(consumerTag)来区分彼此,在同一个Channel中的消费者也需要通过唯一的消费者标签以作区分,关键消费者代码如下:
boolean authAck = false; channel.basicQos(64); com.rabbitmq.client.Consumer consumer = new DefaultConsumer(channel){ @Override public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException { System.out.println(""+consumerTag); System.out.println("getExchange+"+envelope.getExchange()); System.out.println("getRoutingKey+"+envelope.getRoutingKey()); System.out.println("properties"+properties); System.out.println("body"+new String(body,"utf-8")); channel.basicAck(envelope.getDeliveryTag(),false); } }; channel.basicConsume("hello_world",authAck,consumer);
【注意】:上面的代码显示的设置了autoAck为false,然后接收的消息之后进行显示ack操作(channel.basicAck),对于消费者来说,这个设置是非常必要的,可以防止消息不必要的丢失。
Channel类中的basicConsume方法有如下形式:
其对应的的参数说明如下:
对于消费者客户端来说重写handleDelivery方法是十分方便的,更复杂的消费者客户端重写更多的方法:具体如下
使用示例如下:
Consumer consumer = new DefaultConsumer(channel) { @Override public void handleConsumeOk(String consumerTag) { super.handleConsumeOk(consumerTag); System.out.println("-------handleConsumeOk------"); } @Override public void handleCancelOk(String consumerTag) { super.handleCancelOk(consumerTag); System.out.println("-------handleCancelOk------"); } @Override public void handleCancel(String consumerTag) throws IOException { super.handleCancel(consumerTag); System.out.println("------------handleCancel---------"); } @Override public void handleShutdownSignal(String consumerTag, ShutdownSignalException sig) { super.handleShutdownSignal(consumerTag, sig); System.out.println("-------------handleShutdownSignal--------------"); } @Override public void handleRecoverOk(String consumerTag) { super.handleRecoverOk(consumerTag); System.out.println("-------------handleRecoverOk--------------"); } @Override public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException { super.handleDelivery(consumerTag, envelope, properties, body); String message = new String(body,"UTF-8"); logger.info("received:" + message); } };
handleShutdownSignal方法,当Channel或者Connection关闭的时候会调用,handleConsumeOk方法会在其他方法之前调用,返回消费者标签。
重写handleCancelOk和handleCancel方法,这样消费者可以显示地或者隐式地取消订阅的时候调用,也可以通过channel.basicCancel方法来显示的取消一个消费者的订阅:
channel.basicCancel(consumerTag);
注意上面的这行代码会首先触发handleConsumerOk方法,之后触发handleDelivery方法,最后才触发handleCancelOK方法。
和生产者一样,消费者客户端同样需要考虑线程安全问题,消费者客户端的这些callback会被分配到Channel不同的线程上,这意味着消费者客户端可以安全的调用这些阻塞方法,比如channel.queueDeclare,channel.basicCancel等。
每个Channel都拥有自己独立的线程,最常用的做法是一个Channel对应一个消费者,也就是意味着消费者彼此之间没有任何关联,当然 也可以在一个Channel中维持多个消费者,但是要注意一个问题,如果Channel中的一个消费者一直在运行,那么其他消费者的callback会被“耽搁”。
这里讲一个拉模式的消费方式,通过channel.basicGet方法可以单条的获取消息,其返回值GetResponse,Channel类的handleGet方法没有其他重载方法,只有
GetResponse basicGet(String queue,boolean autoAck) throws IOException ;
其中queue代表着队列的名称,如果设置了autoAck为false,那么同样需要调用channel.basicAck来确认消息己经被成功接收。
GetResponse response = channel.basicGet("queue_name",false); System.out.println(new String(response.getBody())); channel.basicAck(response.getEnvelope().getDeliveryTag(),false);
Basic.Consume将信道(Channel)置为接收模式,直到取消队列订阅为止,在接收模式期间,RabbitMQ会不断的推送消息给消费者,当然推送消息的个数还是会受到Basic.Qos的限制的,如果想从队列中获得单条消息而不是持续订阅,建义还是使用Baisc.Get进行消费,但是不能将Basic.Get放在一个循环来代替Basic.Consume,这样做严重影响RabbitMQ的性能,如果要实现高吞吐量,消费者理应使用Baisc.Consume方法。
为了保证消息从队列可靠的达到消费者,RabbitMQ提供了消息确认机制(message acknowle gement),消费者在订阅队列时,可以指定autoAck参数,当autoAck等于false时,RabbitMQ会等待消费者显示的回复确认信号后才从内存(或者磁盘)中移动消息(实质上是先打上删除标记,之后再删除),当autoAck等于true时,RabbitMQ会自动把发送出去的消息置为确认,然后从内存(或者磁盘)中删除,而不管消费者是否真正的消费到这些消息。
采用消息确认机制后,只要设置autoAck参数为false,消费者就有足够的时间处理消息(任务),不用担心处理消息过程中消费者进程挂掉后消息丢失问题,因为RabbitMQ会一直等待持有消息直到消费者显示的调用Basic.Ack命令为止。
当autoAck参数设置为false,对于RabbitMQ服务端而言,队列中的消息分成两个部分,一部分是等待投递给消费者消息,一部分是己经投递给消费者,但是还没有收到消费者确认信号的消息,如果RabbitMQ一直没有收到消费者的确认信号,并且消费者此消息的消费者己经断开连接,则RabbitMQ会安排该消息重新进入队列,等待投递给下一个消费者,当然也可能还是原来的那个消费者。
RabbitMQ不会为未确认的消息设置过期时间,它判断此消息是否需要重新投递给消费者唯一的依据是消费该消息或者连接是否断开 ,那么设计的原因是RabbitMQ允许消费者消费一条消息的时间可以很久很久。
Rabbit的Web管理平台,上可以看到当前队列的“Ready”状态和"Unacknowledged" 状态和消息数,分别对应上文中的等待投递给消费者的消息数和己经投递给消费者但是还未收到确认的信号的消息数。
也可以通过命令的方式来查看信息:
#rabbitmqctl list_queues name messages_ready messages_unacknowledged
listing queue …
queue 1 0
queue_demo 0
在消费者接收到消息后,如果想明确拒绝当前消息而不是确认,那么应该怎样做呢?RabbitMQ 2.0.0 版本开始引入了Basic.Reject这个命令,消费者客户端可以调用与其对应的channel.basicReject方法告诉RabbitMQ拒绝这个消息。
Channel类中的basicReject方法定义如下:
void basicReject(long deliveryTag ,boolean requeue) throws IOException ;
其中deliveryTag可以看作是消息的编号 ,它是一个64位的长整形值,最大的是9223372036854775807,如果requeue参数设置为true,则RabbitMQ会重新将这个消息存入队列,以便可以发送下一个订阅的消息,如果requeue参数设置为false,则RabbitMQ立即会把消息从队列中移除,而不会把它发送给新的消费者。
Baisc.Reject命令一次只能拒绝一条消息,如果想要批量拒绝消息,则可以使用Basic.Nack这个命令,消费者客户端可以调用channel.basicNack方法来实现,方法定义如下:
void basicNack(long deliveryTag ,boolean multiple,boolean requeue) throw IOException ;
其中deliveryTag和requeue的含义参考basicReject方法,multiple参数设置为false则表示拒绝编号为deliveryTag这一条消息,这个时候,basicNack和basicReject方法一样,multiple参数设置为true ,则表示拒绝deliveryTag编号之前所有的未被当前消费者确认的消息。
【注意要点】
将channel.basicReject或者channel.basicNack中的requeue设置为false可以启用“死信队列”的功能,死信队列可以通过检测被拒绝或者未被送达的消息来追踪问题。
对于requeue,AMQP中还有一个命令basic.Recover具备可重入队列的特性,其对就的客户端方法为:
这个channel.basicRecover方法用来请求RabbitMQ重新发送还未被确认的消息,如果requeue参数设置为true,则未被确认的消息会重新加入到队列中,这样对于同一条消息来说,可能会被分配给与之前不同的消费者,如果requeue参数设置为false,那么同一条消息会被分配给与之前相同的消费者,默认情况下,如果不设置requeue这个参数,相当于channel.basicRecover(true),即requeue默认为true。
在应用程序使用完之后,需要关闭连接,释放资源。
channel.close();
conn.close();
显示的关闭Channel是个好习惯,但是并不是必需的,在Connection关闭的时候,Channel也会自动关闭。
AMQP协义中的Connection和Channel采用同样的方式来管理网络失败,内部错误和显示的关闭连接,Connection和Channel所具备的生命周期如下所述;
Connection和Channel最终都会成为Closed的关闭状态,不论是程序正常调用关闭方法,或者客户端的异常,再或者是发生网络异常;
在Connection和Channel中,与关闭相关的方法有addShutdownListener(ShutdownListener listener)和removeShutdownListener(ShutdownListner listener) ,当Connection或者Channel的状态转变为Closed的时候会调用ShutdownListener,而且如果将一个ShutdownListener注册到一个己经处于Closed状态的对象(这里是特指Connection和Channel对象)时,会立刻调用ShutdownListener。
getCloseReason方法可以让你知道对象关闭的原因,isOpen方法检测对象当前是否处于开启状态,close(int closeCode,String closeMessage) 方法显示地通知当前对象执行关闭操作。
connection.addShutdownListener(new ShutdownListener() { @Override public void shutdownCompleted(ShutdownSignalException cause) { System.out.println("...己经关闭. "); } });
当触发ShutdownListener的时候,就可以获取到ShutdownSignalException,这个ShutdownSignalException包含了关闭的原因,这里原因也可以通过前面所提及的getCloseReason方法获取 。
ShutdownSignalException提供了多个方法来分析关闭原因,isHardError方法可以知道Connection还是Channel的错误,getReason方法可以获得cause相关的信息。
public void shutdownCompleted(ShutdownSignalException cause) { System.out.println("...己经关闭. "); if(cause.isHardError()){ Connection conn = (Connection)cause.getReference(); if(!cause.isInitiatedByApplication()){ Method reason = cause.getReason(); //... } // ... }else { Channel ch = (Channel) cause.getReference(); // ... } }
备份交换器,英文名字为Alternate Exchange,简称AE,或者更直接的说,备胎交换器,生产者在发送消息的时候如果不设置mandatory参数,那么消息在未被路由的情况下将会丢失,如果设置了mandatory参数,那么需要添加ReturnListener的编程逻辑,生产者的代码将变得复杂,如果既不想复杂的生产者编程逻辑,又不想消息丢失,那么可以使用备份交换器,这样可以将未被路由的消息存储在RabbitMQ中,再在需要的时候去处理这些消息。
可以通过在声明交换器(调用channel.exchangeDeclare方法)的时候添加alternate-exchange参数来实现,也可以通过策略(Policy )的方式来实现,如果两者同时使用,则前者优先级更高,会覆盖掉Policy的设置。
Map
args.put(“alternate-exchange”,“myAe”);
channel.exchangeDeclare(“normalExchange”,“direct”,true,false,args);
channel.exchangeDeclare(“myAe”,“fanout”,true,false,null);
channel.queueDeclare(“normalQueue”,true,false,false,null);
channel.queueBind(“normalQueue”,“normalExchange”,“normalKey”);
channel.queueDeclare(“unroutedQueue”,true,false,false,null);
channel.queueBind(“unroutedQueue”,“myAe”,"");
上面的代码中声明了两个交换器normalExchange和myAe,分别绑定了normalQueue和unroutedQueue这两个队列,同时将myAe设置为normalExchange的备份交换器,注意myAe的交换器类型为fanout。
如果此时发送一条消息到normalExchange上,当路由键等于“normalKey”的时候,消息能正确路由到normalQueue这个队列中,如果键设为其他的值,比如"errorKey",即消息不能被正确的路由与normalExchange绑定到任何队列上,此时就会发送给myAe,进而发送到unroutedQueue这个队列。
同样,如果采用Policy的方式来设置备份交换器,可以参考如下:
rabbitmqctl set_policy AE “^normalExchange$” ‘{“alternate-exchange”:“myAE”}’
备份交换器其实和普通的交换器没有什么太大的区别,为了方便使用,建义设置为fanout类型,如若读者想设置为direct或者topic的类型也没有什么不妥,需要注意的是,消息被重新发送到备份交换器的路由键和从生产者发出的路由键是一样的。
考虑这样一种情况,如果备份交换器的类型是direct,并且有一个与其绑定的队列,假设绑定的路由键是key1,当某个携带路由键为key2的消息被转发到这个备份的交换器的时候,备份交换器没有匹配到合适的队列,则消息丢失,如果消息携带的路由键key1,则可以存储到队列中。
对于备份的交换器,总结了以下几种特殊情况。
目前有两种方法可以设置消息TTL,第一种方法是通过队列属性设置,队列中所有消息都有相同的过期时间,第二种方法对消息本身进行单独设置,每条消息的TTL可以不同,如果两种方法一起使用,则消息的TTL以两者之间较小的那个数值为准,消息在队列中的生存时间一旦超过设置的TTL值时,就会变成“死信”,消息者将无法再收到该消息(这点不是绝对的)
通过队列属性设置TTL的方法是在channel.queueDecalre方法中加入x-message-ttl参数实现,这个参数单位是毫秒。
代码如下:
Map
args.put(“x-message-ttl”,6000);
channel.queueDeclare(queueName,durable,exclusive,autoDelete,args);
同时也可以通过Policy的方式来设置TTL,示例如下:
rabbitmqctl set_policy TTL “.*” ‘{“message-ttl”:6000}’ --apply-to queues
还可以通过调用HTTP API接口设置。
$ curl -i -u root:root -H “content-type:application/json” -X PUT -d ‘{“auto_delete”:false,“durable”:true,“arguments”:{“x-message-ttl”:60000}}’ http://localhost:15672/api/queues/{vhost}/{queuename}
如果不设置TTL,则表示此消息不会过期,如果将TTL设置为0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃,这个我可以部分替代RabbitMQ 3.0版本之前的immediate参数,之所有部分代替,是因为immediate参数在投递失败时会用Basic.Return 将消息(这个功能可以用死信队列来实现)
针对每条消息设置TTL的方法是在channel.basicPublish方法呷加入expiration的属性参数,单位为毫秒。
AMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();
builder.deliveryMode(2);//持久化消息
builder.expiration(“600000”);//设置TTL = 60000 ms
AMQP.BasicProperties properties = builder.build();
channel.basicPublish(exchangeName,routingKey,mandatory,properties,“hello”.getBytes());
也可以使用下面代码:
AMQP.BasicProperties properties = new AMQP.BasicProperties();
Properties.setDeliveryMode(2);
propeties.setExpiration(“60000”);
channel.basicPublish(exchangeName,routingKey,mandatory,properties,“hello”.getBytes());
还可以通过HTTP API接口设置
$curl -i -u root:root -H “content-type:application/json” -X POST -d ‘{“properties”:{expiration":“60000”,“routing_key”:“routingKey”,“payload”:"my body ", "payload_encoding ": “string”}}’ http://localhost:15672/api/exchanges/{vhost}/{exchangeName}/publish
对于第一种设置队列TTL属性的方法,一旦消息过期,就会从队列中抹去,而在第二种方法中,即使消息过期,也不会马上从队列中抹去,因为每条消息是否过期是在即将投递到消费者之前判定的。
为什么这两种方法的处理方式不一样,因为第一种方法里,队列中己经过期的消息肯定在队列头部,RabbitMQ只要定期从队列头开始扫描是否过期消息即可,而在第二种方法里,每条消息的过期时间不同,如果要删除所有过期消息势必要扫描整个队列,所以不如等到此消息即将被消费时再判定是否过期,如果过期再进行删除即可。
设置队列的TTL
通过channel.queueDeclare方法中的x-expires参数可控队列被自动删除前处于未使用状态的时间,未使用的意思是队列上没有任何的消费者,队列也没有被重新声明,并且在过期时间段内也未调用过Basic.Get命令。
设置了队列里TTL可以应用于类似RPC方式的回复队列,在RPC中,许多队列会被创建出来,但是却是未被使用的。
RabbitMQ会确保在过期时间到达后将队列删除,但是不保障删除的动作有多及时,在RabbitMQ重启后,持久化的队列的过期时间会被重新计算。
用于表示过期时间的x-expires参数以毫秒为单位,并且服从和x-message-ttl一样的约束条件,不过不能设置为0,比如该参数设置为1000,则表示该队列如果在1秒钟之内未被使用则会被删除。
下面演示了一个创建一个过期时间为30分钟的队列。
Map
args.put(“x-expires”,30601000);
channel.queueDeclare(“myqueue”,false,false,false,args);
DLX,全称为Dead-Letter-Exchange,可以称之为死信交换器,也有人称之为死信邮箱,当消息在一个队列中变成死信(dead message)之后,它能被重新发送到另一个交换器中,这个交换器是DLX,绑定DLE的队列就称之为死信队列。
消息变成死信一般是由以下的几种情况:
DLX也是一个正常的交换器,和一般的交换器没有什么区别,它能在任何的队列上被指定,实际上就是设置了某个队列的属性,当这个队列中存在死信时,RabbitMQ就会自动地将这个消息重新发布到设置的DLX上去,进而被路由到另一个队列,即死信队列,可以监听这个队列中的消息以进行相应的处理,这个特性与将消息的TTL设置为0配置使用可以弥补immediate参数的功能。
通过channel.queueDeclare方法中设置x-dead-letter-exchange参数来为这个队列添加DLX( dlx_exchange)。
channel.exchangeDeclare(“dlx_exchange”,“direct”);//创建DLX : dlx_exchange
Map
args.put(“x-dead-letter-exchange”,“dlx_exchange”);
//为队列的myqueue 添加DLX
channel.queueDeclare(“myqueue”,false,false,false,args);
也可以为这个DLX指定路由键,如果没有特殊的指定,则使用原队列路由键。
args.put(“x-dead-letter-routing-key”,“dlx-routing-key”);
当然这里也可以通过Policy的方式设置:
rabbitmqctl set_policy DLX “.*” ‘{“dead-letter-exchange”:“dlx_exchange”}’ --apply-to queues
下面创建一个队列,为其设置TTL和DLX等。
channel.exchangeDeclare(“exchange.dlx”,“direct”,true);
channel.exchangeDeclare(“exchange.normal”,“fanout”,true);
Map
args.put(“x-message-ttl”,10000);
args.put(“x-dead-letter-exchange”,“exchange.dlx”);
args.put(“x-dead-letter-routing-key”,“routingkey”);
channel.queueDeclare(“queue.normal”,true,false,false,args);
channel.queueBind(“queue.normal”,“exchange.normal”,"");
channel.queueDeclare(“queue.dlx”,true,false,false,null);
channel.queueBind(“queue.dlx”,“exchange.dlx”,“routingkey”);
channel.basicPublish(“exchange.normal”,“rk”,MessageProperties.PERSIsTENT_TEXT_PLAIN,“dlx”.getBytes());
这里创建了两个交换器exchange.normal和exchange.dlx,分别绑定了两个队列queue.normal和queue.dlx。
由Web管理页面可以看出,两个队列都被标记了"D",这个是durable缩写的,即设置了队列持久化,queue.normal这个队列还配置了TTL,DLX和DLK,其中DLX指的是x-dead-letter-routing-key这个属性。
生产者首先发送一条携带路由键为"rk"的消息,然后经过交换器exchange.normal顺利的存储到队列queue.normal中,由于队列queue.normal设置了过期时间为10s,在 这10s中没有消费者消费这条消息,那么判断这条消息过期,由于设置了DLX过期之时,消息被丢给了交换器exchange.dlx中,这里找到与exchange.dlx匹配的队列queue.dlx,最后消息被存在到queue.dlx这个死信队列中。
对于RabbitMQ来说,DLX是一个非常有用的特性,它可以处理异常情况下,消息不能够被消费者正确消费(消费者调用了Basic.Nack或者Basic.Reject)而被放置到死信队列的情况,后续分析程序可以通过消费这个死信队列中的内容来分析当前所遇到的异常情况,进而可以改善和优化系统,DLX配合TTL使用还要以实现延迟队列的功能。
延迟队列存储的对象是对应的延迟消息,所谓 ,延迟消息,是指当消息被发送后,并不想让消费者立刻拿到消息,而是等待特定的时间后,消费才才能拿到这个消进行消费。
延迟队列的使用场景有很多,比如:
上面死信队列的示例不仅展示死信队列的用法,也是延迟队列的用法,对于queue.dlx这个死信队列来说,同样可以看作延迟队列,假设一个应用中需要将每条消息都设置为10秒的延迟,生产者通过exchange.normal这个交换器将发送的消息存储在queue.noraml这个队列中,消费者订阅的并非是queue.normal这个队列,而是queue.dlx这个队列,当消息从queue.normal这个队列中过期后被存入queue.dlx这个队列中,消费者就恰巧消费到延迟10秒的这个消息。
在真实应用中,对于延迟队列可以根据延迟的时间长短分为多个等级,一般分为5秒,10秒,30秒 ,1 分钟,10 分钟,30 分钟,1 小时这几个维度,当然也可以再细化一下。
为了简化说明,这里只设置了5秒,10秒,30秒,1 分钟这四个等级,根据应用的需求不同,生产者在发送消息的时候通过设置不同的路由键,以此将消息发送到与交换器绑定的不同的队列中,这里队列分别设置了过期时间为5秒,10秒,30秒,1分钟,同时也分别配置了DLX和相应的死信队列,当相应的消息过期时,就会转存到相应的死信队列(即延迟队列中),这样消费者根据业务的自身情况,分别选择不同的延迟等级的延迟队列进行消费。
优先级队列,顾名思义,具有高优先级的队列具有高的优先权,优先级高的消息具备优先被消息的特权。
可以通过设置队列的x-max-proority参数来实现,示例代码如下:
Map
args.put(“x-max-priority”,10);
channel.queueDeclare(“queue.priority”,true,false,false,args);
上面的代码演示了是如何设置了一个队列的最大优先级,在此之后,需要在发送时在消息中设置消息当前的优先级,示例代码如下。
AMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();
builder.priority(5);
AMQP.BasicProperties properties = new builder.build();
channel.basicPublish(“exchange_priority”,“rk_priority”,properties,“message”.getBytes());
上面的代码中设置消息的优先级为5,默认的最低为0,最高为队列设置的最大优先级,优先级高的消息可以被优先消费,这个也是有前提的,如果在消费者的消费速度大于生产者的速度,且Broker中没有消费堆积的情况下,对发送消息的设置优先级就没有什么实际的意义,因为生产者刚发送完一条消息就被消费者消费了,那么就相当于Broker中至少只多一条消息,对于单条消息来说,优先级没有什么意义。
RPC,是Remote Procedure Call的简称,即远程过程调用,它是一种通过网络从远程计算机上请求服务,而不需要了解底层网络的技术,RPC的主要功用是让构建分页式计算更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。
通俗点来说,假设有两台服务器A和B,一个应用部署在A服务器上,想要调用B服务器上的应用来提供函数或者方法,由于不在同一个内存空间,不能直接调用,需要通过网络调用的语义和传达调用的数据 。
一般在RabbitMQ中进行RPC是很简单的,客户端发送请求消息,服务端回复响应的消息,为了接收响应的消息,我们需要在请求消息中发送一个回调队列。
String 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 call_queue…
对于代码中涉及的BasicProperties这个类,我们再来讲讲他的两个属性。
下面没用RabbitMQ官方网站的一个例子来说明,RPC客户端通过RPC来调用服务端的方法以便得到相应的斐波那契的值。
import com.rabbitmq.client.*; import com.spring_1_100.test_71_80.test78_spring_rabbitmq.RabbitConstant; import java.io.IOException; import java.io.UnsupportedEncodingException; public class RPCServer { private static final String RPC_QUEUE_NAME = "rpc_queue"; public static void main(String[] args) throws Exception { //1 创建连接工厂 ConnectionFactory connectionFactory = new ConnectionFactory(); //2 设置参数 connectionFactory.setHost(RabbitConstant.HOST);//设置注解 connectionFactory.setPort(RabbitConstant.PORT);//设置端口 connectionFactory.setVirtualHost(RabbitConstant.VHOST);//设置虚拟及 connectionFactory.setUsername(RabbitConstant.USERNAME); connectionFactory.setPassword(RabbitConstant.PASSWORD); //3 创建连接 Connection connection = connectionFactory.newConnection(); //4 创建channel Channel channel = connection.createChannel(); channel.queueDeclare(RPC_QUEUE_NAME, false, false, false, null); channel.basicQos(1); System.out.println(" [x] Awaiting RPC requests "); Consumer consumer = new DefaultConsumer(channel) { @Override public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException { AMQP.BasicProperties replyProps = new AMQP.BasicProperties.Builder() .correlationId(properties.getCorrelationId()) .build(); String response = ""; try { String message = new String(body, "UTF-8"); int n = Integer.parseInt(message); System.out.println(" [.] fib (" + message + ")"); response += sum(n); } catch (UnsupportedEncodingException e) { e.printStackTrace(); }finally { channel.basicPublish("",properties.getReplyTo(),replyProps,response.getBytes("utf-8")); channel.basicAck(envelope.getDeliveryTag(),false); } } }; channel.basicConsume(RPC_QUEUE_NAME,false,consumer); } public static int sum(int n ){ int sum = 0; for(int i = 0 ;i < n ;i ++){ sum += i; System.out.println("========" + sum); } return sum; } }
RPC客户端的关键代码如下:
public class RPCClient { private static final String RPC_QUEUE_NAME = "rpc_queue"; private Connection connection; private Channel channel; private String replyQueueName; private QueueingConsumer consumer; public RPCClient() throws Exception { //1 创建连接工厂 ConnectionFactory connectionFactory = new ConnectionFactory(); //2 设置参数 connectionFactory.setHost(RabbitConstant.HOST);//设置注解 connectionFactory.setPort(RabbitConstant.PORT);//设置端口 connectionFactory.setVirtualHost(RabbitConstant.VHOST);//设置虚拟及 connectionFactory.setUsername(RabbitConstant.USERNAME); connectionFactory.setPassword(RabbitConstant.PASSWORD); //3 创建连接 connection = connectionFactory.newConnection(); //4 创建channel channel = connection.createChannel(); replyQueueName = channel.queueDeclare().getQueue(); consumer = new QueueingConsumer(channel); channel.basicConsume(replyQueueName, true, consumer); } public String call(String message) throws Exception { String response = null; String corrId = UUID.randomUUID().toString(); AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .correlationId(corrId) .replyTo(replyQueueName) .build(); channel.basicPublish("", RPC_QUEUE_NAME, props, message.getBytes()); while (true) { QueueingConsumer.Delivery delivery = consumer.nextDelivery(); if (delivery.getProperties().getCorrelationId().equals(corrId)) { response = new String(delivery.getBody()); break; } } return response; } public void close() throws Exception { connection.close(); } public static void main(String[] args) throws Exception { RPCClient fibRpc = new RPCClient(); System.out.println(" [ x] Requeting fib(30)"); String response = fibRpc.call("30"); System.out.println("[.] Got '" + response + "'"); fibRpc.close(); } }
"持久化"这个词在前面的篇幅中有多次提及,持久化可以提高RabbitMQ的可靠性,以及在异常情况(重启,关闭,宕机等)下的数据丢失,下面针对这个概念做一个总结,RabbitMQ的持久化分为三个部分,交换器的持久化,队列的持久化和消息的持久化。
交换器的持久化是通过在声明队列是将durable参数设置为true实现的,如果交换器不设置持久化,那么在RabbitMQ服务器重新之后,相关的交换器元数据会丢失,不过消息不会丢失,只是不能将消息发送到这个交换器中,对一个长期使用的交换器来说,建义将其设置为持久化。
队列持久化是通过在声明队列时将durable参数设置为true实现的,如果队列不设置为持久化,那么在RabbitMQ服务器重启之后,相关的队列元数据就会丢失,此时数据 也会丢失,正所谓“皮之不存,毛将焉附”,队列没有了,消息又能存在到哪里呢?
队列的持久化能保证其本身的元数据不会因异常情况而丢失,但是并不能保证内部所存储的消息不会丢失,要确保消息不会丢失,需要将其设置为持久化,通过将消息的投递模式(BasicProperties中的deliveryMode属性)设置为2即可实现消息的持久化,前面示例中多次提及的MessageProperties.PERSISTENT_TEXT_PLAIN实际上是封装了这个属性。
public static final BasicProperties PERSISTENT_TEXT_PLAIN = new BasicPropeties(“text/plian”,null,null,2,//deliverMode
0,null,null,null,
null,null,null,null,
null,null);
设置了队列和消息的持久化,当RabbitMQ服务器重启后,消息依旧存在,单单设置队列的持久化,重启之后消息会丢失,单单设置消息的持久化,重启之后队列消息,继而消息也丢失,单单设置消息持久化,而不设置队列的持久化显得毫无意义。
注意要点:
可将所有的消息都设置为持久化,但是这样会严重影响RabbitMQ的性能(随机)写入磁盘的速度比写入内存的速度慢的不止一点点,对于可靠性还是那么高的消息可以不采用持久化处理以提高整体的吞吐量,在选择是否要将消息持久化时,需要可靠性和吞吐量之间做一个权衡。
将交换器,队列,消息都设置成了持久化之后就能百分之百的保证数据不丢失了吗?答案是否定的。
首先从消费者来说,如果在订阅消费者队列时将autoAck参数设置为true,那么当消费者接收到相关的消息之后,还没来得及处理就宕机了,这样也算数据丢失,这种情况很好解决,将autoAck参数设置为false,并进行手动确认。
其次,在持久化消息正确存入RabbitMQ之后,还需要有一段时间(虽然很短,但是不可以忽视)才能存入磁盘之中,RabbitMQ并不会为每条消息都进行同步存盘(调用内核的fsync方法)处理,可能仅仅保存到操作系统缓存之中,而不是物理磁盘之中,如果在这段时间之内RabbitMQ服务节点发生了宕机,重启等异常情况,消息保存还没有来得及落盘,那么这个时候消息将会丢失
【注意】:fsync在Linux中的意义在于同步数据到存储设备之上,大多数的块设备数据都是通过缓存进行的,将数据写到文件上通常将数据由内核复制到缓存中,如果缓存没有写满,则不将其排入输出队列之上,而是等待其写满或者当内核需要重用该缓存时,再将该缓存排入输出队列,进而同步到设备上,这种策略的好处是减少了磁盘的读写次数,不足的地方是降低了文件内容的更新速度,使其不能同步到存储设备上,当系统发生故障时,这种机制很可能导致了文件内容的丢失,因此 ,内核提供了fsync接口,用户可以根据自己的需要通过此接口更新数据到存储设备上。
这个问题怎么解决呢?这里可以引入RabbitMQ的镜像队列机制,相当于配置了副本,如果主节点(master)在此特殊时间内挂掉,可以自动切换到从节点(slave),这样有效的保证了高可用性,除非整个集群都挂掉,虽然这样也不能完全保证RabbitMQ消息的不丢失,但是配置了镜像队列要比没有配置镜像队列可靠性要高很多,在实际生产环境中的关键业务队列一般都会设置镜像队列。
还可以在发送端引入事务机制或者发送方确认机制来保证消息己经正确的发送并存储到RabbitMQ中,前提是要保证在调用channel.basicPublish方法的时候交换器能够将消息正确的将消息路由到相应的队列之中。
在使用RabbitMQ的时候,可以通过消息持久化操作来解决因为服务器的异常崩溃瑞导致消息的丢失,除此之外,我们还会遇到一个问题,当消息的生产者将消息发送出去之后,消息到底有没有正确的到达服务器呢?如果不进行特殊的配置,默认情况下发送消息的操作是不会返回任何信息给生产者的,也就是默认情况下,生产者是不知道消息有没有正确的到达服务器,如果在消息到达服务器之前己经丢失了,持久化操作解决不了这个问题,因为消息根本没有到达服务器,何谈持久化?
RabbitMQ针对这个问题,提供了两种解决方式:
RabbitMQ 客户端中与事务机制相关的方法有三个,channel.txSelect,channel.txCommit和channel.txRollback,channel.txSelect用于将当前信道设置成事务的模式,channel.txCommit用于提交事务,channel.txRollback用于事务回滚,在通过channel.txSelect方法开启事务之后,我们便可以发布消息给RabbitMQ了,如果事务提交成功,则消息一定到达RabbitMQ中,如果事务提交执行之前由于RabbitMQ异常,这个时候我们便可以将其捕获,进而通过执行channel.txRollback方法来实现事务回滚,注意,这里RabbitMQ中的事务机制与大多数数据的事务概念并不相同,需要注意区分。
channel.txSelect();channel.txSelect();
channel.basicPublish(“EXCHANGE_NAME”,“ROUNT_KEY”,MessageProperties.PERSISTENT_TEXT_PLAIN,“transaction messages”.getBytes());
上面的代码对应AMQP协义流转过程。
可以发现开启事务的机制与不开启事务的机制多了4个步骤。
try{
channel.txSelect();
channel.basicPublish(exchange,routingKey,
MessageProperties.PERSISTENT_TEXT_PLAIN,
msg.getBytes());
int result = 1 / 0 ;
channel.txCommit();
}catch(Exception e ){
e.printStackTrace();
channel.txRollback();
}
上面的代码很明显有一个java.lang.ArithmeticException,在事务提交之前捕获到异常,之后显式的提交事务回滚,其AMQP协义流程过程如下图
如果要发送多条消息,则将channel.basicPublish和channel.txCommit等方法包裹进循环内即可。
channel.txSelect();
for(int i = 0;i < 10 ;i ++){
try{
channel.basicPublish(“exchange”,“routingKey”,null,
(“message” + i ).getBytes());
channel.txCommit();
} catch(Exception e ){
e.printStackTrace();
channel.txRollback();
}
}
事务确实能够解决消息发送方和RabbitMQ之间消息确认的问题,只有消息被成功接收,事务才能提交成功,否则便可以捕获异常之后进行事务回滚,与此同时可以进行消息的重发,但是使用事务机制会“吸干”RabbitMQ的性能,那么有没有更好的方法既能保证消息发送方确认消息己经正确送达,又能基本上不带来性能上的损失呢?从AMQP协义层面来看并没有更好的办法,但是RabbitMQ提供了一个改进方案,即发送方确认机制 。
前面介绍了RabbitMQ可能会遇到一个问题,即消息发送方(生产者)并不知道消息是否真正的到达RabbitMQ,随后了解到AMQP协义层面提供的事务机制来解决这个问题,但是采用事务机制实现会严重降低RabbitMQ的消息吞吐量,这里就引入一个轻量级的方式,发送方确认(publisher confirm )机制 。
生产者将信道设置成confirm(确认)模式,一旦信道进入comfirm模式,所有在该信道上发布的消息都会被指派一个唯一的id(从1开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一ID),这就使得生产者tdjq消息正确到达目的地了,如果消息和队列是可持久化的,那么确认消息会在消息写入磁盘之后发出,RabbitMQ回传给生产者确认消息中的deliveryTag包含了确认消息的序号,此外RabbitMQ也可以设置channel.basicAck方法中的multiple参数,表示到这个序号之前的所有消息都得到了处理,注意辨别这里确认和消费者时候确认之间的异同 。
事务机制在一条消息发送之后会使发送端阻塞,以等待RabbitMQ回应,之后才能继续发送下一条消息,相比之下,发送方确认机制最大的好处在于它是异步的,一旦发布一条消息后,生产者应用程序便可以通过回调的方法来处理该确认消息,如果RabbitMQ因为自身的内部错误导致消息的丢失,就会发送一条nack(Basic.Nack)命令,生产者应用程序同样可以回调方法中处理该nack命令。
生产者通过调用channel.confirmSelect方法(即Confirm.Select命令)将信道设置为confirm模式,之后RabbitMQ会返回Confirm-Select-OK命令表示同意生产者将当前信道设置为confirm械,所有被发送后续消息都被ack或者nack一次,不会出现一条消息被ack又被nack的情况,并且RabbitMQ也并没有对消息被confirm的快慢做任何保证。
下面看一下publisher confirm 机制怎么动作,知道要代码如下:
try{
channel.confirmSelect();//将信道设置publisher confirm模式
channel.basicPubish(“exchange”,“routingKey”,null,
“publisher confirm test”.getBytes());
if(!channel.waitForConfirms()){
System.out.println(“send message failed”);
}
}catch(Exception e ){
e.printStackTrace();
}
如果发送多条消息,只需要将channel.basicPublish和channel.waitForConfirms方法包裹在循环里即可,可以参考事务机制,不过不需要把channel.confirmSelect方法包裹在循环内部。
publisher confirm模式下发送多条消息AMQP协义流转过程如下。
对于channel.waitForCorms而言,在RabbitMQ客户端中它有4个同类方法;
如果信道没有开启publisher confirm 模式,则调用任何waitForConfirms方法都会报出java.lang.IllegalStateException,对于没有参数的waitForConfirm方法来说,其返回的条件是客户端收到相应的Basic.Ack/.Nack或者被中断,参数timeout表示超时时间,一旦等待RabbitMQ回应超时就会抛出java.util.concurrent.TimeoutException的异常,两个waitForConfirmsOrDie方法在接收到RabbitMQ返回的Basic.Nack之后会抛出java.io.IOException,业务代码可以根据自身的特点灵活的运行这四种方法来保障消息的可靠发送。
前面提到过RabbitMQ引入了publisher confirm机制来弥补事务机制的缺陷,提高了整体的吞吐量,那么我们来对比下面两者间的QPS,测试代码可以参考上面的示例代码。
图4-12中的横坐标表示测试的次数,纵坐标表示QPS,可以发现publisher confirm与事务机制相比,QPS并没有提高多少,难道是RabbitMQ欺骗了我们?
我们再来回顾下前面的示例代码,可以发现publisher confirm 模式每发送一条消息后就调用channel.waitForConfirms方法,之后等待服务端确认,这实际上是一种串行同步等待的方式,事务机制和它一样,发送消息之后等待服务端确认,之后再发送消息,两者存储确认原理相同,尤其对于持久化消息来说,两者都需要等待消息确认落盘之后才会返回(调用Linux内核的fsync方法),在同步等待方式下,publisher confirm 机制发送一条消息需要通信交互的命令是2条,Basic.Publish和Basic.Ack事务机制是3条,Basic.Publish,Tx.Commit/.Commit-OK(或者Tx.Rollback/.Rollback-Ok),事务机制多了一条命令帧报文交互,所以QPS会略微下降。
【注意要点】
在批量confirm方法中,客户端程序需要定期或者定量(达到多条),亦或者两者结合起来调用channel.waitForConfirms来等待RabbitMQ确认返回,相比于前面的示例中普通 confirm方法,指极大的提升了confirm的效率,但是问题在于出现返回Basic.Nack或者超时的情况时,客户端需要将这一批次的消息全部重发,这会带来明显的重复消息数量,并且当消息经常丢失的时候,批量confirm的应该是不升反降的。
try{
channel.confirmSelect();
int MsgCount = 0;
while(true){
channel.basicPublish(“exchange”,“routingKey”,
null,“batch confirm”.getBytes());
//将发送出去的消息存入缓存 中,缓存可以是一个ArrayList或者BlockingQueue之类的
if(++MsgCount >= BATCH_COUNT){
MsgCount = 0 ;
try{
if(channel.waitForConfirms()){
//将缓存的消息清空
}
//将缓存的消息重新发送
}catch(InteruptedException 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客户端JDK中的waitFormConfirms方法也是通过SortedSet维护消息序号的,下面演示异步confirm编程实现,其中的confirmSet就是一个SortedSet类型的集合。
channel.confirmSelect();
channel.addConfirmListener(new ConfirmListener(){
public void handleAck(long deliveryTag,boolean multiple) throws IOException{
System.out.print(“Nack ,SeqNo :” + deliveryTag + “, multiple :” + multiple);
if(multiple){
confirmSet.headSet(deliveryTag -1 ).clear();
}else {
confirmSet.remove(deliveryTag);
}
}
public void handleNack(long deliveryTag,boolean multiple) throw IOException{
if(multiple){
confirmSet.headSet(deliveryTag -1 ).clear();
}else{
confirmSet.remove(deliveryTag);
}
}
});
//下面演示一下发送消息的场景
while(true){
long nextSeqNo = channel.getNextPublishSeqNo();
channel.basicPublish(ConfirmConfig.exchangeName,ConfirmConfig.routingKey,
MessageProperties,PERSISTENT_TEXT_PLAIN,ConfirmConfig.msg_100B.getBytes());
confirmSet.add(nextSeqNo);
}
最后,我们将事务,普通 confirm,批量confirm,和异步confirm这4种方式放到一起来比较一下彼此的QPS,请看下图。
可以看到批量confirm和异步confirm这两种方式所呈现的性能要比其余两种好得多,事务机制和普通的confirm的方式吞吐量很低,但是编程方式简单,不需要在客户端维护状态(这里批的是维护deliveryTag及缓存未确认的消息),批量confirm方式的问题在于遇到RabbitMQ服务端返回Basic.nack需要重新发送批量消息而导致性能降低,异步confirm方式编程模型最为复杂,而且和批量confirm方式一样需要在客户端维护状态,在实际生产环境中采用何种方式,这里就仁者见仁智者见智了,不过强烈建义使用异步confirm的方式。
如何正确的消费消息,消费者客户端可以通过推模式或者拉模式的方式来获取并消息消息,当消费者处理完业务逻辑需要手动确认消息己经被接收,这样,RabbitMQ才能把当前消息从队列中标记清除,当然,如果消费者由于某些原因无法处理当前接收的消息,可以通过channel.basicNack或者channel.basicReject来拒绝掉。
这里对于RabbitMQ消费者来说,还有几点需要注意:
当RabbitMQ队列拥有多个消费者时,队列收到的消息将以轮询的(round-robing)的分发方式发送给消费者,每条消息只会发送给订阅列表里的一个消费者,这种方式非常适合扩展,而且它是专门为并发程序设计的,如果现在负载加重,那么只需要创建更多的消费者处理消息即可,很多的时候轮询的分发机制也不是那么优雅,默认情况下,如果有n个消费者,那么RabbitMQ会将第m条消息分发给第m%n取余的方式个消费者,RabbitMQ不管是消费者是否消费并己经确认(Basic.Ack)了消息,试想一下,如果某些消费者任务繁重,来不及消费那么多的消息,而某些其他消费者由于某些原因(比如业务逻辑简单,机器性能卓越等),很快地处理完所分配的消息,进而进程空闲,这样就造成整体的吞吐量下降。
那么该如何处理这种情况呢?这里就要用到channel.basicQos(int prefetchCount)这个方法,如前面章节所述,channel.basicQos方法允许限制信道上的消费者所能保持最大的确认消息的数量。
举例来说,在订阅消费者队列之前,消费端程序调用了channel.basicQos(5),之后订阅了某个队列进行消费,RabbitMQ会保存一个消费者的列表,每发送一条消息都会有对应的消费者计数,如果达到了所设定的上限,那么RabbitMQ就不会向这个消费者再发送任何消息了,直到消费者确认了某条消息之后,RabbitMQ将相应的计数减1,之后消费者可以继续接收消息,直到再次到达计数上限,这种机制可以类比于TCP/IP中的,滑动窗口。
【注意要点】
Basic.Qos使用对于拉模式的消费方式无效。
channel.basicQos有三种类型的重载方法。
global参数 | AMQP-0-9-1 | RabbitMQ |
---|---|---|
false | 信道上所有的消费者都需要遵从prefetchCount的限定值 | 信道上的新消费者需要遵从prefetchCount的限定值 |
true | 当前通信链路(Connection)上所有消费者都需要遵从prefetchCount的限定值 | 信道上所有的消费者都需要遵从prefetchCount的限定值 |
前面的章节中的channel.basicQos方法的示例是针对单个消费者的,而对于同一个信道上的多个消费者而言,如果没有设置prefetchCount的值,那么都会生效。
Channel channel = …;
Consumer consumer1 = …;
Consumer consumer2 = …;
channel.basicQos(10); // Per consumer limit
channel.basicConsume(“my-queue1”,false,consumer1);
channel.basicConsume(“my-queue2”,false,consumer2);
如果在订阅消息之前,既设置了global为true的限制,又设置了global为false的限制,那么哪个会生效呢?RabbitMQ会确保两者都会生效,举例来说,当前有两个队列queue1和queue2:queue1有10条消息,分别为1到10,queue2也有10条消息,分别为11到20,有两个消费者分别消息这两个队列,如下所示。
Channel channel1 = …;
Channel channel2 = …;
channel1basicQos(3,false);
channel.basicQos(5,true);// Per channel limit
channel.basicConsume(“queue1”,false,consumer1);
channel.basicConsume(“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中,此时ConsumberB己经消息了msg2,msg4,之后再消息msg1,这样消息的顺序性也就错乱了,或者说消息msg1又重新发往ConsumerA中,此时ConsumerA己经消息了msg3,那么再消息msg1,消息的顺序性也无法得到保障,同样可以用Basic.Recover这个AMQP命令中。
包括但不仅限于以上的几种情形会使RabbitMQ消息错序,如果要保证消息的顺序性,需要业务方使用RabbitMQ之后做进一步的处理,比如在消息体内添加全局的有序标识(类似Sequeue ID)来实现。
在前面的章节中所介绍的订阅消息的方式都是通过继承DefaultConsumer类来实现的,前面提到过QueueingConsumer这个类,并且不建议用这个类来实现订阅消费,QueueingConsumer在RabbitMQ客户端3.x版本是用得如火如荼,但是在4.x版本就开始被标记为@Deprecated,想必这个类有些无法弥补的缺陷。
不防来看看QueueingConsumer 的用法,请看下面
QueueingConsumer consumer = new QueueingConsumer(channel);
channel.basicConsume(“queue_name”,false,“consumer_zzh”,consumer);
while(true){
QueueingConsumer.Delivery delivery = consumer.nextDelivery();
String message = new String(deliver.getBody());
System.out.print("[x] Received " + message + “’”);
channel.basicAck(delivery.getEnvelop().getDeliveryTag(),false);
}
乍一看也没有什么问题,而且实际生产环境中如果不是太“傲娇”地使用也不会造成什么大的问题,QueueingConsumer本身也有几大缺陷,需要使用特别注意,首当其冲的就是内存溢出问题,如果由于某些原因,队列之中堆积了比较多的消息,就可能导致消费者客户端内存溢出假死,于是发生了恶性循环,队列消息不断堆积而得不到消化。
使用上面的代码来做演示,首先向一个队列中发送200多MB的消息,然后进行消费,在客户端调用channel.basicConsume方法订阅队列的时候,RabbitMQ会持续的将消息发送QueueingConsumer中,QueueingConsumer内部使用LinkedBlockingQueue来缓存这些信息,通过JVisualVM可以看到堆内存的变化 。
由图4-14可以看到堆内存一直在增加,这里测试发送了200M左右的消息,如果发送更多的消息,那么这个堆内存会变得更加大,直到出现java.lang.OutOfMemoryError的报错。
这个内存溢出问题可以使用Baisc.Qos来得到有效的解决Basic.Qos可以限制某个消费者所保持未确认的消息的数量,也就是间接的限制了QueueingConsumer中的LinkedBlockingQueue的大小,注意一定要调用Basic.Consume之前调用Basic.Qos才能生效。
QueueingConsumer还包含了以下的一些缺陷。
为了为避免不必要的麻烦,建议在消费者时候尽量使用继承DefaultConsumer的方式,具体的使用方式可以参考如下:
消息可靠传输一般是业务系统接入消息中间件时首先考虑的问题,一般消息中间件的消息传输保障分为三个层级 。
"最多一次"在方式就无需考虑以上的这些情况,生产者随意发送,消费者随意消费,不过这样很难确保消息不会丢失。
"恰好一次"是RabbitMQ目前无法保障的,考虑这样一种情况,消费者在消费完一条消息之后向RabbitMQ发送确认Basic.Ack命令,此时由于网络断开或者其他原因造成RabbitMQ并没有收到这个确认命令,那么RabbitMQ不会将此消息标记为删除,在重新连接之后,消费者还是会消费这一条消息,就造成了重复消费,再考虑一种情况,生产者在使用publisher confirm 机制的时候,发送完一条消息等待RabbitMQ返回确认通知,此时网络断开 ,生产者捕获到异常情况,为了确保消息可靠性选择重新发送,这样RabbitMQ中就有两条同样的消息,在消费的时候,消费者就会重复消费。
那么RabbitMQ有没有去重的机制来保证“恰好一次”呢?答案是并没有,不仅是RabbitMQ,目前大多数主流的消息中间件都没有消息去重机制,也不保障“恰好一次”,去重处理一般是在业务客户端实现,比如引入GUID(Globally Unique Identifier)的概念,针对 GUID,如果从客户端的角度去重,那么需要引入集中式缓存,必然会增加依赖复杂度,另外缓存的大小也难以界定,建议在实际生产环境中,业务方根据自身的业务特性进行去重,比如业务消息本身具备幂等性,或者借助Redis等其他的产品进行去重处理。
总结 :
提升数据可靠性有以下途径,设置mandatory参数或者备份交换器(imediate参数己经被淘汰),设置publisher confirm机制或者事务机制,设置交换器,队列和消息都为持久化,设置消费端对应的autoAck参数为false,并在消费完消息后再进行消息的确认,在本文中不仅介绍了数据的可靠性的一些细节 ,还展示了RabbitMQ的几种己经具备或者衍生的高级特性,包括TTL,死信队列,优先级队列,RPC功能等,这些功能的实际使用中可以让相应的应用的实现变得事半功倍。
总结 :
这篇博客主要讲RabbitMQ客户端开发的一个简单使用说明,按照一个生命周期的维度连接,创建,生产,消费和关闭等几个方法进行笼统的介绍,读者学习完这个内容之后,就能够有效的进行与RabbitMQ相关的开发工作,知是行之始,行是知之成,不如现在就动手编写几个程序来实现一下吧。
本文的代码是github地址是
https://github.com/quyixiao/spring_tiny/tree/master/src/main/java/com/spring_1_100/test_71_80/test78_spring_rabbitmq
参考书籍为 RabbitMQ实战指南