RabbitMQ学习笔记(二) 之消息发布与权衡

100 阅读12分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第6天,点击查看活动详情

1、原生 Java 客户端进行消息通信

使用普通maven项目和RabbitMQ原生Java客户端进行消息通讯代码演示,使用依赖:

<!-- RabbitMQ原生客户端-->
<dependencies>
    <dependency>
        <groupId>com.rabbitmq</groupId>
        <artifactId>amqp-client</artifactId>
        <version>5.0.0</version>
    </dependency>
</dependencies>

使用类说明:

  • DirectProducer:direct 类型交换器的生产者
public class DirectProducer {

    public static void main(String[] args) throws IOException, TimeoutException {
        // ---------begin------创建连接可复用部分--------
        ConnectionFactory connectionFactory = new ConnectionFactory();
        //设置下连接工厂的连接地址(使用默认端口5672)
        connectionFactory.setHost("127.0.0.1");
        //这个是我的虚拟主机
        connectionFactory.setVirtualHost("/test");
        //admin 是我新增的用户
        connectionFactory.setUsername("admin");
        connectionFactory.setPassword("admin");
        //创建连接
        Connection connection = connectionFactory.newConnection();
        //创建信道
        Channel channel = connection.createChannel();
        //在信道中设置交换器,交换器名称:direct_logs,类型:DIRECT
        channel.exchangeDeclare("direct_logs",BuiltinExchangeType.DIRECT);
        // ---------end------创建连接可复用部分--------
        
        //申明队列(放在消费者中去做)
        //申明路由键:school,消息体:msg
        String[] routeKeys ={"a-school","b-school","c-school"};
        for (int i=0;i<3;i++){
            String routeKey = routeKeys[i%3];
            String msg = "Hello,RabbitMQ "+(i+1);
            //发布消息
            channel.basicPublish(EXCHANGE_NAME,routeKey,null,msg.getBytes());
            System.out.println("Sent:"+routeKey+":"+msg);
        }
        channel.close();
        connection.close();
    }

}
  • NormalConsumer:普通的消费者
public class NormalConsumer {

    public static void main(String[] argv) throws IOException, TimeoutException {
    
        // ---------begin------创建连接可复用部分--------
        ConnectionFactory connectionFactory = new ConnectionFactory();
        //设置下连接工厂的连接地址(使用默认端口5672)
        connectionFactory.setHost("127.0.0.1");
        //这个是我的虚拟主机
        connectionFactory.setVirtualHost("/test");
        //admin 是我新增的用户
        connectionFactory.setUsername("admin");
        connectionFactory.setPassword("admin");
        //创建连接
        Connection connection = connectionFactory.newConnection();
        //创建信道
        Channel channel = connection.createChannel();
        //在信道中设置交换器,交换器名称:direct_logs,类型:DIRECT
        channel.exchangeDeclare("direct_logs",BuiltinExchangeType.DIRECT);
        // ---------end------创建连接可复用部分--------

        //申明队列(放在消费者中去做)
        String queueName = "queue-school";
        channel.queueDeclare(queueName,false,false,false,null);

        //绑定:将队列(queuq-school)与交换器通过 路由键 绑定(a-school)
        String routeKey ="a-school";
        channel.queueBind(queueName,"direct_logs",routeKey);
        System.out.println("waiting for message ......");

        //申明一个消费者
        final Consumer consumer  = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String s, Envelope envelope, AMQP.BasicProperties basicProperties, byte[] bytes) throws IOException {
                String message = new String(bytes,"UTF-8");
                System.out.println("Received["+envelope.getRoutingKey()+"]"+message);
            }
        };
        //消息者正式开始在指定队列上消费。(queue-school)
        //这里第二个参数是自动确认参数,如果是true则是自动确认
        channel.basicConsume(queueName,true,consumer);
    }
}

  • MulitBindConsumer:队列绑定到交换器上时,是允许绑定多个路由键的,也就是多重绑定

    public static void main(String[] argv) throws IOException, InterruptedException, TimeoutException {

        //创建连接,打开连接,创建信道设置交换器都与之前的一模一样,不再放上去,要做测试的自己ctrl+c/v
        
        //声明一个随机队列
        String queueName = channel.queueDeclare().getQueue();
        
        // todo 队列绑定到交换器上时,是允许绑定多个路由键的,也就是多重绑定
        String[] routekeys = {"a-school","b-school","c-school"};
        for(String routekey:routekeys){
            channel.queueBind(queueName,DirectProducer.EXCHANGE_NAME, routekey);
        }
        System.out.println(" [*] Waiting for messages:");

        // 创建队列消费者
        final Consumer consumerA = new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                String message = new String(body, "UTF-8");
                System.out.println(" Received " + envelope.getRoutingKey() + ":'" + message + "'");
            }
        };
        channel.basicConsume(queueName, true, consumerA);
    }
}
  • MulitChannelConsumer:一个连接下允许有多个信道
public class MultiChannelConsumer {

    private static class ConsumerWorker implements Runnable{ final Connection connection;

        public ConsumerWorker(Connection connection) {
            this.connection = connection;
        }

        public void run() {
            try {
                /*创建一个信道,意味着每个线程单独一个信道*/
                Channel channel = connection.createChannel();
                //信道设置交换器类型(direct)
                channel.exchangeDeclare(DirectProducer.EXCHANGE_NAME,BuiltinExchangeType.DIRECT);
                // 声明一个随机队列
                 String queueName = channel.queueDeclare().getQueue();
                //String queueName = "queue-school";      // 同一个队列

                //消费者名字,打印输出用
                final String consumerName =  Thread.currentThread().getName()+"-all";

                /*队列绑定到交换器上时,是允许绑定多个路由键的,也就是多重绑定*/
                String[] routekeys={"a-school","b-school","c-school"};
                for(String routekey:routekeys){
                    channel.queueBind(queueName,"direct_logs", routekey);
                }
                System.out.println("["+consumerName+"] Waiting for messages:");

                // 创建队列消费者
                final Consumer consumerA = new DefaultConsumer(channel) {
                    @Override
                    public void handleDelivery(String consumerTag,Envelope envelope,AMQP.BasicProperties properties, byte[] body) throws IOException {
                        String message = new String(body, "UTF-8");
                        System.out.println(consumerName  +" Received "  + envelope.getRoutingKey() + ":'" + message + "'");
                    }
                };
                channel.basicConsume(queueName, true, consumerA);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] argv) throws IOException,  InterruptedException,TimeoutException {
        //连接工厂
        ConnectionFactory factory = new ConnectionFactory();
        //连接rabbitMq的地址
        factory.setHost("127.0.0.1");
        factory.setVirtualHost("/test");
        factory.setUsername("admin");
        factory.setPassword("admin");
        // 打开连接和创建频道,与发送端一样
        Connection connection = factory.newConnection();
        //一个连接多个信道
        for(int i=0;i<2;i++){
            /*将连接作为参数,传递给每个线程*/
            Thread worker = new Thread(new ConsumerWorker(connection));
            worker.start();
        }
    }
}
  • MulitConsumerOneQueue:一个队列多个消费者,则会表现出消息在消费者之间的轮询发送

    private static class ConsumerWorker implements Runnable{
        final Connection connection;
        final String queueName;

        public ConsumerWorker(Connection connection,String queueName) {
            this.connection = connection;
            this.queueName = queueName;
        }

        public void run() {
            try {
                /*创建一个信道,意味着每个线程单独一个信道*/
                final Channel channel = connection.createChannel();
                //信道设置交换器类型(direct)
                channel.exchangeDeclare("direct_logs",BuiltinExchangeType.DIRECT);
                /*声明一个队列,rabbitmq,如果队列已存在,不会重复创建*/
                channel.queueDeclare(queueName, false,false, false,null);
                //消费者名字,打印输出用
                final String consumerName =  Thread.currentThread().getName();

                /*队列绑定到交换器上时,是允许绑定多个路由键的,也就是多重绑定*/
                String[] routekeys={"a-school","b-school","c-school"};
                for(String routekey:routekeys){
                    channel.queueBind(queueName,DirectProducer.EXCHANGE_NAME,  routekey);
                }
                System.out.println(" ["+consumerName+"] Waiting for messages:");

                // 创建队列消费者
                final Consumer consumerA = new DefaultConsumer(channel) {
                    @Override
                    public void handleDelivery(String consumerTag,Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                        String message =  new String(body, "UTF-8");
                        System.out.println(consumerName  +" Received "  + envelope.getRoutingKey() + ":'" + message + "'");
        

Direct 交换器

1. 生产者和消费者一般用法
一个消费者绑定一个队列,相当于点对点传递信息。

比如:先执行 NormalConsumer 绑定a-school路由键,等待接收消息;然后执行DirectProducer去发送消息,循环创建了3个路由键,每个路由键发送了一个消息,然后NormalConsumer会根据绑定只接收到一条消息。

image.png

2. 队列和交换器的多重绑定
一个队列绑定到交换器上时,是允许绑定多个路由键的,也就是多重绑定。对比单个绑定的消费者只能收到指定的消息,多重绑定的的消费者可以收到所有的消息。 

比如:先执行MultiBindConsumer等待接收消息;然后执行DirectProducer去发送消息。

image.png

3. 一个连接下允许有多个信道
一个连接,我们可以使用多个信道进行通讯,那么每个信道都会收到一条消息,这样可以做到多路复用。

比如:先执行 MulitChannelConsumer等待接收消息,绑定了3个路由键,使用线程创建2个信道;然后执行DirectProducer去发送消息。每个信道都会收到一条消息,因为3个路由键所,以两个信道6条消息。

image.png

4. 一个队列多个消费者
一个队列多个消费者,则会表现出消息在消费者之间的轮询发送。

比如:为了方便看出轮询,生产者发送6条消息,然后MulitConsumerOneQueue用线程模拟3个消费者,运行结果为每个消费者拿到2条消息

image.png

Fanout

不处理路由键,只需要简单的将队列绑定到交换机上。
发送到交换机的消息都会被转发到该交换机绑定的所有队列上。
Fanout交换机转发消息是最快的。
Fanout Exchange交换机可以简单的理解为广播站。

比如:我这边创建2个消费者类Consumer1 和 Consumer2,一个绑定多个路由,一个绑定不存在的路由,然后执行生产者FanoutProducer,发现他们全部都可以接收到消息

image.png

image.png

Topic

通过使用 * 和 # ,使来自不同源头的消息到达同一个队列,由 . 将路由键分为了几个标识符,* 匹配 1 个,# 匹配一个或多个。

例子如下:假设有交换器 topic_school
学校有: a-school、b-school、c-school
年级有: 一年级、二年级、三年级
班级有: 一班、二班、三班
路由键的规则为 学校.技术专题.班级,如:a-school.一年级.一班 ;
*与#的区别:
如果发送的路由键变成 a-school.一年级.一班; 那么队列中如果绑定了 a-school.* 不能匹配。队列中如果绑定了 a-school.# 能够匹配成功。 

生产者数据:

image.png

  1. 要关注所有的班级,怎么做?声明一个队列并绑定到交换器上:channel.queueBind(queueName,topic_school, "#");

image.png

  1. 关注 a-school 的所有班级,怎么办?声明一个队列并绑定到交换器上:channel.queueBind(queueName,topic_school, "a-school.#"); 注意:如果这里改为 a-school.* 的话,则不会出现任何信息,因为 * 匹配 1 个(使用.分割的标识的个数)

image.png

  1. 关注 a-school 所有的 一班,怎么办?声明一个队列并绑定到交换器上:channel.queueBind(queueName,topic_school, "a-school.#.一班");

    或者声明一个队列并绑定到交换器上:

    channel.queueBind(queueName,topic_school, "a-school.*.一班");

image.png

  1. 关注 一年级 所有的班,怎么办?声明一个队列并绑定到交换器上:channel.queueBind(queueName,topic_school, "#.一年级.#");

image.png

  1. 关注所有的 一班,怎么办?声明一个队列并绑定到交换器上:channel.queueBind(queueName,topic_school, "#.一班");

image.png

  1. 关注 a-school 的 一年级 的 一班,怎么办?声明一个队列并绑定到交换器上:channel.queueBind(queueName,topic_school, "a-school.一年级.一班");

image.png

2、消息发布时的权衡

生产者——消息发布时的权衡

在 RabbitMQ 在设计的时候,特意让生产者和消费者“脱钩”,也就是消息的发布和消息的消费之间是解耦的。

在 RabbitMQ 中,有不同的投递机制(生产者),但是每一种机制都对性能有一定的影响。一般来讲速度快的可靠性低,可靠性好的性能差,具体怎么使用需要根据应用程序来定,所以说没有最好的方式,只有最合适的方式。只有把你的项目和技术相结合,才能找到适合你的平衡。

image.png

在 RabbitMQ 中实际项目中,生产者和消费者都是客户端,它们都可以完成申明交换器、申明队列和绑定关系,但是在我们的实战过程中,我们在生产者代码中申明交换器,在消费者代码中申明队列和绑定关系。

另外还要申明的就是,生产者发布消息时不一定非得需要消费者,对于 RabbitMQ 来说,如果是单纯的生产者你只需要生产者客户端、申明交换器、申明队列、确定绑定关系,数据就能从生产者发送至 RabbitMQ。只是为了演示的方便,我们在例子中使用消费者消费队列中的数据来方便展示结果。

无保障

在演示各种交换器中使用的就是无保障的方式,通过 basicPublish 发布你的消息并使用正确的交换器和路由信息,你的消息会被接收并发送到合适的队列中。但是如果有网络问题,或者消息不可路由,或者 RabbitMQ 自身有问题的话,这种方式就有风险。所以无保证的消息发送一般情况下不推荐。

失败确认

在发送消息时设置 mandatory 标志,告诉 RabbitMQ,如果消息不可路由,应该将消息返回给发送者,并通知失败。可以这样认为,开启 mandatory 是开启故障检测模式。注意:它只会让 RabbitMQ 向你通知失败,而不会通知成功。如果消息正确路由到队列,则发布者不会受到任何通知。带来的问题是无法确保发布消息一定是成功的,因为通知失败的消息可能会丢失。

channel.addConfirmListener 则用来监听 RabbitMQ 发回的信息。

channel.addReturnListener(new ReturnListener() {
    public void handleReturn(int replycode, String replyText, String exchange, String routeKey, AMQP.BasicProperties basicProperties, byte[] bytes) throws IOException {
        String message = new String(bytes);
        System.out.println("返回的replycode:"+replycode);
        System.out.println("返回的replyText:"+replyText);
        System.out.println("返回的exchange:"+exchange);
        System.out.println("返回的routeKey:"+routeKey);
    }
});

在信道关闭和连接关闭时,还有两个监听器可以使用:

image.png

事务

事务的实现主要是对信道(Channel)的设置,主要的方法有三个:

  1. channel.txSelect()声明启动事务模式;
  2. channel.txComment()提交事务;
  3. channel.txRollback()回滚事务;

在发送消息之前,需要声明 channel 为事务模式,提交或者回滚事务即可。

开启事务后,客户端和 RabbitMQ 之间的通讯交互流程:

  • 客户端发送给服务器 Tx.Select(开启事务模式)
  • 服务器端返回 Tx.Select-Ok(开启事务模式 ok)
  • 推送消息客户端发送给事务提交 Tx.Co
  • 服务器端返回 Tx.Commit-Ok

以上就完成了事务的交互流程,如果其中任意一个环节出现问题,就会抛出 IoException,这样用户就可以拦截异常进行事务回滚,或决定要不要重复消息

那么,既然已经有事务了,为何还要使用发送方确认模式呢,原因是因为事务的性能是非常差的。根据相关资料,事务会降低 2~10 倍的性能。

事务使用示例:

//加入事务
channel.txSelect();
try {
    for(int i=0;i<3;i++){
        String routekey = routekeys[i%3];
        // 发送的消息
        String message = "Hello World_"+(i+1)  +("_"+System.currentTimeMillis());
        channel.basicPublish(EXCHANGE_NAME, routekey, true,   null, message.getBytes());
        System.out.println("----------------------------------");
        System.out.println(" Sent Message: [" + routekey +"]:'"  + message + "'");
        Thread.sleep(200);
    }
    //事务提交
    channel.txCommit();
} catch (IOException e) {
    e.printStackTrace();
    //事务回滚
    channel.txRollback();
} catch (InterruptedException e) {
    e.printStackTrace();
}

发送方确认模式

基于事务的性能问题,RabbitMQ 团队为我们拿出了更好的方案,即采用发送方确认模式,该模式比事务更轻量,性能影响几乎可以忽略不计。

原理:生产者将信道设置成 confirm 模式,一旦信道进入 confirm 模式,所有在该信道上面发布的消息都将会被指派一个唯一的 ID(从 1 开始),由这个 id 在生产者和 RabbitMQ 之间进行消息的确认。

不可路由的消息,当交换器发现,消息不能路由到任何队列,会进行确认操作,表示收到了消息。如果发送方设置了 mandatory 模式,则会先调用 addReturnListener 监听器。

可路由的消息,要等到消息被投递到所有匹配的队列之后,broker 会发送一个确认给生产者(包含消息的唯一 ID),这就使得生产者知道消息已经正确到达目的队列了,如果消息和队列是可持久化的,那么确认消息会在将消息写入磁盘之后发出,broker 回传给生产者的确认消息中 delivery-tag 域包含了确认消息的序列号。

image.png

confirm 模式最大的好处在于他可以是异步的,一旦发布一条消息,生产者应用程序就可以在等信道返回确认的同时继续发送下一条消息,当消息最终得到确认之后,生产者应用便可以通过回调方法来处理该确认消息,如果 RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条 nack 消息,生产者应用程序同样可以在回调方法中处理该 nack 消息决定下一步的处理。

Confirm 的三种实现方式:

  • 方式一:channel.waitForConfirms()普通发送方确认模式;消息到达交换器,就会返回 true。
// 启用发送者确认模式
channel.confirmSelect();

//所有日志严重性级别
for(int i = 0;i < 2;i++){
    // 发送的消息
    String message = "Hello World_"+(i+1);
    //参数1:exchange name,参数2:routing key
    channel.basicPublish(EXCHANGE_NAME, ROUTE_KEY, true,null, message.getBytes());
    System.out.println(" Sent Message: [" + ROUTE_KEY +"]:'"+ message + "'");
    //确认是否成功(true成功)
    if(channel.waitForConfirms()){
        System.out.println("send success");
    }else{
        System.out.println("send failure");
    }
}
  • 方式二:channel.waitForConfirmsOrDie()批量确认模式;使用同步方式等所有的消息发送之后才会执行后面代码,只要有一个消息未到达交换器就会抛出 IOException 异常。
// 添加失败通知监听器
channel.addReturnListener(new ReturnListener() {
    public void handleReturn(int replyCode, String replyText,
                             String exchange, String routingKey,
                             AMQP.BasicProperties properties,
                             byte[] body)
            throws IOException {
        String message = new String(body);
        System.out.println("RabbitMq返回的replyCode:  "+replyCode);
        System.out.println("RabbitMq返回的replyText:  "+replyText);
        System.out.println("RabbitMq返回的exchange:  "+exchange);
        System.out.println("RabbitMq返回的routingKey:  "+routingKey);
        System.out.println("RabbitMq返回的message:  "+message);
    }
});
// 启用发送者确认模式
channel.confirmSelect();

//所有日志严重性级别
for(int i=0;i<10;i++){
    // 发送的消息
    String message = "Hello World_"+(i+1);
    //参数1:exchange name
    //参数2:routing key
    channel.basicPublish(EXCHANGE_NAME, ROUTE_KEY, true,null, message.getBytes());
    System.out.println(" Sent Message: [" + ROUTE_KEY +"]:'"+ message + "'");
}
// 启用发送者确认模式(批量确认)
channel.waitForConfirmsOrDie();
  • 方式三:channel.addConfirmListener()异步监听发送方确认模式;
// 启用发送者确认模式
channel.confirmSelect();

// 添加发送者确认监听器
channel.addConfirmListener(new ConfirmListener() {
    // 成功
    public void handleAck(long deliveryTag, boolean multiple)  throws IOException {
        System.out.println("send_ACK:"+deliveryTag+",multiple:"+multiple);
    }
    //失败
    public void handleNack(long deliveryTag, boolean multiple)  throws IOException {
        System.out.println("Error----send_NACK:"+deliveryTag+",multiple:"+multiple);
    }
});

// 添加失败者通知
channel.addReturnListener(new ReturnListener() {
    public void handleReturn(int replyCode, String replyText,
                             String exchange, String routingKey,
                             AMQP.BasicProperties properties,
                             byte[] body)  throws IOException {
        String message = new String(body);
        System.out.println("RabbitMq路由失败:  "+routingKey+"."+message);
    }
});

备用交换器

在第一次声明交换器时被指定,用来提供一种预先存在的交换器,如果主交换器无法路由消息,那么消息将被路由到这个新的备用交换器。

如果发布消息时同时设置了 mandatory 会发生什么?如果主交换器无法路由消息,RabbitMQ 并不会通知发布者,因为,向备用交换器发送消息,表示消息已经被路由了。注意,新的备用交换器就是普通的交换器,没有任何特殊的地方。

使用备用交换器,向往常一样,声明 Queue 和备用交换器,把 Queue 绑定到备用交换器上。然后在声明主交换器时,通过交换器的参数, alternate-exchange,,将备用交换器设置给主交换器。

建议备用交换器设置为 faout 类型,Queue 绑定时的路由键设置为“#”

// 声明备用交换器
Map<String,Object> argsMap = new HashMap<String,Object>();
argsMap.put("alternate-exchange",BAK_EXCHANGE_NAME);
//主交换器
channel.exchangeDeclare(EXCHANGE_NAME,"direct", false,false,argsMap);
//备用交换器
channel.exchangeDeclare(BAK_EXCHANGE_NAME,BuiltinExchangeType.FANOUT, true,false,null);

总结:

image.png

3、消息的消费

可靠性和性能的权衡

image.png

消息的获得方式

拉取 Get

属于一种轮询模型,发送一次 get 请求,获得一个消息。如果此时 RabbitMQ 中没有消息,会获得一个表示空的回复。总的来说,这种方式性能比较差,很明显,每获得一条消息,都要和 RabbitMQ 进行网络通信发出请求。而且对 RabbitMQ 来说,RabbitMQ 无法进行任何优化,因为它永远不知道应用程序何时会发出请求。

// 无限循环拉取
while(true){
    // 下面传参为true,表示自动确认,然后消息从队列中删除
    GetResponse getResponse = channel.basicGet(queueName, true);
    if(null != getResponse){
        System.out.println("received["  +getResponse.getEnvelope().getRoutingKey()+"]" + new String(getResponse.getBody()));
    }
    Thread.sleep(1000);
}
// 无限循环拉取
while(true){
    //传参为false,表示手动确认
    GetResponse getResponse = channel.basicGet(queueName, false);
    if(null != getResponse){
        System.out.println("received["  +getResponse.getEnvelope().getRoutingKey()+"]" + new String(getResponse.getBody()));
    }
    //进行手动确认,0代表从第一条开始,true代表是不是批量
    channel.basicAck(0,true);
    Thread.sleep(1000);
}

对我们实现者来说,要在一个循环里,不断去服务器 get 消息。

推送 Consume

属于一种推送模型。注册一个消费者后,RabbitMQ 会在消息可用时,自动将消息进行推送给消费者。这种模式我们已经使用过很多次了,具体使用,可以看上面 Direct交换器节点

消息的应答

前面说过,消费者收到的每一条消息都必须进行确认。消息确认后,RabbitMQ 才会从队列删除这条消息,RabbitMQ 不会为未确认的消息设置超时时间,它判断此消息是否需要重新投递给消费者的唯一依据是消费该消息的消费者连接是否已经断开。这么设计的原因是 RabbitMQ 允许消费者消费一条消息的时间可以很久很久。

自动确认

消费者在声明队列时,可以指定 autoAck 参数,当 autoAck=true 时,一旦消费者接收到了消息,就视为自动确认了消息。如果消费者在处理消息的过程中,出了错,就没有什么办法重新处理这条消息,所以我们很多时候,需要在消息处理成功后,再确认消息,这就需要手动确认。

手动确认

当 autoAck=false 时,RabbitMQ 会等待消费者显式发回 ack 信号后才从内存(和磁盘,如果是持久化消息的话)中移去消息。否则,RabbitMQ 会在队列中消息被消费后立即删除它。

采用消息确认机制后,只要令 autoAck=false,消费者就有足够的时间处理消息(任务),不用担心处理消息过程中消费者进程挂掉后消息丢失的问题,因为 RabbitMQ 会一直持有消息直到消费者显式调用 basicAck 为止。

当 autoAck=false 时,对于 RabbitMQ 服务器端而言,队列中的消息分成了两部分:一部分是等待投递给消费者的消息;一部分是已经投递给消费者,但是还没有收到消费者 ack 信号的消息。如果服务器端一直没有收到消费者的 ack 信号,并且消费此消息的消费者已经断开连接,则服务器端会安排该消息重新进入队列,等待投递给下一个消费者(也可能还是原来的那个消费者)。

通过运行程序,启动两个消费者 A、B,都可以收到消息,但是其中有一个消费者 A 不会对消息进行确认,当把这个消费者 A 关闭后,消费者 B 又会收到本来发送给消费者 A 的消息。所以我们一般使用手动确认的方法是,将消息的处理放在 try/catch 语句块中,成功处理了,就给 RabbitMQ 一个确认应答,如果处理异常了,就在 catch 中,进行消息的拒绝。

QoS 预取模式

在确认消息被接收之前,消费者可以预先要求接收一定数量的消息,在处理完一定数量的消息后,批量进行确认。如果消费者应用程序在确认消息之前崩溃,则所有未确认的消息将被重新发送给其他消费者。所以这里存在着一定程度上的可靠性风险。

这种机制一方面可以实现限速(将消息暂存到 RabbitMQ 内存中)的作用,一方面可以保证消息确认质量(比如确认了但是处理有异常的情况)。

注意:消费确认模式必须是非自动 ACK 机制(这个是使用 baseQos 的前提条件,否则会 Qos 不生效),然后设置 basicQos 的值;另外,还可以基于 consume 和 channel 的粒度进行设置(global)。

生产者:

// --------生产者发送非常多的数据------------
//发送210条消息,其中第210条消息表示本批次消息的结束
for(int i=0;i<210;i++){
    // 发送的消息
    String message = "Hello World_"+(i+1);
    if(i==209){ //最后一条
        message = "stop";
    }
    //参数1:exchange name,参数2:routing key
    channel.basicPublish(EXCHANGE_NAME, "error",  null, message.getBytes());
    System.out.println(" [x] Sent 'error':'"   + message + "'");
}

QOS批量消费:

//------声明了一个消费者,进行单条确认-------
final Consumer consumer = new DefaultConsumer(channel){
    @Override
    public void handleDelivery(String consumerTag,
                               Envelope envelope,
                               AMQP.BasicProperties properties,
                               byte[] body) throws IOException {
        String message = new String(body, "UTF-8");
        System.out.println("Received["+envelope.getRoutingKey()   +"]"+message);
        // 进行单条确认
        channel.basicAck(envelope.getDeliveryTag(),false);
    }
};

// 150条预取(150都取出来 150, 210-150  60  )
channel.basicQos(150,true);
//消费者正式开始在指定队列上消费消息
channel.basicConsume(queueName,false,consumer);

自定义批量消费者:

// ------批量确认 -----消费者-------

public class BatchAckConsumer extends DefaultConsumer {
    //计数,第多少条
    private  int meesageCount = 0;
    public BatchAckConsumer(Channel channel) {
        super(channel);
        System.out.println("批量消费者启动了......");
    }

    @Override
    public void handleDelivery(String consumerTag,
                               Envelope envelope,
                               AMQP.BasicProperties properties,
                               byte[] body) throws IOException {
        //把消息体拿出来
        String message = new String(body,"UTF-8");

        System.out.println("批量消费者---Received["+envelope.getRoutingKey()    +"]"+message);
        meesageCount++;
        //批量确认 50一批
        if(meesageCount %50 ==0){
           this.getChannel().basicAck(envelope.getDeliveryTag(),true);
            System.out.println("批量消息费进行消息的确认------------");
        }
        if(message.equals("stop")){ //如果是最后一条消息,则把剩余的消息都进行确认
            this.getChannel().basicAck(envelope.getDeliveryTag(),true);
            System.out.println("批量消费者进行最后业务消息的确认---------");
        }
    }
}

使用批量消费者:

// 使用上面自定义的 批量消费者
public static void main(String[] argv)  throws IOException, TimeoutException {
    
    //该处应有rabbitmq相关连接,省略...

    System.out.println("waiting for message........");
    // 自定义消费者批量确认
    BatchAckConsumer batchAckConsumer = new BatchAckConsumer(channel);
    channel.basicConsume(queueName,false,batchAckConsumer);
}

如果,两个消费者(QOS ,自定义批量)同时存在,则轮询获取数据。 QOS相当于优化版的批量。

basicQos 方法参数详细解释:

prefetchSize:最多传输的内容的大小的限制,0 为不限制,但据说 prefetchSize 参数,rabbitmq 没有实现。

prefetchCount:会告诉 RabbitMQ 不要同时给一个消费者推送多于 N 个消息,即一旦有 N 个消息还没有 ack,则该 consumer 将 block 掉,直到有消息

ack global:true\false 是否将上面设置应用于 channel,简单点说,就是上面限制是 channel 级别的还是 consumer 级别。

如果同时设置 channel 和消费者,会怎么样?AMQP 规范没有解释如果使用不同的全局值多次调用 basic.qos 会发生什么。 RabbitMQ 将此解释为意味着两个预取限制应该彼此独立地强制执行; 消费者只有在未达到未确认消息限制时才会收到新消息。 channel.basicQos(10, false); // Per consumer limit channel.basicQos(15, true); // Per channel limit channel.basicConsume("my-queue1", false, consumer1); channel.basicConsume("my-queue2", false, consumer2); 也就是说,整个通道加起来最多允许 15 条未确认的消息,每个消费者则最多有 10 条消息。

消费者中的事务

使用方法和生产者一致 假设消费者模式中使用了事务,并且在消息确认之后进行了事务回滚,会是什么样的结果? 结果分为两种情况:

  1. autoAck=false 手动应对的时候是支持事务的,也就是说即使你已经手动确认了消息已经收到了,但 RabbitMQ 对消息的确认会等事务的返回结果,再做最终决定是确认消息还是重新放回队列,如果你手动确认之后,又回滚了事务,那么以事务回滚为准,此条消息会重新放回队列;
  2. autoAck=true 如果自动确认为 true 的情况是不支持事务的,也就是说你即使在收到消息之后在回滚事务也是于事无补的,队列已经把消息移除了

4、消息的拒绝

Reject 和 Nack

消息确认可以让 RabbitMQ 知道消费者已经接受并处理完消息。但是如果消息本身或者消息的处理过程出现问题怎么办?需要一种机制,通知 RabbitMQ,这个消息,我无法处理,请让别的消费者处理。这里就有两种机制,Reject 和 Nack。

Reject 在拒绝消息时,可以使用 requeue 标识,告诉 RabbitMQ 是否需要重新发送给别的消费者。如果是 false 则不重新发送,一般这个消息就会被 RabbitMQ 丢弃。Reject 一次只能拒绝一条消息。如果是 true 则消息发生了重新投递。

Nack 跟 Reject 类似,只是它可以一次性拒绝多个消息。也可以使用 requeue 标识,这是 RabbitMQ 对 AMQP 规范的一个扩展。

/*声明了一个消费者*/
final Consumer consumer = new DefaultConsumer(channel){
    @Override
    public void handleDelivery(String consumerTag,
                               Envelope envelope,
                               AMQP.BasicProperties properties,
                               byte[] body) throws IOException {
        try{
            String message = new String(body, "UTF-8");
            System.out.println("Received[" +envelope.getRoutingKey() +"]"+message);
            throw new RuntimeException("处理异常"+message);
        }catch (Exception e){
            e.printStackTrace();
            // Reject方式拒绝(这里第2个参数决定是否重新投递)
            //channel.basicReject(envelope.getDeliveryTag(),true);

            // Nack方式的拒绝(第2个参数决定是否批量)
            channel.basicNack(envelope.getDeliveryTag(), false, false);
        }
    }
};
/*消费者正式开始在指定队列上消费消息*/
channel.basicConsume(queueName,false,consumer);

执行 代码示例 可以看到无论是使用 Reject 方式还是 Nack 方式,当 requeue 参数设置为 true 时,消息发生了重新投递。当 requeue 参数设置为 false 时,消息丢失了。

requeue=true

消息队列中有 10 条消息,有三个消费者,有两个消费可以正常消费消息,有一个消费进行消息的拒绝,同时设置 requeue 参数设置为 true,我们来看下具体的过程。

  1. 三个消费者订阅一个队列,消息使用轮询的方式进行发送

  2. 有一个消费者拒绝消息,同时 requeue 参数设置为 true,消息准备进行重新投

  3. 再使用消息轮询的方式,把三条消息方便发送至三个消费者,其中又会发生一次消息拒绝和消息的重新投递。

死信交换器 DLX

前面我们看到,如果使用消息拒绝机制,同时 requeue 参数设置为 false 时,消息丢失了,这点作为程序员我们不能忍。所以 RabbitMQ 作为一个高级消息中间件,提出了死信交换器的概念,死信,意思就是死了的信息。这种交换器专门处理死了的信息(被拒绝可以重新投递的信息不能算死的)。

死信交换器是 RabbitMQ 对 AMQP 规范的一个扩展,往往用在对问题消息的诊断上(主要针对消费者),还有延时队列的功能。

image.png

消息变成死信一般是以下三种情况:

  • 消息被拒绝,并且设置 requeue 参数为 false
  • 消息过期(默认情况下 Rabbitmq 中的消息不过期,但是可以设置队列的过期时间和消息的过期时间以达到消息过期的效果)
  • 队列达到最大长度(一般当设置了最大队列长度或大小并达到最大值时),最老的那条(最先进去的)会变成死信消息。

死信交换器仍然只是一个普通的交换器,创建时并没有特别要求和操作。在创建队列的时候,声明该交换器将用作保存被拒绝的消息即可,相关的参数是 x-dead-letter-exchange。

// ---- WillMakeDlxConsumer ---
// 绑定死信交换器
/*声明一个队列,并绑定死信交换器*/
String queueName = "dlx_make";
Map<String,Object> args = new HashMap<String,Object>();
args.put("x-dead-letter-exchange", DlxProcessConsumer.DLX_EXCHANGE_NAME);
// 死信路由键,会替换消息原来的路由键
//args.put("x-dead-letter-routing-key", "deal");
channel.queueDeclare(queueName,false,true, false, args);

/*绑定,将队列和交换器通过路由键进行绑定*/
channel.queueBind(queueName, DlxProducer.EXCHANGE_NAME,"#");

System.out.println("waiting for message........");

/*声明了一个消费者*/
final Consumer consumer = new DefaultConsumer(channel){
    @Override
    public void handleDelivery(String consumerTag,
                               Envelope envelope,
                               AMQP.BasicProperties properties,
                               byte[] body) throws IOException {
        String message = new String(body, "UTF-8");
      
        // 如果是 a-school 的消息确认
        if(envelope.getRoutingKey().equals("a-school")){
            System.out.println("Received[" +envelope.getRoutingKey()  +"]"+message);
            channel.basicAck(envelope.getDeliveryTag(),false);
        }else{
            // 如果是其他的消息拒绝(queue=false),成为死信消息
            System.out.println("Will reject["   +envelope.getRoutingKey() +"]"+message);
            channel.basicReject(envelope.getDeliveryTag(),false);
        }
    }
};
/*消费者正式开始在指定队列上消费消息*/
channel.basicConsume(queueName,false,consumer);

执行代码示例,生产者 DlxProducer(类似Direct节点的生产者) 产生了 3 条消息,分别是 a-school,b-schoole,c-school,消费者 WillMakeDlxConsumer 都拒绝了两条消息(b-schoole 和 c-schoole), 同时设置 requeue 参数为 false,这样这两条消息会被作为死信消息,同时在主交换器中,绑定对应的死信交换器,这样死信消息会通过死信交换器投递到对应绑定的死信队列上(dlx_accept), 这样 DlxProcessConsumer 消费者(类似Direct节点的消费者)就能看到消息的消费,同时这样死信消息还是保持原有的路由键。

如果是我们还想做点其他事情,我们可以在死信交换的时候改变死信消息的路由键,具体的相关的参数是 x-dead-letter-routing-key

和备用交换器的区别

1、备用交换器是主交换器无法路由消息,那么消息将被路由到这个新的备用交换器,而死信交换器则是接收过期或者被拒绝的消息。 2、备用交换器是在声明主交换器时发生联系,而死信交换器则声明队列时发生联系。场景分析:备用交换器一般是用于生产者生产消息时,确保消息可以尽量进入 RabbitMQ,而死信交换器主要是用于消费者消费消息的万不一失性的场景(比如消息过期,队列满了,消息拒绝且不重新投递)

5、控制队列

前面讲了比较多的消费者,RabbitMQ 中消费行为主要跟队列有直接关系,那么我们接下来深入的分析队列。

临时队列

临时队列对应的是没有持久化的队列,也就是如果 RabbitMQ 服务器重启,那么这些队列就不会存在,所以我们称之为临时队列。

自动删除队列

自动删除队列和普通队列在使用上没有什么区别,唯一的区别是,当消费者断开连接时,队列将会被删除。自动删除队列关联的消费者没有限制,也就是说当这个队列上最后一个消费者断开连接才会执行删除。

image.png

自动删除队列只需要在声明队列时,设置属性 auto-delete 标识为 true 即可。系统声明的随机队列,缺省就是自动删除的。

单消费者队列

普通队列关联的消费者没有限制,多个消费者绑定到多个队列时,RabbitMQ 会采用轮询进行投递。如果需要消费者独占队列,在队列创建的时候, 设定属性 exclusive 为 true。

image.png

自动过期队列

指队列在超过一定时间没使用,会被从 RabbitMQ 中被删除。

什么是没使用?1.一定时间内没有 Get 操作发生。2.没有 Consumer 连接在队列上。

特别的:就算一直有消息进入队列,也不算队列在被使用。

通过声明队列时,设定 x-expires 参数即可,单位毫秒。

比如这个队列会在当消费者断开连接时 10 秒后过期。

image.png

永久队列

队列的持久性

持久化队列和非持久化队列的区别是,持久化队列会被保存在磁盘中,固定并持久的存储,当 Rabbit 服务重启后,该队列会保持原来的状态在 RabbitMQ 中被管理,而非持久化队列不会被保存在磁盘中,Rabbit 服务重启后队列就会消失

非持久化比持久化的优势就是,由于非持久化不需要保存在磁盘中,所以使用速度就比持久化队列快。即是非持久化的性能要高于持久化。而持久化的优点就是会一直存在,不会随服务的重启或服务器的宕机而消失。

在声明队列时,将属性 durable 设置为“false”,则该队列为非持久化队列,设置成“true”时,该队列就为持久化队列。

image.png

队列级别消息过期

就是为每个队列设置消息的超时时间。只要给队列设置 x-message-ttl 参数,就设定了该队列所有消息的存活时间,时间单位是毫秒。如果声明队列 时指定了死信交换器,则过期消息会成为死信消息。

image.png

队列保留参数列表
参数名目的
x-dead-letter-exchange死信交换器
x-dead-letter-routing-key死信消息的可选路由键
x-expires队列在指定毫秒数后被删除
x-ha-policy创建 HA 队列
x-ha-nodes HA队列的分布节点
x-max-length队列的最大消息数,对队列中消息的条数进行限制
x-message-ttl毫秒为单位的消息过期时间,队列级别
x-max-priority最大优先值为255
x-max-length-bytes对队列中消息的总量进行限制

6、消息的属性

消息标准化可以减少开发人员重复使用的痛苦,所以 RabbitMQ 对消息进行了如下标准化:

按照 AMQP 的协议单个最大的消息大小为 16EB(2 的 64 次方),但是 RabbitMQ 将消息大小限定为 2GB(2的31次方)。

image.png

image.png

消息存活时间

当队列消息的 TTL 和消息 TTL 都被设置,时间短的 TTL 设置生效。

如果将一个过期消息发送给 RabbitMQ,该消息不会路由到任何队列,而是直接丢弃。

为消息设置 TTL 有一个问题:RabbitMQ 只对处于队头的消息判断是否过期(即不会扫描队列),所以,很可能队列中已存在死消息,但是队列并不知情。这会影响队列统计数据的正确性,妨碍队列及时释放资源

消息的持久化

默认情况下,队列和交换器在服务器重启后都会消失,消息当然也是。将队列和交换器的 durable 属性设为 true,缺省为 false,但是消息要持久化还不够,还需要将消息在发布前,将投递模式设置为 2。消息要持久化,必须要有持久化的队列、交换器和投递模式都为 2。

应用程序的类型和版本号

image.png

Request-Response 模式

我们前面的学习模式中都是一方负责发送消息而另外一方负责处理。而我们实际中的很多应用相当于一种一应一答的过程,需要双方都能给对方发送消息。于是请求-应答的这种通信方式也很重要。它也应用的很普遍。

image.png

生产者:

public class ReplyToProducer {

    public final static String EXCHANGE_NAME = "replyto";

    public static void main(String[] args)  throws IOException, TimeoutException {
        /* 创建连接,连接到RabbitMQ*/
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setHost("127.0.0.1");
        Connection connection = connectionFactory.newConnection();

        /*创建信道*/
        Channel channel = connection.createChannel();
        /*创建持久化交换器*/
        channel.exchangeDeclare(EXCHANGE_NAME,"direct",false);

        //响应QueueName ,消费者将会把要返回的信息发送到该Queue
        String responseQueue = channel.queueDeclare().getQueue();
        //消息的唯一id
        String msgId = UUID.randomUUID().toString();
        AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
                .replyTo(responseQueue)
                .messageId(msgId)
                .build();

        /*声明了一个消费者*/
        final Consumer consumer = new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag,
                                       Envelope envelope,
                                       AMQP.BasicProperties properties,
                                       byte[] body) throws IOException {
                String message = new String(body, "UTF-8");
                System.out.println("Received["+envelope.getRoutingKey() +"]"+message);
            }
        };
        /*消费者正式开始在指定队列上消费消息*/
        channel.basicConsume(responseQueue,true,consumer);

        String msg = "Hellol,RabbitMq";
        channel.basicPublish(EXCHANGE_NAME,"error",properties,msg.getBytes());
        System.out.println("Sent error:"+msg);
    }
}

总结:

  • 消费者主要关注队列
  • 消费者一般使用推送
  • 死信交换器结合消息过期机制一般用在”限时订单“业务场景
  • 批量机制可以极大提升性能
  • 事务机制一般会被遗弃
  • 单队列一般用于顺序消息,但是也丧失了高性能消费者遇到异常不用慌,各种机制来护航,如重新投递机制,死信交换器机制等

参考资料: 享学课堂king老师