RocketMQ4源码(三)普通消息消费

前言

本章基于rocketmq4.6.0分析普通消息消费。

基于最常见的消费模式:

  • 集群消费:MessageModel.CLUSTERING;
  • Push模式:DefaultMQPushConsumer;
  • 并行(非顺序)消费:MessageListenerConcurrently;

由于消费逻辑大多在客户端,所以本文先梳理客户端逻辑。

broker侧提供了必要的api:

  1. 心跳相关:心跳、查询消费组成员;
  2. 消费offset(逻辑offset)相关:提交offset、查询消费组offset、查询最大offset、根据时间查询offset;
  3. Pull消息相关:Pull消息(长轮询);

最后结合客户端和broker,梳理一下消费重试逻辑,简单提一下延迟消息特性。

模型

DefaultMQPushConsumer

和DefaultMQProducer差不多,DefaultMQPushConsumer是暴露给用户的api。

实现都交给MQConsumerInner实现类DefaultMQPushConsumerImpl。

DefaultMQPushConsumerImpl

DefaultMQPushConsumerImpl管理消费者的相关组件:

  • RebalancePushImpl rebalanceImpl:rebalance相关;
  • MQClientInstance mQClientFactory:通讯client,和生产者一样;
  • PullAPIWrapper pullApiWrapper:众所周知,Push模式实际底层还是Pull模式;
  • OffsetStore offsetStore:消费者offset管理;
  • MessageListener messageListenerInner:用户代码消费逻辑;
  • ConsumeMessageService consumeMessageService:消费服务;

OffsetStore

每个consumer实例,都有一个OffsetStore管理consumer的消费进度。

对于集群消费,关注RemoteBrokerOffsetStore

可以看到内存中offsetTable管理了每个MessageQueue的消费进度offset。

这里offset指的就是逻辑offset,即broker-topic-queue纬度自增的那个。

RemoteBrokerOffsetStore#updateOffset:更新消费offset,只更新内存。

RemoteBrokerOffsetStore#readOffset:支持从内存获取消费offset,也支持从broker实时同步offset。

RemoteBrokerOffsetStore#persistAll/persist:在适当的时机,consumer会将内存offset同步至broker。

启动阶段

DefaultMQPushConsumerImpl#start:

  • 准备工作;
  • 对所有订阅的topic,刷新一次路由;
  • 向所有broker发送一次心跳;
  • 开始rebalance;

收集订阅信息SubscriptionData

第一步,将用户通过subscribe api订阅信息,注入到rebalance服务。

DefaultMQPushConsumerImpl#start:

DefaultMQPushConsumerImpl#copySubscription

订阅数据包含两部分,一部分是用户主动订阅的topic,另一部分是基于消费组的重试topic=%RETRY%+group

SubscriptionData是封装后的订阅信息,每个topic一个。

其中codeSet是tag.hashCode集合,和broker中messagequeue呼应。

创建MQClientInstance

第二步,创建MQClientInstance底层通讯client。

DefaultMQPushConsumerImpl#start:

在集群消费模式下,同一个进程内,由于ip和pid相同,生产与消费使用同一个MQClientInstance实例。

OffsetStore加载消费offset

第三步,创建OffsetStore,加载消费offset(逻辑offset)。

DefaultMQPushConsumerImpl#start:

根据MessageModel选择不同的OffsetStore实现:

  • 集群消费:RemoteBrokerOffsetStore;
  • 广播消费:LocalFileOffsetStore;

集群消费load空实现。

创建并启动ConsumeMessageService

第四步,创建ConsumeMessageService并启动。

DefaultMQPushConsumerImpl#start:

根据用户注入的Listener类型,创建不同的ConsumeMessageService实现。

  • MessageListenerConcurrently:并行消费,ConsumeMessageConcurrentlyService;
  • MessageListenerOrderly:顺序消费,ConsumeMessageOrderlyService;

ConsumeMessageConcurrentlyService#start:

开启一个后台任务,每15分钟清理过期消息,具体逻辑后续深入。

启动MQClientInstance

DefaultMQPushConsumerImpl#start:第五步,启动MQClientInstance。

MQClientInstance#start:启动多个组件

  • mQClientAPIImpl:通讯client;
  • startScheduledTask:多个后台线程;
  • PullMessageService:拉消息线程;
  • RebalanceService:rebalance线程;
  • defaultMQProducer:消费重试producer;

MQClientInstance#startScheduledTask:

上一章讲到producer在这里会开启路由刷新broker心跳后台任务,consumer同样适用。

除此以外,consumer每隔5s进行offset持久化,具体逻辑后续深入。

刷新路由MessageQueue

DefaultMQPushConsumerImpl#updateTopicSubscribeInfoWhenSubscriptionChanged:

第六步,根据订阅信息,将所有订阅topic的路由数据刷新一遍。

MQClientInstance#updateTopicRouteInfoFromNameServer:

刷新路由逻辑和生产者是一致的,只需要关注broker路由表TopicRouteData转换为消费路由。

MQClientInstance#topicRouteData2TopicSubscribeInfo:

对于消费者,将TopicRouteData中的QueueData按照可读队列数量转换为MessageQueue。

最终topic对应MessageQueue会放入RebalanceImpl,给rebalance使用,后续深入。

发送心跳ConsumerData

第七步,发送一次心跳给所有broker。

MQClientInstance#prepareHeartbeatData:

客户端心跳包HeartbeatData中,consumerDataSet部分属于consumer信息。

每个消费组对应一个ConsumerData

Rebalance阶段

铺垫

为什么要Rebalance

PullAPIWrapper#pullKernelImpl

broker提供的的pull api,需要consumer提供多个必要参数来获取消息:如topic、queueId、brokerAddr。

而这些数据都来源于客户端路由中的MessageQueue

众所周知,在4.x版本中,集群消费模式下,同一个topic,同一个消费组,每个MessageQueue是被一个consumer实例独占的。

这种概念在kafka也是类似的,一个partition被一个consumer实例独占。

rebalance,就是在消费组内为每个consumer实例分配MessageQueue

消费组的概念不再赘述,同一个消费组的订阅关系是一致的。

在5.x版本中,有POP消费模式,MessageQueue可以不被独占,Rebalance也由服务端控制。

Rebalance的实现

rocketmq的rebalance是在客户端完成的(5.x不能这么说),抽象实现是RebalanceImpl,针对不同的消费模式,有不同实现。

RebalanceImpl有几个比较重要的成员变量:

  1. subscriptionInner:订阅表,由用户subscribe转换为SubscriptionData;
  2. topicSubscribeInfoTable:消费路由表,由broker返回TopicRouteData转换为MessageQueue;
  3. processQueueTable:分配给当前consumer的MessageQueue,每个MessageQueue映射到一个ProcessQueue模型;

ProcessQueue

ProcessQueue 是对于一个MessageQueue的消费情况。

ProcessQueue通过一个TreeMap(msgTreeMap)缓存从broker拉取到的消息,key是offset,value是消息。

Rebalance的入口

case1 定时rebalance

MQClientInstance启动阶段,开启RebalanceService 线程,每20s检测一次是否需要rebalance。

case2 启动rebalance

DefaultMQPushConsumerImpl#start:

启动阶段consumer首次分配MessageQueue,需要立即执行一次rebalance。

MQClientInstance#rebalanceImmediately:

本质上是唤醒RebalanceService线程。

case3 消费组成员变更

ClientRemotingProcessor#notifyConsumerIdsChanged

如果客户端与broker的连接正常,在某些场景下会收到broker发来的NOTIFY_CONSUMER_IDS_CHANGED请求,代表消费组成员发生变更。

客户端会立即开始执行rebalance。

在broker侧有多种场景,触发成员变更通知,逻辑都收口在DefaultConsumerIdsChangeListener

场景一:通讯层连接空闲/异常/关闭

ClientHousekeepingService会监听通讯层发生的事件。

当连接空闲、异常、关闭,都会最终触发连接关闭,触发消费组成员变更通知。

场景二:新连接建立/订阅信息发生变更

ConsumerManager#registerConsumer:

在处理客户端心跳HEART_BEAT的时候,会判断:

  1. 是否是新注册的consumer
  2. consumer订阅信息是否发生变更

如果满足其中一种条件,都会触发消费组成员变更通知。

场景三:consumer正常关闭

ConsumerManager#unregisterConsumer:

consumer正常下线,发送UNREGISTER_CLIENT

rebalance

RebalanceImpl#doRebalance:循环每个订阅的topic执行rebalance。

RebalanceImpl#rebalanceByTopic:针对集群消费和广播消费,rebalance实现不同。

获取路由和组内consumer实例

RebalanceImpl#rebalanceByTopic:

第一步,获取两个集合:

  1. mqSet:获取topic下所有MessageQueue,由nameserver提供路由;
  2. cidAll:调用topic下的某个broker获取组内所有consumer实例的clientId(ip+pid),由broker提供;

对这两个集合按照顺序排序。

分配MessageQueue

RebalanceImpl#rebalanceByTopic:

第二步,使用AllocateMessageQueueStrategy策略,为当前consumer的这个topic分配MessageQueue。

默认使用AllocateMessageQueueAveragely平均分配策略。

根据当前clientId所处所有clientId的下标index,分配MessaegQueue。

比如8个queue,3个client:index=0的client分配q0-q2,index=1的client分配q3-q5,index=2的client分配q6-q7。

比如2个queue,3个client:index=0的client分配q0,index=1的client分配q1,index=2的client分配不到queue。

更新processQueueTable

rebalanceImpl#rebalanceByTopic:

第三步,更新processQueueTable。

RebalanceImpl#updateProcessQueueTableInRebalance:

  1. 移除processQueueTable中不属于自己的queue;
  2. 处理新增给自己的queue;
  3. 对于新增给自己的queue,提交PullRequest到PullMessageService;

移除不属于自己的queue

RebalanceImpl#updateProcessQueueTableInRebalance:

移除processQueueTable中不属于自己的queue,

标记MessageQueue对应ProcessQueue为dropped。

RebalancePushImpl#removeUnnecessaryMessageQueue:

Push模式下,将queue对应消费进度offset持久化。

RemoteBrokerOffsetStore#persist:

集群模式消费,调用broker持久化offset。

这里可以看到对于MessageQueue的消费进度,临时保存在RemoteBrokerOffsetStore的内存table中。

新增queue(ConsumeFromWhere)

RebalanceImpl#updateProcessQueueTableInRebalance:

循环分配给自己的queue,如果有queue不在processQueueTable中,需要注册到processQueueTable并构建PullRequest。

PullRequest中比较重要的是要指定queue的下一个消费进度nextOffset。

RebalancePushImpl#computePullFromWhere:

根据用户指定的ConsumeFromWhere采取不同的策略,获取nextOffset,返回负数代表失败,不会发起PullRequest。

默认使用ConsumeFromWhere=CONSUME_FROM_LAST_OFFSET策略。

OffsetStore会调用broker获取当前group在这个queue上的消费进度lastOffset。

如果lastOffset>=0,那么直接返回lastOffset;

如果lastOffset=-1,那么会获取目前queue的最大offset;

RemoteBrokerOffsetStore#readOffset:

从broker获取group对于queue的消费进度brokerOffset,并更新到内存offsetTable。

ConsumeFromWhere=CONSUME_FROM_FIRST_OFFSET

如果lastOffset=-1,那么从这个队列头开始消费;

ConsumeFromWhere=CONSUME_FROM_TIMESTAMP

如果lastOffset=-1,那么根据一个时间戳定位一个offset。

DefaultMQPushConsumer#consumeTimestamp:

用户可以指定这个时间戳,默认是Consumer构造时生成的,是当前时间-30分钟。

提交PullRequest

RebalanceImpl#updateProcessQueueTableInRebalance:

如果上一步中,存在新分配给当前consumer实例的queue,会构造PullRequest。

RebalancePushImpl#dispatchPullRequest:

Push模式下,循环所有PullRequest提交。

本质上是Rebalance线程将PullRequest提交给PullMessage线程,此时PullMessage线程从阻塞中恢复。

单线程生产,单线程消费,无锁。

后续处理

至此当前consumer已经分配完成queue:

对于新增的queue,提交PullRequest到PullMessage线程;

对于移除的queue,将ProcessQueue标记为dropped。

rebalanceImpl#rebalanceByTopic:

第四步,当processQueueTable发生变化,做一些处理。

RebalancePushImpl#messageQueueChanged:

Push模式下,更新pull消息queue纬度流控阈值(默认未开启),向所有broker再次发送心跳。

Pull消息阶段

现在从rebalance线程来到了pull线程,开始拉取消息。

为什么拉消息只需要一个pull线程

因为这个过程只是提交请求到io线程,当收到broker返回消息后,消费逻辑由其他线程处理

PullMessageService#pullMessage:

从队列中拿到PullRequest后,根据消费组找到消费者实例。

校验

DefaultMQPushConsumerImpl#pullMessage:在实际请求broker前有一些校验。

校验一:ProcessQueue是否drop。

在rebalance线程中,如果queue不属于自己被移除,这里就会直接返回。

校验二:流控。

push模式下,如果ProcessQueue中缓存消息过多都会发生流控,会延迟50ms重新提交PullRequest到Pull线程。

  1. ProcessQueue缓存消息超过1000条
  2. ProcessQueue缓存消息超过100M
  3. ProcessQueue缓存消息offset跨度超出2000

调用broker

DefaultMQPushConsumerImpl#pullMessage:调用pull api前,准备一些数据。

首先定义PullCallback,当收到broker响应后,回调这个回调函数。

pull api还支持顺便提交offset。

从OffsetStore的内存中,读取当前queue已经提交的offset。

构建sysFlag,包括是否提交offset、是否挂起、是否有tag过滤等。

最终发起调用。

PullAPIWrapper#pullKernelImpl:封装PullMessageRequestHeader,调用通讯层。

MQClientAPIImpl#pullMessageAsync:提交到io线程,至此Pull线程完成。

收到消息阶段

在第一章讲nameserver最后总结时提到过,客户端对于异步请求的callback由public线程池执行。

NettyRemotingClient#NettyRemotingClient:核心线程数=核数。

MQClientAPIImpl#pullMessageAsync:

  1. 将RemotingCommand解析为PullResult;
  2. 执行PullCallback;

MQClientAPIImpl#processPullResponse:

broker会返回多种响应码,除了4种以外,其他都属于异常,回调PullCallback#onException。

PullResult包含一些offset和broker返回的消息。

异常流程

NO_NEW_MSG/NO_MATCHED_MSG

PullCallback对于NO_NEW_MSG和NO_MATCHED_MSG都重新提交PullRequest到PullMessage线程。

OFFSET_ILLEGAL

PullCallback对于OFFSET_ILLEGAL,延迟10s将这个queue从processQueueTable移除,可能触发rebalance。

Exception

PullCallback对于所有异常,延迟3s再次投递PullRequest到PullMessage线程。

正常流程

PullCallback正常收到消息。

第一步,更新PullRequest下次请求broker的offset为broker返回的nextBeginOffset。

第二步,将消息缓存到ProcessQueue。

ProcessQueue用一个treeMap存储offset和message,更新一些统计指标,可用于流控。

第三步,将消息提交到ConsumeMessageConcurrentlyService。

ConsumeMessageConcurrentlyService#submitConsumeRequest:

根据批量消费数量大小(默认1),将消息分批构建为ConsumeRequest提交到消费线程池。

如果消费线程池满了,延迟5s再次投递。

一个消费者实例,对应一个消费线程池。

默认核心线程数=最大线程数=20,队列其实是个LinkedBlockingQueue属于无界(Integer.MAX_VALUE),所以线程池一般也不会满。

第四步,默认立即提交PullRequest到PullMessage线程,向broker发起下一次调用。

Consume阶段

ConsumeMessageConcurrentlyService.ConsumeRequest#run:

第一步,执行用户代码并行消费MessageListenerConcurrently。

ConsumeConcurrentlyContext,传递给用户代码的上下文,暴露给用户更多操作消息的能力。

delayLevelWhenNextConsume:默认0,代表由broker控制重试频率,用户可设置大于0,由客户端控制,也可设置为-1,不重试。

ackIndex:当开启批量消费时(默认未开启),可以指定一组message实际ack的位置,比如10条消息,5条成功,第6条失败,可以指定ackIndex=4,即下标0-4条消息ack,下标5-9消息unack。

用户代码返回ConsumeConcurrentlyStatus:

如果用户消费抛出异常,status=RECONSUME_LATER;

如果用户消费返回null,status=RECONSUME_LATER。

ConsumeMessageConcurrentlyService.ConsumeRequest#run:

第二步,处理用户代码消费结果。

ConsumeMessageConcurrentlyService#processConsumeResult:

首先决定ackIndex,默认ackIndex=Integer.MAX_VALUE。

对于消费成功,ackIndex=消息总数-1;对于消费失败,ackIndex=-1。

所以如果批量消费要指定ackIndex,那么返回status要设置为success,否则会认为整批消息都失败。

对于处理失败的消息,发送给broker。

如果发送给broker失败,本地延迟5s再次投递ConsumeRequest,走Consume流程。

最后,从ProcessQueue的TreeMap中移除这批消息,返回目前ProcessQueue的消费进度,OffsetStore更新内存offset。

ProcessQueue#removeMessage:

case1:如果移除这批消息后,无缓存消息了,那么取最大已消费offset+1;

case2:如果移除这批消息后,仍然存在缓存消息,那么取剩余缓存消息的最小offset;

总而言之,返回已处理消息的offset+1。

Consume超时

ConsumeMessageConcurrentlyService#start:

消费者启动阶段,启动后台任务,每隔15分钟清理过期消息。

ConsumeMessageConcurrentlyService#cleanExpireMsg:

扫描分配给自己的所有ProcessQueue。

ProcessQueue#cleanExpiredMsg:

扫描缓存在ProcessQueue中的消息,判断用户代码是否处理头部offset消息超时(15分钟)。

对于执行超过15分钟的消息,重新发送回broker(即当做消费失败,重试),并从ProcessQueue中移除。

Broker

客户端心跳

ClientManageProcessor#heartBeat:

客户端心跳对应数据包HeartbeatData,分为三部分:clientId,ConsumerData,ProducerData。

在broker侧,clientId对应ClientChannelInfo,包含通讯层Channel。

ConsumerData由ConsumerManager处理。

ConsumerManager#registerConsumer:

将ClientChannelInfo和ConsumerData封装为ConsumerGroupInfo,存储consumerTable中。

ConsumerGroupInfo消费组信息:

  • subscriptionTable:组内对于topic的订阅情况,同一个组内的订阅数据应当一致;
  • channelInfoTable:通讯channel和ClientChannelInfo的映射关系;
  • lastUpdateTimestamp:上次心跳时间;

ConsumerManageProcessor#getConsumerListByGroup:

rebalance初期,broker通过ConsumerManager中的channelInfoTable,汇总所有clientId返回组内所有消费实例id给consumer(GET_CONSUMER_LIST_BY_GROUP)。

Offset

Broker侧使用ConsumerOffsetManager管理consumer消费进度。

ConsumerOffsetManager通过config/consumerOffset.json存储消费进度。

BrokerController#initialize:broker初始化阶段会将消费进度从磁盘加载到内存。

内存通过两层map存储,key1=topic@group,key2=queueId,value=offset。

提交offset(UPDATE_CONSUMER_OFFSET)

ConsumerOffsetManager#commitOffset

客户端提交offset,仅仅是将offset放到broker的内存map中。

BrokerController#initialize:

broker初始化阶段,会开启后台任务,默认每5s将消费offset持久化到consumerOffset.json

此外在broker正常关闭时也会持久化一次,忽略。

查询消费组offset(QUERY_CONSUMER_OFFSET)

case1

ConsumerManageProcessor#queryConsumerOffset:

首先从ConsumerOffsetManager的内存中,获取消费组topic+queue对应的offset。

case2

ConsumerManageProcessor#queryConsumerOffset:

如果消费组topic+queue对应offset不存在,从consumequeue中找最小逻辑offset。

即broker-topic-queue纬度从0开始自增的offset

由于broker侧有消息清理工作,consumequeue中最小的逻辑offset可能为0(未清理),也可能大于0(清理过),接下来分成两种情况。

case2-1:

如果未执行清理,最小逻辑offset是0,且offset对应消息还在commitlog的内存中,那么返回0。

这种情况下,无论客户端ConsumeFromWhere如何设置,都将从这个MessageQueue初始offset开始消费

DefaultMessageStore#checkInDiskByCommitOffset:

判断逻辑offset对应消息是否在commitlog内存中,

offsetPy是逻辑offset对应物理offset,maxOffsetPy是commitlog目前内存中最大offset。

case2-2:

不满足上述两种情况,返回QUERY_NOT_FOUND。

此时客户端才会走ConsumeFromWhere指定逻辑。

查询最大逻辑offset(GET_MAX_OFFSET)

当消费组offset返回QUERY_NOT_FOUND,

且消费者指定ConsumeFromWhere=CONSUME_FROM_LAST_OFFSET

调用broker根据topic+queueId查询最大逻辑offset。

DefaultMessageStore#getMaxOffsetInQueue:

先定位ConsumeQueue(topic/queueId目录下的n个MappedFile)。

ConsumeQueue#getMaxOffsetInQueue:

最大逻辑offset=最后一个consumequeue的物理offset/consumequeue的结构大小20字节。

根据时间查询offset(SEARCH_OFFSET_BY_TIMESTAMP)

当消费组offset返回QUERY_NOT_FOUND,

且消费者指定ConsumeFromWhere=CONSUME_FROM_TIMESTAMP

调用broker根据topic+queueId+timestamp查询逻辑offset。

ConsumeQueue#getOffsetInQueueByTime:

getMappedFileByTime,扫描consumequeue/{topic}/{queueId}下的文件,找到第一个更新时间大于等于timestamp的文件。

对这个文件从头到尾使用二分查找,

每次定位一个物理offset后去commitlog中找这条消息,

直到找到消息存储时间与timestamp最接近的这条消息对应的逻辑offset。

Pull

BrokerController#initialize:

broker为PullMessage API分配业务线程池如下:

  • 核心/最大线程数:16+核数*2;
  • 队列:10w;

PullMessageProcessor#processRequest:处理consumer拉消息。

Step1,MessageStore获取消息,得到GetMessageResult。

Step2,如果获取到消息,写response.body。

Step3,如果没获取到消息,默认支持长轮询,PullRequestHoldService挂起当前PullMessage请求。

Step4,在满足某些条件的情况下,针对客户端传来的已经提交的offset,执行提交。

即Pull消息同时支持提交已经消费的offset。

获取消息

DefaultMessageStore#getMessage:

根据消费组、topic、queueId、逻辑offset获取消息,

客户端指定最多返回32条,

MessageFilter提供消息过滤功能,如tag过滤。

第一步,找ConsumeQueue并判断逻辑offset是否在ConsumeQueue中。

第二步,根据逻辑offset读取ConsumeQueue中的一段buffer。

ConsumeQueue#getIndexBuffer

  1. 根据逻辑offset,算出consumequeue的物理offset;
  2. 根据consumequeue的物理offset,定位一个consumequeue文件;
  3. 根据consumequeue的物理offset从这个文件读取buffer,这个buffer包含从这个offset开始到已经写入的offset;

假设consumequeue每个文件2000byte,可以存放100条consumequeue记录(上一章提过,每个consumequeue记录20byte),逻辑如下。

注意这里的物理offset,值的是consumequeue的物理offset,并非commitlog的。

第三步,循环处理consumequeue中的这段buffer,获取目标message。

每20字节代表一条消息对应的consumequeue记录。

offsetpy是commitlog的物理offset,sizePy是消息长度,tagsCode是消息的tag的hashCode。

判断Pull请求中的tag.hashCode是否包含consumequeue中的tag.hashCode。

根据commitlog的物理offset和消息长度可以得到这条消息的buffer,加入GetMessageResult。

CommitLog#getMessage:根据commitlog物理offset+消息长度读buffer。

CommitLog和ConsumeQueue都用MappedFileQueue管理,所以逻辑差不多。

写response

PullMessageProcessor#processRequest:

如果成功获取到消息,那么有两种策略写response.body。

默认transferMsgByHeap=true,采用heap内存拷贝方式;

可选transferMsgByHeap=false,采用sendfile方式,即FileRegion.transferTo。

PullMessageProcessor#readGetMessageResult:

默认将GetMessageResult中的n个消息buffer合并写入一个HeapByteBuffer。

挂起

PullMessageProcessor#processRequest:

如果offset没有消息可以给客户端了,broker会挂起这个请求(长轮询),客户端指定了长轮询时长30s。

注意这里response置空了,所以不会响应客户端。

PullRequestHoldService#suspendPullRequest:

将本次Pull请求封装为PullRequest,放入一个内存table。

ManyPullRequest封装了多一个PullRequest,忽略。

提交offset

PullMessageProcessor#processRequest:

当满足多个条件时,本次pull请求还能顺便提交offset。

  1. brokerAllowSuspend:如果本次请求不是挂起后恢复;
  2. hasCommitOffsetFlag:客户端sysFlag允许提交;
  3. brokerRole:当前broker角色是master;

提交逻辑与正常提交逻辑一致,即ConsumerOffsetManager写内存。

回顾客户端,只要是集群模式消费,且客户端OffsetStore中存在queue对应offset就可以提交offset。

恢复挂起

有两种场景会恢复挂起。

第一种,PullRequestHoldService作为一个后台线程ServiceThread,每隔5s扫描挂起的PullRequest。

第二种,ReputMessageService构建consumequeue后(见上一章),触发NotifyMessageArrivingListener

NotifyMessageArrivingListener只会调用PullRequestHoldService。

PullRequestHoldService#notifyMessageArriving:

从table中获取topic@queueId下所有PullRequest,循环处理。

如果consumequeue中最大逻辑offset大于Pull请求的offset,代表有新消息到达,再次提交Pull请求。

如果pull挂起超时(30s),仍然再次提交Pull请求。

如果PullRequest不满足上述条件,重新回到PullRequestHoldService的table中,等待后续处理。

PullMessageProcessor#executeRequestWhenWakeup:

重新提交Pull请求到PullMessage线程池,执行逻辑同上面的流程,区别在于入参brokerAllowSuspend=false。

即不允许再次挂起且不允许提交offset,这很正常。

Consume重试

客户端发送CONSUMER_SEND_MSG_BACK

MQClientAPIImpl#consumerSendMessageBack:

用户代码异常或返回RECONSUME_LATER或返回Null,最终会将消息重新投递给broker。

携带参数包括:消费组,原始topic,消息对应commitlog物理offset,延迟级别,消息id,最大重试次数。

其中延迟级别可以由client通过ConsumeConcurrentlyContext控制,上文提到过,默认是0,由broker控制。

Broker处理CONSUMER_SEND_MSG_BACK

SendMessageProcessor#consumerSendMsgBack:

处理consumer发送重试消息,和正常消息消息发送使用同一个线程池。

第一步,获取消费组重试topic=%RETRY%+group,默认队列数量1个。

第二步,根据commitlog的物理offset,找到消息。

第三步,将原始topic放入消息的properties.RETRY_TOPIC。

第四步,判断是否超出重试次数16,如果是的话,topic改为死信topic=%DLQ%+group。

delayLevel如果客户端不指定,则设置为3+重试次数。

重试消息一定异步刷盘。

第五步,调用MessageStore存储消息,和普通消息发送几乎一致。

CommitLog#putMessage:

区别在于对于包含delayTimeLevel的消息,将真实topic放入properties,将消息topic替换为SCHEDULE_TOPIC_XXXX。

至此消息的关键数据如下:

topic=SCHEDULE_TOPIC_XXXX;

queueId=delayTimeLevel-1;

properties.REAL_TOPIC=%RETRY%+group;

properties.REAL_QID=重试topic的queueId,默认只有一个队列,所以是0;

properties.RETRY_TOPIC=实际消费失败消息的topic;

延迟投递

这部分涉及延迟消息特性,简单提一下。

ScheduleMessageService.DeliverDelayedMessageTimerTask#executeOnTimeup:

针对每个delayLevel,会有一个定时任务,

消费SCHEDULE_TOPIC_XXXX下delayLevel-1对应consumequeue的消息。

ScheduleMessageService.DeliverDelayedMessageTimerTask#messageTimeup:

将properties.REAL_TOPIC和properties.REAL_QID回写到message,重新投递到commitlog,进而构建consumequeue对消费者可见。

至此消息的关键数据如下:

topic=%RETRY%+group;

queueId=0;

properties.RETRY_TOPIC=实际消费失败消息的topic;

消费重试topic

在消费者启动阶段,会自动订阅重试topic,上面已经提到过,见DefaultMQPushConsumerImpl#copySubscription

所以rebalance阶段,同样包含重试topic,而重试topic只有一个queue,所以只会有一个consumer执行重试;

所以pull阶段,consumer也会把重试topic当做正常topic一样请求broker;

在consume阶段,consumer会对topic做特殊处理。

ConsumeMessageConcurrentlyService.ConsumeRequest#run:

DefaultMQPushConsumerImpl#resetRetryAndNamespace:

最终consumer侧识别message.topic=%RETRY%+自身消费组,

替换message.topic=properties.RETRY_TOPIC=实际消费失败的topic。

总结

最后来梳理一些重要逻辑。

客户端消费

Consumer启动阶段

  1. 收集消费组订阅信息SubscriptionData,自动订阅重试Topic,即%RETRY%+group;
  2. ConsumeMessageService开启ProcessQueue超时消息清理线程;
  3. MQClientInstance启动,包括:通讯client、心跳任务、路由刷新任务、定时持久化offset任务(5s)、Pull消息线程、Rebalance线程;
  4. 主动刷新路由,路由结果topic和MesssageQueue缓存到RebalanceImpl;
  5. 主动发送心跳,包含ConsumerData消费者信息和订阅信息;
  6. 唤醒Rebalance线程,立即执行Rebalance;

Rebalance触发

  1. consumer启动阶段必定需要先执行rebalance,后续会定时检测是否需要rebalance;
  2. broker侧监测到组内consumer实例上下线或订阅数据发生变更,会通知consumer进行rebalance;

Rebalance阶段

reblance由客户端执行,循环每个订阅topic执行reblance,一个线程。

  1. 获取topic下所有MessageQueue和组内成员clientId集合;
  2. 采用平均分配策略,为自己分配MessageQueue;
  3. 为每个分配给自己的MessageQueue创建ProcessQueue,用于缓存pull过来的消息;
  4. 为每个ProcessQueue保证有一个PullRequest提交给Pull线程
  5. PullRequest的拉取消息offset需要从broker获取(先根据group拿,如果拿不到有可能返回0,即queue的第一条消息开始消费),如果获取不到,走ConsumeFromWhere逻辑获取;
  6. 向所有broker再次发送心跳;

Pull消息阶段

针对一个MessageQueue有一个PullRequest,由PullMessageService单线程处理,提交到io线程。

重点在于请求broker端的数据。

  1. 集群消费,如果OffsetStore中存在MessageQueue的消费进度,支持pull的同时提交offset
  2. PullRequest中的offset,即逻辑offset,用于请求queue对应位置开始的消息;
  3. 客户端允许broker挂起(长轮询),挂起时长30s;
  4. 默认每次最多拉32条消息;

收到消息阶段

根据broker返回数据,流程不同:

  1. NO_NEW_MSG/NO_MATCHED_MSG,如果queue中没有更多消息,再次提交PullRequest到PullMessageService线程;
  2. OFFSET_ILLEGAL,请求逻辑offset超出consumequeue的逻辑offset范围,即非法offset,延迟10s移除对应ProcessQueue,可能触发rebalance;
  3. FOUND,收到消息,正常执行;
  4. 其他,都属于异常,延迟3s再次投递PullRequest到PullMessageService线程;

对于FOUND:

  1. 更新PullRequest中的offset为broker返回offset;
  2. 缓存消息到ProcessQueue中的TreeMap,key是逻辑offset,value是消息;
  3. 将消息分批提交到ConsumeMessageConcurrentlyService的线程池,默认1条消息一批对应一个ConsumeRequest(Runnable,ConsumeMessageConcurrentlyService的内部类);
  4. 立即提交PullRequest到PullMessageService线程,向broker发起下一次调用(长轮询);

Consume阶段

一个消费者实例,对应一个ConsumeMessageConcurrentlyService,对应一个消费线程池。

默认核心线程数=最大线程数=20,队列是个LinkedBlockingQueue。

  1. 执行用户代码MessageListenerConcurrently;
  2. 对于处理失败的消息,重新投递给broker,如果投递broker失败,延迟5s组装为ConsumeRequest提交到ConsumeMessageConcurrentlyService的线程池
  3. 将处理完的消息(包括成功的和重新投递broker成功的,但不包括重新投递broker失败的),从ProcessQueue中移除;
  4. 更新OffsetStore内存中的queue的消费进度,由Pull线程或后台定时任务5s异步向broker更新消费进度;

Consume超时

ConsumeMessageConcurrentlyService开启后台线程,每隔15分钟扫描所有ProcessQueue。

如果ProcessQueue中缓存的第一个消息处理时间超过15分钟,则从ProcessQueue中移除这条消息,重新投递给broker,即重试。

Broker

心跳

ClientChannelInfo=clientId+通讯channel;

ConsumerGroupInfo=一个消费组信息=多个消费者实例心跳=多个ClientChannelInfo+订阅信息;

ConsumerManager管理group和ConsumerGroupInfo的映射关系;

consumer在rebalance阶段从broker的ConsumerManager中可以得到组内所有clientId。

Offset

ConsumerOffsetManager管理所有消费组的消费进度(逻辑offset),所有数据存储在consumerOffset.json。

在broker启动时,ConsumerOffsetManager加载consumerOffset.json到内存。

consumer查询和提交offset只会操作内存。

后台线程每5s将消费offset持久化到consumerOffset.json。

Pull

broker对于Pull消息请求,业务线程池核心/最大线程数:16+核数*2,队列:10w。

根据topic、queueId、逻辑offset获取消息。

如果存在消息,则写response,分为两种方式:

默认transferMsgByHeap=true,采用heap内存拷贝方式;

可选transferMsgByHeap=false,采用sendfile方式,即FileRegion.transferTo。

如果不存在消息,则挂起Pull请求,缓存到PullRequestHoldService,当超时30s或有新消息到达,获取消息响应客户端。

客户端可以在pull请求的同时提交已经处理的offset,broker侧处理逻辑同正常提交offset。

消费重试

消费重试场景:

  1. 客户端后台线程检测到消费超时(15m);
  2. 用户代码异常、返回RECONSUME_LATER、返回Null;

消费重试流程:

Step1,消费者调用broker重新投递这条消息CONSUMER_SEND_MSG_BACK。

Step2,broker修改消息数据,重新写commitlog。

ini 复制代码
topic=SCHEDULE_TOPIC_XXXX;
queueId=delayTimeLevel-1;
properties.REAL_TOPIC=%RETRY%+group;
properties.REAL_QID=重试topic的queueId,默认只有一个队列,所以是0;
properties.RETRY_TOPIC=实际消费失败消息的topic;

Step3,broker针对每个延迟级别,开启一个定时任务,消费topic=SCHEDULE_TOPIC_XXXX,queueId=延迟级别-1。

修改消息数据,重新写commitlog。

ini 复制代码
topic=%RETRY%+group;
queueId=0;
properties.RETRY_TOPIC=实际消费失败消息的topic;

Step4,consumer启动时自动订阅重试topic=%RETRY%+自身消费组,所以能正常消费。

识别message.topic=%RETRY%+自身消费组,替换message.topic=properties.RETRY_TOPIC=实际消费失败的topic。

欢迎大家评论或私信讨论问题。

本文原创,未经许可不得转载。

欢迎关注公众号【程序猿阿越】。

相关推荐
魔道不误砍柴功14 分钟前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_23415 分钟前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨18 分钟前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
Chrikk2 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*2 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue2 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man2 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟2 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity3 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天3 小时前
java的threadlocal为何内存泄漏
java