参考:
en_oc:RocketMQ源码详解 | Consumer篇 · 其一:消息的 Pull 和 Push
官方文档:Apache RocketMQ
Kong Blog: RocketMQ——4. Consumer 消费消息
发送消息
RocketMQ中定义了如下三种消息通信的方式:
SYNC
:同步发送,生产端会阻塞等待发送结果;- 应用场景:这种方式应用场景非常广泛,如重要业务事件通知。
ASYNC
:异步发送,生产端调用发送API之后,立刻返回,在拿到Broker的响应结果后,触发对应的SendCallback回调;- 应用场景:一般用于链路耗时较长,对 RT 较为敏感的业务场景;
ONEWAY
:单向发送,发送方只负责发送消息,不等待服务器回应且没有回调函数触发,即只发送请求不等待应答。 此方式发送消息的过程耗时非常短,一般在微秒级别;- 应用场景:适用于耗时非常短,对可靠性要求不高的场景,如日志收集。
Producer 队列选择
RocketMq中所有关于生产者和消费者的代码都在client包下。打开源码,可以看到Procuder下有个selector包
RocketMq提供了3种不同的选择队列方式:
- SelectMessageQueueByHash
- SelectMessageQueueByMachineRoom
- SelectMessageQueueByRandom
它们都实现了 MessageQueueSelector 接口,可以自己实现这个接口,定义自己的队列选择方式
顺序消息可以使用 SelectMessageQueueByHash 用 shardingkey 对队列数取模,实现把同一个 shardingkey 的消息发送到同一个队列
那么默认机制是哪一种呢?
默认选择方式是轮询
Broker 接收
Producer往broker发消息,写到CommitLog同时异步地dispatch到ConsumeQueue中,Consumer对ConsumeQueue进行消费
消费组消费模式
集群消费模式(需要消费组内负载均衡)
Group 1 和 Group 2 分别订阅 Topic 的所有队列(Group 内,每个队列只能被一个消费者消费)
一条消息就会被 Group1 和 Group2 分别消费一次。
由于集群中的消费者只要一个消费消息即可,故消息的消费进度,需要保存在集中点,故 RocketMQ存储在Broker所在的服务器,通过 RemoteBrokerOffsetStore 结构。
广播消费模式(不需要消费组内负载均衡)
同一个订阅组内,每个消费者都要拉取所有队列的消息
注意事项:
- 广播消费模式下不支持 顺序消息。
- 广播消费模式下不支持 重置消费位点。
- 每条消息都需要被相同订阅逻辑的多台机器处理。
- 消费进度在客户端维护,出现重复消费的概率稍大于集群模式。
- 广播模式下,消息队列 RocketMQ 版保证每条消息至少被每台客户端消费一次,但是并不会重投消费失败的消息,因此业务方需要关注消费失败的情况。
- 广播模式下,客户端每一次重启都会从最新消息消费。客户端在被停止期间发送至服务端的消息将会被自动跳过,请谨慎选择。
- 广播模式下,每条消息都会被大量的客户端重复处理,因此推荐尽可能使用集群模式。
- 广播模式下服务端不维护消费进度,所以消息队列 RocketMQ 版控制台不支持消息堆积查询、消息堆积报警和订阅关系查询功能。
- 该方式一般可用于网关推送、配置推送等场景。
消费组内负载均衡策略
在 Rocket MQ 5.0 之前,仅支持队列粒度的负载均衡
-
队列粒度负载均衡:5.0 及以后的 PullConsumer默认负载策略。对于历史版本(服务端4.x/3.x版本)的消费者,包括PullConsumer、DefaultPushConsumer、DefaultPullConsumer、LitePullConsumer等,默认且仅能使用队列粒度负载均衡策略。
队列粒度负载均衡运用的比较广泛,后面所说的消费端均是基于队列粒度负载均衡
队列粒度负载均衡
同一消费者分组内的多个消费者将按照队列粒度消费消息,即每个队列只能被一个消费者消费(每个消费者可消费多个队列)。
如上图所示,主题中的三个队列Queue1、Queue2、Queue3被分配给消费者分组中的两个消费者,每个队列只能分配给一个消费者消费,该示例中由于队列数大于消费者数,因此,消费者A2被分配了两个队列。若队列数小于消费者数量,可能会出现部分消费者无绑定队列的情况。
队列粒度的负载均衡,基于队列数量、消费者数量等运行数据进行统一的算法分配,将每个队列绑定到特定的消费者,然后每个消费者按照取消息>提交消费位点>持久化消费位点的消费语义处理消息,取消息过程不提交消费状态,因此,为了避免消息被多个消费者重复消费,每个队列仅支持被一个消费者消费。
队列粒度负载均衡策略保证同一个队列仅被一个消费者处理,该策略的实现依赖消费者和服务端的信息协商机制(Rebal),Apache RocketMQ 并不能保证协商结果完全强一致。因此,在消费者数量、队列数量发生变化时,可能会出现短暂的队列分配结果不一致,从而导致少量消息被重复处理。
策略特点
相对于消息粒度负载均衡策略,队列粒度负载均衡策略分配粒度较大,不够灵活。但该策略在流式处理场景下有天然优势,能够保证同一队列的消息被相同的消费者处理,对于批量处理、聚合处理更友好。
适用场景
队列粒度负载均衡策略适用于流式计算、数据聚合等需要明确对消息进行聚合、批处理的场景。
使用示例
队列粒度负载均衡策略不需要额外设置,对于历史版本(服务端4.x/3.x版本)的消费者类型PullConsumer默认启用。
消息粒度负载均衡
(5.0之后)消息粒度负载均衡策略中,同一消费者分组内的多个消费者将按照消息粒度平均分摊主题中的所有消息,即同一个队列中的消息,可被平均分配给多个消费者共同消费。
这个目前应该还未大规模推广使用,网上的资料都是队列粒度负载均衡。
Rebalance (消费组内队列重分配)
Rebalance(再均衡)机制指的是:将一个Topic下的多个队列(或称之为分区),在同一个消费者组(consumer group)下的多个消费者实例(consumer instance)之间进行重新分配。
适合消息粒度负载均衡时
Rebalance机制本意是为了提升消息的并行处理能力。例如,一个Topic下5个队列,在只有1个消费者的情况下,那么这个消费者将负责处理这5个队列的消息。如果此时我们增加一个消费者,那么可以给其中一个消费者分配2个队列,给另一个分配3个队列,从而提升消息的并行处理能力。
Rebalance限制:
由于一个队列最多分配给一个消费者,因此当某个消费者组下的消费者实例数量大于队列的数量时,多余的消费者实例将分配不到任何队列。
Rebalance危害:
除了以上限制,更加严重的是,在发生Rebalance时,存在着一些危害,如下所述:
- 消费暂停:考虑在只有Consumer 1的情况下,其负责消费所有5个队列;在新增Consumer 2,触发Rebalance时,需要分配2个队列给其消费。那么Consumer 1就需要停止这2个队列的消费,等到这两个队列分配给Consumer 2后,这两个队列才能继续被消费。
- 重复消费:Consumer 2 在消费分配给自己的2个队列时,必须接着从Consumer 1之前已经消费到的offset继续开始消费。然而默认情况下,offset是异步提交的,如consumer 1当前消费到offset为10,但是异步提交给broker的offset为8;那么如果consumer 2从8的offset开始消费,那么就会有2条消息重复。也就是说,Consumer 2 并不会等待Consumer1提交完offset后,再进行Rebalance,因此提交间隔越长,可能造成的重复消费就越多。
- 消费突刺:由于rebalance可能导致重复消费,如果需要重复消费的消息过多;或者因为rebalance暂停时间过长,导致积压了部分消息。那么都有可能导致在rebalance结束之后瞬间可能需要消费很多消息。
从本质上来说,触发Rebalance的根本因素无非是两个:1 ) Topic的队列数量变化 2)消费者组信息变化。导致二者发生变化的典型场景如下所示:
Topic的队列数量变化:
- broker宕机
- broker升级等运维操作
- 队列扩容/缩容
消费者组信息变化:
- 日常发布过程中的停止与启动消费者
- 异常宕机
- 网络异常导致消费者与Broker断开连接
- 主动进行消费者数量扩容/缩容
- Topic订阅信息发生变化
Rebalance过程
在Broker中维护着多个Map集合,这些集合中动态存放着当前Topic中Queue的信息;ConsumerGroup中Consumer实例的信息。
一旦发现消费者所订阅的Queue数量发生变化(因为 broker 宕机了),或消费者的数量发生变化,立即向ConsumerGroup中的每个实例发出Rebalance通知。
rebalance有两种唤醒方式,除了前面的broker监听到 queue 或 consumer发生变化唤醒rebalance,还有一个是rebalance本省是一个while循环,每隔20s会唤醒一次rebalace。
Rebalance时,如果某个队列重新分配给了某个消费者,那么必须接着从上一个消费者的位置继续开始消费,这就是ConsumerOffsetManager的作用。
队列分配(给消费者)策略
- AllocateMessageQueueAveragely:平均分配,默认
- AllocateMessageQueueAveragelyByCircle:循环分配
- AllocateMessageQueueConsistentHash:一致性哈希
- AllocateMessageQueueByConfig:根据配置进行分配
- AllocateMessageQueueByMachineRoom:根据机房
- AllocateMachineRoomNearby:就近分配
消费者消费方式
消费起始位置
- CONSUME_FROM_LAST_OFFSET
这是Consumer默认的消费策略,它分为两种情况,如果Broker的磁盘消息未过期且未被删除,则从最小偏移量开始消费。如果磁盘已过期,并被删除,则从最大偏移量开始消费。
- CONSUME_FROM_FIRST_OFFSET
从最早可用的消息开始消费
- CONSUME_FROM_TIMESTAMP
从指定的时间戳开始消费,这意味着在consumeTimestamp之前生成的消息将被忽略
Client
RocketMQ 中,具有三个客户端类:
DefaultMQPullConsumer(deprecated)- DefaultLitePullConsumer
- DefaultMQPushConsumer
其中,前两个为 Pull 类型,即由自己去拉取消息;后面一个是 Push 类型,即只需要设置好回调方法等,然后等待消息到来后进行调用该方法即可。
但实际上,他们的底层实现都是 pull,即都是由客户端去 Broker 获取消息。
没有使用维持长连接服务端真正向消费端push,这是因为,使用真正的 Push 需要额外考虑一些问题,如消费者的消费速率慢于 Broker 的发送速率时会导致消费者的缓冲区满,即使可以通过设置背压(BackPressure)机制来做流量控制,但这样毫无疑问会增加程序的复杂度。但如果是消费者按需拉取的话,则设计方法会简便很多,也可以将消费者缓冲区满的问题转化为 Broker 消息堆积的问题。
Pull 和 Push 的实质上的区别在于 Push 会使用长轮询:
- 如果是 Pull 模式,拉取不到消息后,也会立马返回
- 而 Push 模式下,发送请求后如果服务端这时没有消息返回给你,就会把这个请求挂起,默认挂起15秒,然后这个期间会有后台线程每隔一会就去检查是否有新的消息给你;另外在这个请求挂起期间,如果有新的消息到达了,就会主动唤醒挂起的线程,然后把这些消息用这个请求返回给你。如果挂起超时仍然没有新消息过来请求也会返回。
通过这种长轮询机制,即可解决Consumer端需要通过不断地发送无效的轮询Pull请求,而导致整个RocketMQ集群中Broker端负载很高的问题。
DefaultLitePullConsumer(拉)
有两种模式,即 Assign 和 Subscribe,两种模式的区别主要在于 rebalance 服务是否启动。
Assign
第一种是 Assign,即由我们自己分配要拉取的 queue (不跟 rebalance 分配的队列)
消费者实例 org.apache.rocketmq.example.simple.LitePullConsumerAssign
public class LitePullConsumerAssign { public static volatile boolean running = true; public static void main(String[] args) throws Exception { // 消费组 DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("C-CLOUD-UPSTREAM-YSS"); litePullConsumer.setNamesrvAddr("10.10.168.3:10812"); litePullConsumer.setAutoCommit(false); // 消费者启动过程,会启动 schedual litePullConsumer.start(); // 获取 "TopicTest" 下的所有队列 Collection<MessageQueue> mqSet = litePullConsumer.fetchMessageQueues("MEDIA_MESSAGE_UPSTREAM_YSS"); List<MessageQueue> list = new ArrayList<>(mqSet); List<MessageQueue> assignList = new ArrayList<>(); // 自定义分配队列:取所有队列的前一半 for (int i = 0; i < list.size()/2; i++) { assignList.add(list.get(i)); } // 手动将前一半队列分配给这个消费者, 这里会为每个队列创建一个 PullTask,供 schedual 对这些队列进行的消息批量拉取下来后放到 consumeRequestCache 里 // org.apache.rocketmq.client.impl.consumer.DefaultLitePullConsumerImpl.PullTaskImpl litePullConsumer.assign(assignList); litePullConsumer.seek(assignList.get(0), 32); try { // 一个线程在这里循环,也可以用线程池 while (running) { // schedual 会定时执行每个队列的 PullTask,每次拉取一批量的数据回来 // schedual 把拉取来的消息存到 BlockingQueue<ConsumeRequest> consumeRequestCache 里。在这里出队进行消费 List<MessageExt> messageExts = litePullConsumer.poll(); // 消费就是输出消息即可 System.out.printf("%s %n", messageExts); // 提交消费位点 litePullConsumer.commit(); } } finally { litePullConsumer.shutdown(); } } }
Subscribe
消费者实例 org.apache.rocketmq.example.simple.LitePullConsumerSubscribe
public class LitePullConsumerSubscribe { public static volatile boolean running = true; public static void main(String[] args) throws Exception { // 消费组 DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("C-CLOUD-UPSTREAM-YSS"); litePullConsumer.setNamesrvAddr("10.10.168.3:10812"); litePullConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET); // 会添加一个 MessageQueueListener,每当 rebalance 队列变化时会 updatePullTask updateAssignedMessageQueue litePullConsumer.subscribe("MEDIA_MESSAGE_UPSTREAM_YSS", "*"); // 启动过程,会启动 rebalance litePullConsumer.start(); try { while (running) { List<MessageExt> messageExts = litePullConsumer.poll(); System.out.printf("%s%n", messageExts); } } finally { litePullConsumer.shutdown(); } } }
拉取线程池
org.apache.rocketmq.client.impl.consumer.DefaultLitePullConsumerImpl
public class PullTaskImpl implements Runnable { private final MessageQueue messageQueue; private volatile boolean cancelled = false; private Thread currentThread; public PullTaskImpl(final MessageQueue messageQueue) { this.messageQueue = messageQueue; } ...... @Override public void run() { ....... if (this.isCancelled() || processQueue.isDropped()) { return; } long pullDelayTimeMills = 0; try { SubscriptionData subscriptionData; String topic = this.messageQueue.getTopic(); if (subscriptionType == SubscriptionType.SUBSCRIBE) { subscriptionData = rebalanceImpl.getSubscriptionInner().get(topic); } else { String subExpression4Assign = topicToSubExpression.get(topic); subExpression4Assign = subExpression4Assign == null ? SubscriptionData.SUB_ALL : subExpression4Assign; subscriptionData = FilterAPI.buildSubscriptionData(topic, subExpression4Assign); } PullResult pullResult = pull(messageQueue, subscriptionData, offset, defaultLitePullConsumer.getPullBatchSize()); if (this.isCancelled() || processQueue.isDropped()) { return; } switch (pullResult.getPullStatus()) { case FOUND: final Object objLock = messageQueueLock.fetchLockObject(messageQueue); synchronized (objLock) { if (pullResult.getMsgFoundList() != null && !pullResult.getMsgFoundList().isEmpty() && assignedMessageQueue.getSeekOffset(messageQueue) == -1) { processQueue.putMessage(pullResult.getMsgFoundList()); submitConsumeRequest(new ConsumeRequest(pullResult.getMsgFoundList(), messageQueue, processQueue)); } } break; case OFFSET_ILLEGAL: log.warn("The pull request offset illegal, {}", pullResult.toString()); break; default: break; } updatePullOffset(messageQueue, pullResult.getNextBeginOffset(), processQueue); } catch (InterruptedException interruptedException) { log.warn("Polling thread was interrupted.", interruptedException); } catch (Throwable e) { if (e instanceof MQBrokerException && ((MQBrokerException) e).getResponseCode() == ResponseCode.FLOW_CONTROL) { pullDelayTimeMills = PULL_TIME_DELAY_MILLS_WHEN_BROKER_FLOW_CONTROL; } else { pullDelayTimeMills = pullTimeDelayMillsWhenException; } log.error("An error occurred in pull message process.", e); } if (!this.isCancelled()) { scheduledThreadPoolExecutor.schedule(this, pullDelayTimeMills, TimeUnit.MILLISECONDS); } else { log.warn("The Pull Task is cancelled after doPullTask, {}", messageQueue); } } } ...... }
submitConsumerRequest 就是把拉取到的消息放到 consumeRequestCache 这个阻塞队列里
private final BlockingQueue<ConsumeRequest> consumeRequestCache = new LinkedBlockingQueue<>();
private void submitConsumeRequest(ConsumeRequest consumeRequest) { try { consumeRequestCache.put(consumeRequest); } catch (InterruptedException e) { log.error("Submit consumeRequest error", e); } }
litePullConsumer.poll(); 就是阻塞从 consumeRequestCache 里获取拉取到的消息
public synchronized List<MessageExt> poll(long timeout) { try { ...... ConsumeRequest consumeRequest = consumeRequestCache.poll(endTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS); .......if (consumeRequest != null && !consumeRequest.getProcessQueue().isDropped()) { List<MessageExt> messages = consumeRequest.getMessageExts(); .......return messages; } } catch (InterruptedException ignore) { } return Collections.emptyList(); }
DefaultMQPushConsumer(推)
消费过程大致如下图
上图省略了与 netty 的交互,交互如下:
- Consumer端PullMessageService线程take到PullRequest调用netty的writeAndFlush,这个方法是异步方法,可以保证整个过程非常轻量,运行完这段代码发送出去请求就结束了。
- Broker端会收到一个事件触发netty的channelRead()方法,chennelRead方法实现也很轻量,直接将Pull请求丢给业务线程池去处理
- 业务线程PullMessageThread会获取PullRequest里的内容,去ConsumeQueue中根据逻辑的offset拿到物理偏移和size,这样就可以去CommitLog中将msg截取出来,封装成PullResult。再通过netty的writeAndFlush方法将PullResult返回Consumer给客户端。
- 同样会将请求丢给另一个业务线程池,也就是callback线程池NettyClientPubilicExecutor
- 在callback里将msg反序列化后丢到treemap里,再从treemap里取消息丢到一个消费的线程池里进行消费
- 整个过程很绕,是为了保持netty线程的轻量