【面试题】消息队列面试题总结(RocketMQ版)

发布时间 2023-11-07 22:20:59作者: shanml

自己整理、总结了一些消息队列相关面试题,并想了一些RocketMQ面试过程中可能会问的知识点。

使用消息队列的优点

  1. 系统解耦
    比如系统A产生的某个事件,系统B需要感知,简单实现就是在系统A产生事件之后,调用系统B的接口通知系统B,如果此时再增加一个系统C,还需要修改系统A的代码,再加入调用系统C接口的代码,这种做法违法了设计模式中的开闭原则(对扩展开放,对修改关闭)。
    如果使用消息队列的话,在系统A产生事件之后,将事件发送到消息队列中即可,其他系统如果需要获取这个事件,只需要订阅该消息队列即可。
  2. 流量削峰
    流量削峰想必大家都听说过,很常见的例子,比如双十一购物,瞬间有大量的请求,如果不借助一些中间件进行缓冲,直接请求到数据库,很有可能把数据库打垮,在使用了消息队列之后,可以将流量先分摊到消息队列中,目前常用的消息队列中基本都可以集群部署。
  3. 异步调用

当然引入消息队列也有缺点:

  1. 增加系统的复杂度:比如部署的时候还需要部署一套消息队列、需要考虑消息重复发送、重复消费等问题;
  2. 降低了系统的可用性:如果消息队列宕机或者其他原因导致消息发送失败等问题,会对系统的使用造成影响,降低系统可用性;

消息队列选型

目前业界常用的消息队列有Kafka、RocketMQ、RabbitMQ、ActiveMQ以及新兴的Pulsar。

Kafka:Kafka是一个开源的分布式的消息队列,最初由LinkedIn公司开发,之后贡献给Apache成为顶级开源项目。Kafka使用Scala语言编写,它的主要目标是提高系统的吞吐量、承担超大流量的业务,主打流式业务场景,并且非常稳定,所以在功能方面,相对于其他消息队列可能会少一些,比如不支持消息过滤、延迟消息等。
Pulsar:Pulsar在2017年由Yahoo开发,定位与Kafka类似,主打大吞吐量的流式计算,不过在功能方面,Pulsar会支持的更丰富一些,并且Pulsar是计算存储分离的架构,更适合云原生环境,所以Pulsar可以看做是将传统消息队列的功能与流式计算结合发展为目标的新一代消息队列系统,目前Pulsar发展时间相对较短,架构比较复杂,在稳定性上会差一些。
RabbitMQ:RabbitMQ由erlang语言开发的,是一款消息类的消息队列,它发展较早,功能比较丰富、稳定性高,社区也比较活跃,基本可以满足大部分业务场景,在国外一般会优先选择RabbitMQ。
RocketMQ:RocketMQ是最初由阿里开发后来移交给Apache的一个开源分布式消息队列,开发语言是Java,它在功能方面、性能方面、以及稳定性方面都比RabbitMQ好一些,支持延时下线、消息过滤等功能,而且开发语言是Java,便于阅读源码,所以在国内目前一般会首选RocketMQ。
RocketMQ 5.0以后为了适应云原生环境,也在逐步更改架构,往计算存储分离的架构上发展。
ActiveMQ:ActiveMQ较早的一款消息类型的消息队列,早些年使用的比较多,目前社区不活跃,已经越来越少选择ActiveMQ的了。

业务选型
日志收集处理、大数据流式业务场景首选Kafka,Pulsar目前还不稳定。

如何保证消息的顺序性

全局有序
如果使消息全局有序,以RocketMQ为例,可以为Topic设置一个消息队列,使用一个生产者单线程发送数据,消费者端也使用单线程进行消费,从而保证消息的全局有序,但是这种方式效率低下。

局部有序
消息队列可以保证投递到某一个消息队列(Partition/MessageQueue)中的消息是有序的,所以可以通过某个Key作为路由ID,将需要保证顺序的消息投递到同一个消息队列中,比如想保证某个订单的相关消息有序,那么就使用订单ID当做路由ID,在发送消息的时候,通过订单ID对消息队列的个数取余,根据取余结果选择消息队列,这样同一个订单的所有消息就可以发送到一个消息队列中,保证消息的局部有序。

RocketMQ顺序消息
RocketMQ中提供了顺序顺序消息的实现,生产端采用的是局部有序的方案。消费端采用加锁的方式保证顺序消费:

  1. 向Broker申请的消息队列锁
    集群模式下一个消息队列同一时刻只能被同一个消费组下的某一个消费者进行,为了避免负载均衡等原因引起的变动,消费者会向Broker发送请求对消息队列进行加锁,如果加锁成功,记录到消息队列对应的ProcessQueue中的locked变量中。

  2. 消息队列锁
    对应MessageQueue对应的Object对象锁,消费者在处理拉取到的消息时,由于可以开启多线程进行处理,所以处理消息前需要对MessageQueue加锁,锁住要处理的消息队列,主要是处理多线程之间的竞争,保证消息的顺序性。

  3. 消息消费锁
    对应ProcessQueue中的consumeLock,消费者在调用consumeMessage方法之前会加消费锁,主要是为了避免在消费消息时,由于负载均衡等原因,ProcessQueue被删除。

详细可参考【RocketMQ】顺序消息实现

如何保证消息不丢失

生产端
首先RocketMQ生产者发送消息有三种方式:
(1)同步进行消息发送,向Broker发送消息之后等待响应结果;
(2)异步进行消息发送,向Broker发送消息之后立刻返回,当消息发送完毕之后触发回调函数;
(3)sendOneway单向发送,也是异步消息发送,向Broker发送消息之后立刻返回,但是没有回调函数;
如果需要保证消息不丢失,可以选择同步发送或者异步发送,生产端发送消息可以分为两个过程:
(1)生产者从NameServer获取Topic路由信息,从中选取消息队列;
(2)生产者向选择的消息队列所在的那个Broker发送消息;
对于过程1,如果NameServer出现故障或者某些原因未能查询到路由信息,会向上抛出异常,所以在调用消息发送方法时可以对异常捕捉,进行处理。
对于过程2,生产者本身有消息重试机制,它会判断消息的发送结果并对一些异常进行捕捉,发送失败时进行重试,如果开启了故障延迟机制,某个Broker出现故障时还可以在一段时间内规避这个Broker,以此保证消息的可靠性传输。

Broker端
Broker端收到生产者发送的消息后,会将消息写入CommitLog文件,RocketMQ提供了两种方式进行写入:
(1)通过暂存池将数据写入缓冲区;
(2)通过mmap文件映射;
不过以上两种方式消息都会暂存在操作系统的PAGECACHE中,需要根据刷盘策略决定何时将内容刷入到硬盘中,RocketMQ有两种刷盘策略:
(1)同步刷盘:表示消息写入到内存之后需要立刻刷到磁盘文件中;
(2)异步刷盘:表示消息写入内存成功之后就返回,由MQ定时将数据刷入到磁盘中,会有一定的数据丢失风险;
所以如果需要保证消息不丢失,需要使用刷盘的策略。

对于Broker段,还可以通过主从模式部署的方式提高可用性,如果Master所在机器出现故障,从节点上也有从Master节点同步是数据,将congjied。

消费端
消费端在消费消息之后,会返回消费状态,有以下两种:
(1)CONSUME_SUCCESS:消息消费成功;
(2)RECONSUME_LATER:消息消费失败;
对于消费失败的消息,会记录消费失败的次数,如果大于规定的次数,会将其放入死信队列中,如果未达到最大的消费次数,会重新生成一条消息,使用重试主题(%RETRY% + 消费组名称),从中随机选取一个队列投递到重试队列中进行重试(不会直接放入重试队列,会先放入延迟队列中,到达时间后再放到重试队列中)。

消费者如何保证消费的幂等性

保证消费的幂等性也就是如何保证消息不被重复消费,以RocketMQ为例,首先来看下消息重复消费的原因。

生产端

  1. 客户端本身消息发送重复,比如说未做好异常处理,导致调用了两次消息发送的接口,重复发送消息;
  2. RocketMQ默认在生产者中添加了消息重试,如果消息发送失败会进行重试,假设第一次消息发送之后Broker已经收到了消息,但是由于网络等原因生产者未收到响应,认为消息发送失败,那么生产者就会重试,重新投递消息,就造成了重复发送;

消费端

  1. 消费者在进行消费完毕之后,会更新消费进度,但是集群模式下会先保存到内存中,之后由定时任务向Broker发送请求,更新到Broker,如果未发送前消费者宕机,重启后重新从Broker获取消费进度,会导致重复消费;

  2. Broker端收到消费的更新进度请求之后,也是先保存在内存中,由定时任务持久化,在持久化之前如果Broker宕机导致持久化失败,重启后如果刚好有消费者从Broker获取消费进度同样可能造成重复消费;

  3. RocketMQ需要通过负载均衡为每个消费者分配队列,这个过程是在每个消费者端执行的,在集群模式下,如果消费者1负责队列1和2,假设新增一个消费者2,此时把队列2分配给了消费者2,很有可能消费者1已经消费了部分数据,由于分配给消费者2之后,消费者1消费完成之后也不会更新进度,从而导致消费者2重复消费消息。

重复消费解决方案

  1. 可以为每条消息设置一个唯一ID,消费后保存这个ID的消费记录(可以使用Redis或者数据库),下次消费前根据这个ID查询之前是否已经消费过,不过这种方式效率低下,在并发消费情况下查询之时还需要加锁来保证线程安全性(分布式环境下并发消费可能需要加分布式锁)。
  2. 同样为每条消息设置一个唯一ID,只不过是利用数据库的唯一索引,消费之后,向数据库插入消费记录,由于是唯一索引,出现重复的数据的时候就会报错,然后对异常进行捕捉,进行错误处理。

如何处理消费堆积

如果生产者生产消息的速度大于消费者消费的速度,那么就有可能导致消费堆积。
在RocketMQ 5.0以前,是按消息队列的粒度进行分配的,可以根据情况,先增加消费者的数量,加大消费能力,比如某个消费者负责了两个消息队列,就可以再增加一个消费去分摊其中的一个消息队列。
如果消息队列已经不能再继续均摊,比如有4个消息队列和四个消费者,每个消费者负责一个,此时即便新增一个消费者也无法分配到队列进行消费,这种情况有一个临时的处理办法,再写一个程序从这个四个队列中消费数据,什么也不处理只是读出来再放入另外一个新的Topic中,在创建这个新的Topic的时候,将消息队列的数量设置大一些,这样就可以继续增加消费者来提高消费能力(在网上看到的方法)。

在RocketMQ 5.0以后,提供了消息粒度的分配方式,对于这种情况,只需要增加消费者的数量即可。

RocketMQ消费模式

首先RocketMQ消息的消费以组为单位,有两种消费模式:

广播模式:同一个消息队列可以分配给组内的每个消费者,每条消息可以被组内的消费者进行消费。
集群模式:同一个消费组下,一个消息队列同一时间只能分配给组内的一个消费者,也就是一条消息只能被组内的一个消费者进行消费。

在RocketMQ 5.0之前,消费者从Broker拉取消息的时候有两种方式,分别为Pull模式和Push模式:

Pull模式:消费需要不断的从阻塞队列中获取数据,如果没有数据就等待,这个阻塞队列中的数据由消息拉取线程从Broker拉取消息之后加入的,所以Pull模式下消费需要不断主动从Broker拉取消息。
Push模式:需要注册消息监听器,当有消息到达时会通过回调函数进行消息消费,从表面上看就像是Broker主动推送给消费者一样,所以叫做推模式,底层依旧是消费者从Broker拉取数据然后触发回调函数进行消息消费,只不过不需要像Pull模式一样不断判断是否有消息到来。

在RocketMQ 5.0增加了Pop模式消费,将负载均衡、消费位点管理等功能放到了Broker端,减少客户端的负担,并且支持消息粒度的负载均衡。

讲讲RocketMQ的负载均衡

负载均衡,是指为消费组下的每个消费者分配订阅主题下的消费队列,分配了消费队列消费者就可以知道去消费哪个消费队列上面的消息,这里针对集群模式,因为广播模式,所有的消息队列可以被消费组下的每个消费者消费不涉及负载均衡,而集群模式一个消息队列同一时间只能分配给组内的一个消费者进行消费。

RocketMQ 5.0以前是按照队列粒度进行负载均衡的,负载均衡在每个消费者端进行,它有以下缺点:
(1)队列粒度负载均衡策略分配粒度较大,不够灵活;
(2)在消费者数量、队列数量发生变化时,可能会导致重复消费;
(3)如果队列数量和消费者数量不均衡,可能会出现部分消费者空闲或者部分消费者分配到的消息队列过多的情况。

RocketMQ 5.0以后提供了按消息粒度进行负载均衡,并且将过程移动到了Broker端,可以更均匀的分摊消息。

几种策略
RocketMQ默认提供了以下几种分配策略:

  • AllocateMessageQueueAveragely:平均分配策略,根据消息队列的数量和消费者的个数计算每个消费者分配的队列个数。
  • AllocateMessageQueueAveragelyByCircle:平均轮询分配策略,将消息队列逐个分发给每个消费者。
  • AllocateMessageQueueConsistentHash:根据一致性 hash进行分配。
  • AllocateMessageQueueByConfig:根据配置,为每一个消费者配置固定的消息队列 。
  • AllocateMessageQueueByMachineRoom:分配指定机房下的消息队列给消费者。
  • AllocateMachineRoomNearby:优先分配给同机房的消费者。

Rebalance的触发时机
一、消费者启动时触发
消费者在启动时会进行一次负载均衡,为自己分配消息队列。

二、Broker发现消费组变更时触发
处于以下两种情况之一时会被判断为消费组发生了变化,需要进行负载均衡:

(1)某个消费组内有新的消费者向Broker进行了注册,比如某个消费组原来有两个消费者,现在新增了一个消费者,新增的消费者启动时会向Broker发送注册请求;

(2)消费组订阅的主题信息发生了变化,比如消费组新增订阅了某个主题或者取消某个主题的订阅,会被判断为主题订阅信息发生了变化;

被判定为变化之后,会触发变更事件,向该消费者下的所有消费者发送发送变更请求,通知组下每个消费者进行负载均衡。

三、Broker收到消费者下线时触发
如果有消费者向Broker发送UNREGISTER_CLIENT取消注册请求,并且开启了允许通知变更,会触发变更事件,变更事件同上,Broker会通知该消费者组下的所有消费者进行一次负载均衡。

四、消费者定时触发
消费者本身也会定时执行负载均衡,默认是20s执行一次;

RocketMQ数据清理机制

RocketMQ会注册定时任务,定时执行清理任务,删除过期文件(默认72小时),在清理任务执行时,会先判断是否达到了清理的条件,满足以下条件之一,开始执行清理:

  1. 已经到了清理文件的时间,默认是4点;
  2. 磁盘使用已经超过了设定的阈值;
  3. 人工触发删除;

RocketMQ事务实现原理

一、 生产者发送事务消息
生产者在发送事务消息的时候,会在消息属性中设置PROPERTY_TRANSACTION_PREPARED属性,然后向Broker发送消息。
Broker收到消息后,会判断消息是否含有PROPERTY_TRANSACTION_PREPARED属性,如果没有该属性,表示是普通消息,按照普通消息的写入流程执行即可,如果有该属性表示开启事务,还不能直接加入到实际的消息队列中,否则一旦加入就会被消费者消费,所以需要先对消息暂存,等收到消息提交请求时才可以添加到实际的消息队列中,RocketMQ设置了一个RMQ_SYS_TRANS_HALF_TOPIC主题来暂存事务消息,放入这个主题中的消息被称为half消息,之后会向生产者返回half消息的存储结果状态。

二、 执行本地事务
在上一步中,生产者向Broker发送了事务消息,生产者会根据Broker返回的half消息写入结果状态来判断消息是否写入成功:
(1)响应状态成功,开始执行本地事务,并返回本地事务执行结果状态,如果执行成功返回COMMIT_MESSAGE,执行失败返回ROLLBACK_MESSAGE;
(2)响应状态失败,意味着half消息发送失败,本地事务状态置为ROLLBACK_MESSAGE,准备回滚事务;

三、结束事务

经过了前两步骤之后,消息暂存在Broker的half主题中,也得到了本地事务的执行结果状态,接下来就需要根据本地事务的执行结果状态来决定回滚还是提交事务,生产者会向Broker发送这个结束事务的请求,Broker收到请求后会根据请求中设置的提交类型进行判断:
(1)如果是提交事务,会恢复消息原本的主题和队列,将消息投递到对应的队列中,然后将对应的half消息进行删除;
(2)如果回滚事务,将对应的half消息直接删除即可;
需要注意,这里的删除不会直接将消息从CommitLog删除,会将其放入RMQ_SYS_TRANS_OP_HALF_TOPIC(以下简称OP主题/队列),将已经删除的half消息记录在OP主题队列中。

事务状态回查(补偿机制)

由于各种原因Broker有可能未成功收到生产者发送结束(提交/回滚)事务的请求,所以需要定期检查half消息,检查事务的执行结果。在检查的时候会获取half主题(RMQ_SYS_TRANS_HALF_TOPIC)下的所有消息队列,遍历所有的half消息队列,对队列中的消息进行处理。

  • 如果half消息被删除,会放入到OP主题中,所以这里会判断消息是否在OP主题中,如果在表示消息已被删除,跳过这个half消息继续处理下一个即可。
  • 如果half消息未被删除,并且也未超时,会触发checkLocalTransaction方法进行状态检查,根据检查结果再向Broker发起结束事务的请求。

这里做了一些省略,详情可参考:【RocketMQ】事务实现原理

RocketMQ为什么不使用ZooKeeper而选择自己实现NameServer

为什么需要NameServer?

在使用RocketMQ的时候,为了提升性能以及应对高并发的情况,一般都会使用多个Broker进行集群部署,假设没有注册中心,对于Broker来说,如果想获取到集群中所有的Broker信息(生产者和消费者需要通过某个Broker获取整个集群的信息,从而得到Topic的分布情况),每个Broker都需要与其他Broker通信来交换信息,以此来得到集群内所有Broker的信息,在Broker数量比较大的情况下,会造成非常大的通信压力。

为什么不使用zookeeper这样的分布式协调组件?
首先zookeeper的实现复杂,引入zookeeper会增加系统的复杂度,并且zookeeper在CAP中选择了CP,也就是一致性和分区容错性,从而牺牲了可用性,为了保持数据的一致性会在一段时间内会不可用。

而NameServer在实现上简单,RocketMQ的设计者也许认为对于一个消息队列的注册中心来说,一致性与可用性相比,可用性更重要一些,至于一致性可以通过其他方式来解决。

假如选择了CP的ZooKeeper,先不考虑其他原因,在ZooKeeper不可用的时候,如果有消费者或生产者刚好需要从NameServer拉取信息,由于服务不可用,导致生产者和消费者无法进行消息的生产和发送,在高并发或者数据量比较大的情况下,大量的消息无法发送/无法消费影响是极大的,而如果选择AP,即便数据暂时处于不一致的状态,在心跳机制的作用下也可以保证数据的最终一致性,所以RocketMQ选择了自己实现注册中心,简单并且轻量,并且有一定的方案来保证数据的最终一致性以及消息发送的可靠性。

举个例子,假如集群中有三个Broker(分别为 A、B、C),向三台NameServer进行了注册(也分别为A、B、C),消费者从NameServer中获取到了三个Broker的信息,如果此时BrokerA需要停止服务,分别通知三台NameServer需要下线,从NameServer中剔除该Broker的信息,由于网络或者其他原因,NameServer A和B收到了下线的请求,NameServer C并未收到,此时就处于数据不一致的状态,如果某个消费者是与NameServer C进行通信,会认为Broker还处于可用的状态。

对于这种情况,首先NameServer与Broker之间会有一个心跳机制,NameServer定时检测在某个时间范围内是否收到了Broker发送的心跳请求,如果未收到,会认为该Broker不可用,将其剔除(在下面会讲到),所以对于NameServer来说,尽管数据会暂时处于不一致的状态,但是可以保证过一段时间之后恢复数据的一致性,也就是最终一致性。

对于生产者来说,既然可以从NameServer C中获取到Broker A的信息,那么生产者就认为Broker A可用,如果发送的消息所在的消息队列在Broker A中,就会与Broker A通信进行发送,但实际上Broker A实际上是不可用的,消息会发送失败,所以RocketMQ设计了消息重试机制(消息发送失败时重试)以及故障延迟机制(开启时,如果某个Broker不可用,会在一定时间内规避这个Broker)。

RocketMQ在消息存储方面的优化

(1)RocketMQ在写入数据到CommitLog时,采用的是顺序写的方式,顺序写比随机写文件效率要高很多。

(2)RocketMQ提供了一种暂存池的方式,会提前申请好内存,申请内存是一个比较重的操作,所以避免在消息写入时申请内存,以此提高效率,并且使用暂存池时,如果是异步刷盘,会积攒一批写入的消息一起刷盘,避免频繁的刷盘。

(3)RocketMQ也提供了mmap文件映射的方式写入消息(MappedByteBuffer),向CommitLog写入数据时可以减少数据的拷贝过程。

消息的存储详细过程可参考:【RocketMQ】消息的存储

关于零拷贝可参考:【Java】Java中的零拷贝

设计一个消息队列,都需要从哪方面进行考虑

  1. 架构方面,RocketMQ为了拥抱云原生,已经在往计算存储分离的方向调整架构,所以在架构方面需要考虑是否采用计算存储分离的架构。
  2. 存储方面
    (1)包括数据的存储格式、消息如何存储等;
    (2)数据的写入,由于数据最终要写入磁盘文件,防止数据丢失,所以数据如何写入到磁盘文件是核心,它会影响到消息队列的整体性能;
    (3)数据的删除策略,当磁盘空间不足时如何进行处理;
  3. 消息消费方面,比如支持什么样的消费模式,如何为消费者分配消息进行消费等;
  4. 集群方面
    (1)集群元数据如何存储,比如是否引入第三方组件Zookeeper等;
    (2)如何支持高可用,比如主从模式的部署方式,如果Master节点宕机,通过什么算法选出新的主节点;
    (3)主从节点之间的数据同步;
  5. 网络通信方面,比如选择什么样的协议进行底层网络通信;