前言
本章基于rocketmq4.6.0分析普通消息消费。
基于最常见的消费模式:
- 集群消费:MessageModel.CLUSTERING;
- Push模式:DefaultMQPushConsumer;
- 并行(非顺序)消费:MessageListenerConcurrently;
由于消费逻辑大多在客户端,所以本文先梳理客户端逻辑。
broker侧提供了必要的api:
- 心跳相关:心跳、查询消费组成员;
- 消费offset(逻辑offset)相关:提交offset、查询消费组offset、查询最大offset、根据时间查询offset;
- 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有几个比较重要的成员变量:
- subscriptionInner:订阅表,由用户subscribe转换为SubscriptionData;
- topicSubscribeInfoTable:消费路由表,由broker返回TopicRouteData转换为MessageQueue;
- 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的时候,会判断:
- 是否是新注册的consumer
- consumer订阅信息是否发生变更
如果满足其中一种条件,都会触发消费组成员变更通知。
场景三:consumer正常关闭
ConsumerManager#unregisterConsumer:
consumer正常下线,发送UNREGISTER_CLIENT。
rebalance
RebalanceImpl#doRebalance:循环每个订阅的topic执行rebalance。
RebalanceImpl#rebalanceByTopic:针对集群消费和广播消费,rebalance实现不同。
获取路由和组内consumer实例
RebalanceImpl#rebalanceByTopic:
第一步,获取两个集合:
- mqSet:获取topic下所有MessageQueue,由nameserver提供路由;
- 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:
- 移除processQueueTable中不属于自己的queue;
- 处理新增给自己的queue;
- 对于新增给自己的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线程。
- ProcessQueue缓存消息超过1000条
- ProcessQueue缓存消息超过100M
- 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:
- 将RemotingCommand解析为PullResult;
- 执行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:
- 根据逻辑offset,算出consumequeue的物理offset;
- 根据consumequeue的物理offset,定位一个consumequeue文件;
- 根据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。
- brokerAllowSuspend:如果本次请求不是挂起后恢复;
- hasCommitOffsetFlag:客户端sysFlag允许提交;
- 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启动阶段:
- 收集消费组订阅信息SubscriptionData,自动订阅重试Topic,即%RETRY%+group;
- ConsumeMessageService开启ProcessQueue超时消息清理线程;
- MQClientInstance启动,包括:通讯client、心跳任务、路由刷新任务、定时持久化offset任务(5s)、Pull消息线程、Rebalance线程;
- 主动刷新路由,路由结果topic和MesssageQueue缓存到RebalanceImpl;
- 主动发送心跳,包含ConsumerData消费者信息和订阅信息;
- 唤醒Rebalance线程,立即执行Rebalance;
Rebalance触发:
- consumer启动阶段必定需要先执行rebalance,后续会定时检测是否需要rebalance;
- broker侧监测到组内consumer实例上下线或订阅数据发生变更,会通知consumer进行rebalance;
Rebalance阶段:
reblance由客户端执行,循环每个订阅topic执行reblance,一个线程。
- 获取topic下所有MessageQueue和组内成员clientId集合;
- 采用平均分配策略,为自己分配MessageQueue;
- 为每个分配给自己的MessageQueue创建ProcessQueue,用于缓存pull过来的消息;
- 为每个ProcessQueue保证有一个PullRequest提交给Pull线程
- PullRequest的拉取消息offset需要从broker获取(先根据group拿,如果拿不到有可能返回0,即queue的第一条消息开始消费),如果获取不到,走ConsumeFromWhere逻辑获取;
- 向所有broker再次发送心跳;
Pull消息阶段:
针对一个MessageQueue有一个PullRequest,由PullMessageService单线程处理,提交到io线程。
重点在于请求broker端的数据。
- 集群消费,如果OffsetStore中存在MessageQueue的消费进度,支持pull的同时提交offset;
- PullRequest中的offset,即逻辑offset,用于请求queue对应位置开始的消息;
- 客户端允许broker挂起(长轮询),挂起时长30s;
- 默认每次最多拉32条消息;
收到消息阶段:
根据broker返回数据,流程不同:
- NO_NEW_MSG/NO_MATCHED_MSG,如果queue中没有更多消息,再次提交PullRequest到PullMessageService线程;
- OFFSET_ILLEGAL,请求逻辑offset超出consumequeue的逻辑offset范围,即非法offset,延迟10s移除对应ProcessQueue,可能触发rebalance;
- FOUND,收到消息,正常执行;
- 其他,都属于异常,延迟3s再次投递PullRequest到PullMessageService线程;
对于FOUND:
- 更新PullRequest中的offset为broker返回offset;
- 缓存消息到ProcessQueue中的TreeMap,key是逻辑offset,value是消息;
- 将消息分批提交到ConsumeMessageConcurrentlyService的线程池,默认1条消息一批对应一个ConsumeRequest(Runnable,ConsumeMessageConcurrentlyService的内部类);
- 立即提交PullRequest到PullMessageService线程,向broker发起下一次调用(长轮询);
Consume阶段:
一个消费者实例,对应一个ConsumeMessageConcurrentlyService,对应一个消费线程池。
默认核心线程数=最大线程数=20,队列是个LinkedBlockingQueue。
- 执行用户代码MessageListenerConcurrently;
- 对于处理失败的消息,重新投递给broker,如果投递broker失败,延迟5s组装为ConsumeRequest提交到ConsumeMessageConcurrentlyService的线程池
- 将处理完的消息(包括成功的和重新投递broker成功的,但不包括重新投递broker失败的),从ProcessQueue中移除;
- 更新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。
消费重试
消费重试场景:
- 客户端后台线程检测到消费超时(15m);
- 用户代码异常、返回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。
欢迎大家评论或私信讨论问题。
本文原创,未经许可不得转载。
欢迎关注公众号【程序猿阿越】。