面试总结之 《消息队列》

一、什么是RabbitMQ、以及为什么要使用。

1. 什么是队列

队列是常用的数据结构之一,是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作。

2. 什么是消息队列

消息是计算机/应用间传送的数据单位,可以非常简单,例如只包含文本字符串,也可以很复杂,可能包含嵌入对象。

消息队列是在消息的传输过程中保存消息的容器。
消息传输时,先发送到队列,队列的主要目的是提供路由并保证消息的传递,如果发送消息时接收者不可用,消息队列会保留消息,直到可以成功的传递它。

3. 什么是RabbitMQ

RabbitMQ是用Erlang语言开发的基于高级消息队列协议(AMQP)的消息队列中间件。

4. 为什么要使用

异步处理 :相比于传统的串行、并行方式,提高了系统吞吐量。

应用解耦 :系统间通过消息通信,不用关心其他系统的处理。

流量削锋 :可以通过消息队列长度控制请求量;可以缓解短时间内的高并发请求。

二、RabbitMQ中的组件

1. 组件

Broker: 简单来说就是消息队列服务器实体

Exchange: 消息交换机,它指定消息按什么规则,路由到哪个队列

Queue: 消息队列载体,每个消息都会被投入到一个或多个队列

Binding Key: 绑定,它的作用就是把exchange和queue按照路由规则绑定起来

Routing Key: 路由关键字,exchange根据这个关键字进行消息投递。

VHost: vhost 可以理解为虚拟 broker ,即 mini-RabbitMQ server。其内部均含有独立的 queue、exchange 和 binding 等,但最最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段(一个典型的例子就是不同的应用可以跑在不同的 vhost 中)。

Producer: 消息生产者,就是投递消息的程序

Consumer: 消息消费者,就是接受消息的程序

Channel: 消息推送使用的通道,在客户端的每个连接里,可建立多个channel,每个channel代表一个会话任务。

由Exchange、Queue、RoutingKey三个才能决定一个从Exchange到Queue的唯一的线路。

2. rabbitmq 的消息是怎么发送的

首先客户端必须连接到 RabbitMQ 服务器才能发布和消费消息,客户端和 rabbit server 之间会创建一个 tcp 连接,一旦 tcp 打开并通过了认证(认证就是你发送给 rabbit 服务器的用户名和密码),你的客户端和 RabbitMQ 就创建了一条 amqp 信道(channel),信道是创建在“真实” tcp 上的虚拟连接,amqp 命令都是通过信道发送出去的,每个信道都会有一个唯一的 id,不论是发布消息,订阅队列都是通过这个信道完成的。

消息如何分发?

若该队列至少有一个消费者订阅,消息将以循环(round-robin)的方式发送给消费者。每条消息只会分发给一个订阅的消费者(前提是消费者能够正常处理消息并进行确认)。通过路由可实现多消费的功能

消息怎么路由?

消息提供方->路由->一至多个队列消息发布到交换器时,消息将拥有一个路由键(routing key),在消息创建时设定。通过队列路由键,可以把队列绑定到交换器上。消息到达交换器后,RabbitMQ 会将消息的路由键与队列的路由键进行匹配(针对不同的交换器有不同的路由规则);

常用的交换器主要分为一下三种:

fanout:如果交换器收到消息,将会广播到所有绑定的队列上

direct:如果路由键完全匹配,消息就被投递到相应的队列

topic:可以使来自不同源头的消息能够到达同一个队列。使用 topic 交换器时,可以使用通配符

四、RabbitMQ的工作模式

1. simple模式(即最简单的收发模式)

在这里插入图片描述

  1. 消息产生消息,将消息放入队列

  2. 消息的消费者(consumer) 监听 消息队列,如果队列中有消息,就消费掉,消息被拿走后,自动从队列中删除(隐患 消息可能没有被消费者正确处理,已经从队列中消失了,造成消息的丢失,这里可以设置成手动的ack,但如果设置成手动ack,处理完后要及时发送ack消息给队列,否则会造成内存溢出)。

2. work工作模式(资源的竞争)

在这里插入图片描述

  1. 消息产生者将消息放入队列消费者可以有多个,消费者1,消费者2同时监听同一个队列,消息被消费。C1 C2共同争抢当前的消息队列内容,谁先拿到谁负责消费消息(隐患:高并发情况下,默认会产生某一个消息被多个消费者共同使用,可以设置一个开关(syncronize) 保证一条消息只能被一个消费者使用)。

3. publish/subscribe发布订阅(共享资源)

在这里插入图片描述

  1. 每个消费者监听自己的队列;

  2. 生产者将消息发给broker,由交换机将消息转发到绑定此交换机的每个队列,每个绑定交换机的队列都将接收到消息。

4. routing路由模式

在这里插入图片描述

  1. 消息生产者将消息发送给交换机按照路由判断,路由是字符串(info) 当前产生的消息携带路由字符(对象的方法),交换机根据路由的key,只能匹配上路由key对应的消息队列,对应的消费者才能消费消息;

  2. 根据业务功能定义路由字符串

  3. 从系统的代码逻辑中获取对应的功能字符串,将消息任务扔到对应的队列中。

  4. 业务场景:error 通知;EXCEPTION;错误通知的功能;传统意义的错误通知;客户通知;利用key路由,可以将程序中的错误封装成消息传入到消息队列中,开发者可以自定义消费者,实时接收错误;

5. topic 主题模式(路由模式的一种)

在这里插入图片描述

  1. 星号井号代表通配符

  2. 星号代表多个单词,井号代表一个单词

  3. 路由功能添加模糊匹配

  4. 消息产生者产生消息,把消息交给交换机

  5. 交换机根据key的规则模糊匹配到对应的队列,由队列的监听消费者接收消息消费

(在我的理解看来就是routing查询的一种模糊匹配,就类似sql的模糊查询方式)

五、如何保证消息99.99%被发送成功

1. 事务机制

RabblitMQ客户端中与事务机制相关的方法有以下3个:

  1. channel.txSelect:用于将当前的信道设置成事务模式
  2. channel.txCommit:用于提交事务
  3. channel.txRollback:用于回滚事务

我们可以对提交的消息进行事务控制,如果消息发送失败则对提交的事务进行回滚,然后通过重试机制尝试重新发送消息。

虽然事务能够解决消息发送方和RabbitMQ之间消息确认的问题,只有消息成功被RabbitMQ接收,事务才能提交成功,否则便可在捕获异常之后进行事务回滚。但是使用事务机制会“吸干”RabbitMQ的性能,因此建议使用下面讲到的发送方确认机制。

2. 发送方确认机制

发送方确认机制是指生产者将信道设置成confirm(确认)模式,一旦信道进入confirm模式,所有在该信道上面发布的消息都会被指派一个唯一的ID(从1开始),一旦消息被投递到RabbitMQ服务器之后,RabbitMQ就会发送一个确认**(Basic.Ack)给生产者(包含消息的唯一ID),**这就使得生产者知晓消息已经正确到达了目的地了。

如果RabbitMQ因为自身内部错误导致消息丢失,就会发送一条nack(Basic.Nack)命令,生产者应用程序同样可以在回调方法中处理该nack指令。

如果消息和队列是可持久化的,那么确认消息会在消息写入磁盘之后发出。

confirm又分为三种

1.普通confirm
channel.confirmSelect();将信道设置成confirm模式。
channel.waitForConfirms();等待发送消息的确认消息,如果发送成功,则返回ture,如果发送失败,则返回false。

2.批量confirm
批量confirm模式是每发送一批消息后,调用channel.waitForConfirms()方法,等待服务器的确认返回,因此相比于5.1中的普通confirm模式,性能更好。

但是不好的地方在于,如果出现返回Basic.Nack或者超时情况,生产者客户端需要将这一批次的消息全部重发,这样会带来明显的重复消息数量,如果消息经常丢失,批量confirm模式的性能应该是不升反降的。

3.异步confirm
异步confirm模式是在生产者客户端添加ConfirmListener回调接口,重写接口的handAck()和handNack()方法,分别用来处理RabblitMQ回传的Basic.Ack和Basic.Nack。

这两个方法都有两个参数,第1个参数deliveryTag用来标记消息的唯一序列号,第2个参数multiple表示的是是否为多条确认,值为true代表是多个确认,值为false代表是单个确认。

代码如下

 int batchCount = 100;
        long msgCount = 1;
        SortedSet<Long> confirmSet = new TreeSet<Long>();
        channel.confirmSelect();
        channel.addConfirmListener(new ConfirmListener() {
            @Override
            public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                System.out.println("Ack,SeqNo:" + deliveryTag + ",multiple:" + multiple);
                if (multiple) {
                    confirmSet.headSet(deliveryTag - 1).clear();
                } else {
                    confirmSet.remove(deliveryTag);
                }
            }

            @Override
            public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                System.out.println("Nack,SeqNo:" + deliveryTag + ",multiple:" + multiple);
                if (multiple) {
                    confirmSet.headSet(deliveryTag - 1).clear();
                } else {
                    confirmSet.remove(deliveryTag);
                }
                // 注意这里需要添加处理消息重发的场景
            }
        });
        // 演示发送100个消息
        while (msgCount <= batchCount) {
            long nextSeqNo = channel.getNextPublishSeqNo();
            channel.basicPublish(EXCHANGE_NAME, "", null, "async confirm test".getBytes());
            confirmSet.add(nextSeqNo);
            msgCount = nextSeqNo;
        }

六、如何保证RabbitMQ异常情况(人为重启、异常宕机等)下,队列和消息不丢失?

要解决该问题,就要用到RabbitMQ中持久化的概念,所谓持久化,就是RabbitMQ会将内存中的数据(Exchange 交换器,Queue 队列,Message 消息)固化到磁盘,以防异常情况发生时,数据丢失。
其中,RabbitMQ的持久化分为三个部分:

交换器(Exchange)的持久化
队列(Queue)的持久化
消息(Message)的持久化

1. 交换器(Exchange)的持久化

在RabbitMQ出现异常情况(重启,宕机)时,该Exchange会丢失,会影响后续的消息写入该Exchange,那么如何设置Exchange为持久化的呢?答案是设置durable参数。

durable:设置是否持久化。durable设置为true表示持久化,反之是非持久化。
持久化可以将交换器存盘,在服务器重启的时候不会丢失相关信息。

设置Exchange持久化:

channel.exchangeDeclare(EXCHANGE_NAME, "direct", true);

此时调用的重载方法为:

public DeclareOk exchangeDeclare(String exchange, String type, boolean durable) throws IOException {
    return this.exchangeDeclare(exchange, (String)type, durable, false, (Map)null);
}

2. 队列的持久化

虽然现在重启RabbitMQ服务后,Exchange不丢失了,但是队列和消息丢失了,那么如何解决队列不丢失呢?答案也是设置durable参数。

durable:设置是否持久化。为true则设置队列为持久化。
持久化的队列会存盘,在服务器重启的时候可以保证不丢失相关信息。

将durable参数设置为true:

channel.queueDeclare(QUEUE_NAME, true, false, false, null);

此时调用的重载方法如下:

public com.rabbitmq.client.impl.AMQImpl.Queue.DeclareOk queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments) throws IOException {
    validateQueueNameLength(queue);
    return (com.rabbitmq.client.impl.AMQImpl.Queue.DeclareOk)this.exnWrappingRpc((new com.rabbitmq.client.AMQP.Queue.Declare.Builder()).queue(queue).durable(durable).exclusive(exclusive).autoDelete(autoDelete).arguments(arguments).build()).getMethod();
}

3. 消息(Message)的持久化

虽然现在RabbitMQ重启后,Exchange和Queue都不丢失了,但是存储在Queue里的消息却仍然会丢失,那么如何保证消息不丢失呢?答案是设置消息的投递模式为2,即代表持久化。
修改发送消息的代码为:

// 发送消息
String message = "durable exchange test";
AMQP.BasicProperties props = newAMQP.BasicProperties().builder().deliveryMode(2).build();
channel.basicPublish(EXCHANGE_NAME, "", props, message.getBytes());

调用的重载方法为:

public void basicPublish(String exchange, String routingKey, BasicProperties props, byte[] body) throws IOException {
    this.basicPublish(exchange, routingKey, false, props, body);
}

注意事项

1)理论上可以将所有的消息都设置为持久化,但是这样会严重影响RabbitMQ的性能。因为写入磁盘的速度比写入内存的速度慢得不止一点点。对于可靠性不是那么高的消息可以不采用持久化处理以提高整体的吞吐量。在选择是否要将消息持久化时,需要在可靠性和吞吐量之间做一个权衡。

2)将交换器、队列、消息都设置了持久化之后仍然不能百分之百保证数据不丢失,因为当持久化的消息正确存入RabbitMQ之后,还需要一段时间(虽然很短,但是不可忽视)才能存入磁盘之中。如果在这段时间内RabbitMQ服务节点发生了宕机、重启等异常情况,消息还没来得及落盘,那么这些消息将会丢失。

3)单单只设置队列持久化,重启之后消息会丢失;单单只设置消息的持久化,重启之后队列消失,继而消息也丢失。单单设置消息持久化而不设置队列的持久化显得毫无意义。

七、如何保证队列里的消息99.99%被消费

当消费者接收到消息后,还没处理完业务逻辑,消费者挂掉了,那消息也算丢失了

为了保证消息被消费者成功的消费,RabbitMQ提供了消息确认机制(message acknowledgement)

1. 开启显式Ack模式

一个消费者的代码是这样的:

// 创建队列消费者
com.rabbitmq.client.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 Message '" + message + "'");
    }
};
channel.basicConsume(QUEUE_NAME, true, consumer);

这里的重点是
channel.basicConsume(QUEUE_NAME, true, consumer);方法的第2个参数,让我们先看下basicConsume()的源码:

public String basicConsume(String queue, boolean autoAck, Consumer callback) throws IOException {
    return this.basicConsume(queue, autoAck, "", callback);
}

这里的autoAck参数指的是是否自动确认,如果设置为ture,RabbitMQ会自动把发送出去的消息置为确认,然后从内存(或者磁盘)中删除,而不管消费者接收到消息是否处理成功;如果设置为false,RabbitMQ会等待消费者显式的回复确认信号后才会从内存(或者磁盘)中删除
建议将autoAck设置为false,这样消费者就有足够的时间处理消息,不用担心处理消息过程中消费者宕机造成消息丢失。
此时,队列里的消息就分成了2个部分:

等待投递给消费者的消息
已经投递给消费者,但是还没有收到消费者确认信号的消息

如果RabbitMQ一直没有收到消费者的确认信号,并且消费此消息的消费者已经断开连接,则RabbitMQ会安排该消息重新进入队列,等待投递给下一个消费者,当然也有可能还是原来的那个消费者。

RabbitMQ不会为未确认的消息设置过期时间,它判断此消息是否需要重新投递给消费者的唯一依据是消费该消息的消费者连接是否已经断开,这么设计的原因是RabbitMQ允许消费者消费一条消息的时间可以很久很久。

在代码中添加显式的Ack代码:

String message = new String(body, "UTF-8");
//int result = 1 / 0;
System.out.println("Received Message '" + message + "'");

long deliveryTag = envelope.getDeliveryTag();
channel.basicAck(deliveryTag, false);

deliveryTag可以看做消息的编号,它是一个64位的长整形值。
此时运行消费者客户端,发现消息消费成功,并且在队列中被移除:

八、如何保证消息的高可用

RabbitMQ 可以开启镜像集群模式,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。

如何开启这个镜像集群模式呢

其实很简单,RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。

这样的话,好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!第二,这么玩儿,不是分布式的,就没有扩展性可言了,如果某个 queue 负载很重,你加机器,新增的机器也包含了这个 queue 的所有数据,并没有办法线性扩展你的 queue。你想,如果这个 queue 的数据量很大,大到这个机器上的容量无法容纳了,此时该怎么办呢?

九、如何保证消息重复消费的问题(幂等性)

首先你的明白重复消费会出现什么问题,为什么要保证幂等性。举个例子:如果消费者干的事儿是拿一条数据就往数据库里写一条,你可能就把数据在数据库里插入了 2 次,那么数据就错了。其实重复消费不可怕,可怕的是你没考虑到重复消费之后,怎么保证幂等性。

解决:
每个消息加一个全局唯一的序号,根据序号判断这条消息是否处理过,然后再根据自己的业务场景进行处理。或更新或丢弃。

十、如何保证消息的顺序性

错乱场景
**RabbitMQ:**一个 queue,多个 consumer。比如,生产者向 RabbitMQ 里发送了三条数据,顺序依次是 data1/data2/data3,压入的是 RabbitMQ 的一个内存队列。有三个消费者分别从 MQ 中消费这三条数据中的一条,结果消费者2先执行完操作,把 data2 存入数据库,然后是 data1/data3。这不明显乱了。

解决方案
RabbitMQ: 拆分多个 queue,每个 queue 一个 consumer,就是多一些 queue 而已,确实是麻烦点; 或者就一个 queue 但是对应一个 consumer

参考文章

消息中间件MQ与RabbitMQ面试题(2020最新版)
RabbitMQ使用教程(三)如何保证消息99.99%被发送成功?

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值