11)并发消费
分两部分,一个是定时任务清除客户端本地的过期数据,一个是消费拉取到的消息;
11.1)消费者启动时会清理processQueue中的过期消息
DefaultMQPushConsumerImpl#start()方法中会执this.consumeMessageService.start()方法,其中会执行一个调度任务,延迟15分钟,每隔15分钟执行ConsumeMessageConcurrentlyService#cleanExpireMsg方法,该方法会遍历processQueueTable,执行每个ProcessQueue#cleanExpireMsg方法;清理processQueue上超过了15分钟未被消费的数据;
1)权限控制
若是顺序直接return,若msgTreeMap中消息数大于或等于16,则最多遍历16次;
2)判断最大值是否小于15分钟
拿到msgTreeMap的读锁,判断msgTreeMap中的第一条消息的消费开始时间距离当前时间是否超过15分钟,若没有,则表示最大值都小于15分钟,故整个msgTreeMap中所有数据的消费时间都不会超过15分钟,则跳出循环,退出方法;若第一条消息的消费时间大于15分钟,则取出该条消息保存至msg,准备回退;
3)回退与删除
3.1)执行 pushConsumer.sendMessageBack方法,回退刚才的msg,并设定延迟级别为3;
3.2)获取msgTreeMap的写锁,判断msg的offset是否还是msgTreeMap的firstKey,如果不相等,则表示在消息回退期间,该条消息被消费任务成功消费掉,导致该条消息从msgTreeMap中移走,firstKey发生变化,此时不能删除该条msg;只有在消息回退期间,该条消息没有被消费任务成功消费,接下来才能将该条msg从msgTreeMap中删除,此时当再次循环处理时,firstKey已发生变化;
11.2)提交消费任务以及任务运行
在两种场景下会提交消费任务,当客户端发送拉取消息的请求后,broker端处理后,返回数据给客户端,此时客户端会提交消费任务;另外当消费失败,并且回退也失败了,此时会再次提交消费任务;
由提交消费任务由方法ConsumeMessageConcurrentlyService#submitConsumeRequest执行,上接8.2.1.4小节,客户端从broker端成功拉取到消息后,准备提交至消费线程池进行消费;
11.2.1)实例化ConsumeRequest
封装了List,processQueue,messageQueue;
11.2.2)consumeExecutor.submit
入参为ConsumeRequest实例;ConsumeRequest继承了Runnable;接着进入ConsumeRequest#run方法中;获取消息提交的规格consumeBatchSize,默认为1;消息数量小于规格大小,则直接封装成consumeRequest提交至消费任务线程池,若消息数量大于规格大小,则每次封装规格数量的消息至consumeRequst中,再提交至消费任务线程池;
11.2.2.1)processQueue是否为dropped状态
11.2.2.2)重试消息恢复原主题
执行DefaultMQPushConsumerImpl#resetRetryAndNamespace方法,遍历msg,若为重试消息,则恢复原主题,判断重试消息的条件是消息的RETRY_TOPIC属性值不为null,则用原主题值替换主题%RETRY%groupName属性的值,即恢复原主题;
11.2.2.3)设置每条消息消费开始时间
在清除过期消息的时候,会用到;
11.2.2.4)执行listener.consumeMessage方法
执行业务逻辑,返回三种状态CONSUME_SUCCESS,RECONSUME_LATER,null,若为null,则设置为RECONSUME_LATER;
11.2.2.5)processConsumeResult
如果在业务消费过程中,当前messageQueue被分配给了其他consume或者消费者退出,则无需处理消费结果,直接退出run方法,否则执行ConsumeMessageConcurrentlyService#processConsumeResult方法;该方法主要完成以下任务
11.2.2.5.1)消费结果为CONSUME_SUCCESS
准备从processQueue中移除该条消息;此时设置ackIndex为List的长度减1(长度和容量不是一回事,长度指实际元素个数);接着会以ackIndex+1为起点,遍历List,由于条件不满足所以此时直接退出for循环,不会进行遍历;
11.2.2.5.2)消费结果为RECONSUME_LATER,执行消息回退
此时设置ackIndex为-1,接着会以ackIndex+1为起点,遍历List,
此时会从第0个元素开始,遍历consumeRequest中的消息,此时会调用ConsumerMessageConcurrentlyService#sendMessageBack方法将每条消息都回退给broker端;关于消息回退的具体逻辑将抽出一个小节进行分析,详情见11.3小节;
11.2.2.5.2.1)回退失败
将消息重试属性加1,并且将消息加入到回退失败集合,并且将回退失败的消息从consumeRequest的消息中移除,并且再次提交消费任务,延迟5秒后,再次消费;
11.2.2.5.2.2)回退成功
对于消费成功和回退成功两种情况,会执行ProcessQueue#removeMessage方法,移除当前消息,因为回退成功后,该消息延迟一段时间后broker会再次发送给消费者消费;removeMessage方法会返回一个offset,表示pq本地的消费进度,有三种情况:(暂时未理解)
a)返回-1,当msgTreeMap中无数据会返回此值;
b)返回当前messageQueue的最大offset加1,当移除前有数据,移除后msgTreeMap中无数据了,会返回此值,此时该返回值对应消息还在broker,还未拉取到客户端,所以offset需要更新到offsetStore中;
c)返回firstKey值,当删完后,msgTreeMap中还有剩余待消费消息会返回此值;
11.2.2.5.3)更新当前messageQueue的消费进度
当执行完removeMessage方法后的返回值offset大于0且pq处于正常状态,则更新客户端当前messageQueue本地消费进度;执行RemoteBrokerOffsetStore#updateOffset方法,该方法第三个参数传的是true,表示只有新offset大于旧offset时,才会更新进度;
11.3)消费失败后执行消息回退
这里接上边11.2.2.5.2小节;这里以并发消费为例,顺序消费后边会抽出一小节单独分析;
消费失败重试基于延迟队列实现的,但也可以在客户端设置延迟级别单纯的发送延迟消息;消费失败重试也即消息回退给broker端,之所以要延迟,是因为如果每次消费失败的消息回退给broker之后,紧接着又拉取下来,消费失败了,再次回退…造成无效消费,所以消费失败的消息会在broker端存储一段时间,让消费者不可见,时间到了之后,再次让消费者可见;另外还有一个定时任务,用于broker端给客户端发送回退消息请求的响应;
消费失败回退分为三大部分;
第1部分是将消费失败的消息由客户端回退给broker,11.3小节将分析第1部分;
第2部分是broker端收到消息回退的请求后,将该回退消息存储进特殊主题的commitLog,第2部分将在11.4小节单独分析;
第3部分是broker中的延迟消息机制,是基于调度主题和调度队列实现的,第3部分将在11.5小节单独分析;
先分析第一部分,即当本地提交consumeRequest至消费线程池,执行其run方法,其中又会执行listener.consumeMessage方法,消费后返回的状态若为RECONSUME_LATER,则遍历当前consumeRequest中的List,执行sendMessageBack方法;
11.3.1)获取消息重试策略
该值表示消息重试的策略,默认为0,表示延迟级别由broker端控制,broker端通过判断消息重试次数,每重试一次,延迟级别加1,延迟级别越高,在broker端需要被hold的时间越长;若delayLevelWhenNextConsume为-1,则消费失败的消息直接发送到broker端的死信队列,此时没有任何消费者可以消费它,需要人工干预;若delayLevelWhenNextConsume大于0,表示延迟级别由客户端控制,指定是几,broker端就按几处理;
11.3.2)DefaultMQPushConsumerImpl#sendMessageBack
11.3.2.1)构建RemotingCommand实例
消费者客户端向broker端发送RemotingCommand类型的request,主要包括opaque,requestCode为CONSUMER_SEND_MSG_BACK,RPC_TYPE为REQUEST_COMMAND,requestHeader等;注意消息回退,并不会将消息体传给broker端,只需要传输commitLogOffset即可,requestHeader中还包括originTopic,originMsgId,group,delayLevel等;
在客户端,writeAndFlush方法的入参是RemotingCommand类型的实例request;在broke端,writeAndFlush方法的入参是RemotingCommand类型的实例response;
11.3.2.2)NettyRemotingClient#invokeSync方法
执行同步发送
11.3.2.3)ResponseFuture#waitResponse方法超时等待
发送线程调用countDownLatch.await带时间的超时阻塞;
11.3.2.4)回退发送出现异常
当发送时出现异常,会执行以下代码,此时会新建一个消息Message实例,设置主题为%RETRY%groupName,添加属性键值对<RETRY_TOPIC,原始主题>,设置重试次数加1,设置延迟级别加3,等等,最后调用内部生产者向broker发送存储消息请求;
11.4)broker端处理消息回退的请求
11.3小节中已经分析了在ConsumeMessageConcurrentlyService#processConsumeResult方法中,判断了消息消费状态,若存在失败消息,则会调用sendMessageBack方法,向broker端发送重新拉取该条失败消息的请求,broker的SendMessageProcessor#asyncConsumerSendMsgBack方法是处理请求的入口;主要会执行以下几步,最终目的是用于将失败的消息再次存储至commitLog,重新构建cq,使消费者能够消费到;11.4.9小节就是broker存储消息的逻辑,但此时延迟级别大于0,所以会用到调度主题和调度队列;
11.4.1)创建RemotingCommand类型的response
11.4.2)获取消费者组订阅配置信息
根据消费者组,获取消费者订阅配置信息SubscriptionGroupConfig实例,其中有几个重要属性,retryQueueNums默认为1表示重试队列个数,retryMaxTimes默认为16表示最大支持多少次重试;若retryQueueNums不大于0,则表示该消费者组下的消息不用重试,此时broker直接返回给客户端success;消费者订阅配置信息可以通过控制台修改;
11.4.3)获取消费者组重试主题与queueId
重试主题设为%RETRY%groupName;获取该主题下的queueId,计算方式是随机数对1取余,结果都是0,所以queueId为0;接着获取该主题下的topicConfig配置信息;
11.4.4)DefaultMessageStore#lookMessageByOffset
根据请求的commitLog的物理偏移量commitLogOffset,从commitLog文件中找到消费失败的消息;这里有一个要点,是只要找到一条消息,以前所有的根据offset去mfq中找,都是最后获得一个buffer切片,范围是从[offset, offset所在mappedFile的文件名+wrotePosition],即从offset起,一直到offset所在mappedFile文件的最后一个有效数据;但这里只是要获取到消费端消费失败的那一条消息,所以根据commitLog消息的存储格式,可以先拿到消息的size,再根据size,拿到消息;这里分两步:
第一步是findMappedFileByOffset方法找到offset所在的mappedFile;
第二步是MappedFile#selectMappedBuffer(offset%1g,size)方法;以前调用的selectMappedFile方法都只有第一个参数,这里加了第二个参数size,表示要在当mappedFile上获取以offset%1g为起点,大小为size的一个副本buffer;
第一次size传入4,返回buffer后,读取其上的内容,即消息大小x;再次令size为x,调用上边两个方法,即可获得这条消息msgExt;
11.4.5)判断查询到消息是否是首次重试
若属性RETRY_TOPIC为null,则为首次重试,则添加新属性RETRY_TOPIC,并将原始主题赋值给属性RETRY_TOPIC,原始主题即该是啥主题就是啥主题;
如果是首次重试,则查询出来的消息msgExt,其topic为原始主题,queueId为原始messageQueue的id,msgId为原始id,reconsumeTimes为0,属性RETRY_TOPIC为null,属性DELAY为null;
如果不是首次重试,则查询出来的消息msgExt,其topic为%RETRY%groupName,queueId为0,msgId是新定义的id,reconsumeTimes大于0,属性RETRY_TOPIC为原始主题,属性DELAY也是存在的,属性ORIGIN_MESSAGE_ID存在且保存了最原始消息的msgId;
11.4.6)判断查询到消息是否需要改为死信主题
若requestHeader中的delayLevel小于0或者重试次数reconsumeTimes不小于16,则将重试主题修改为死信队列主题%DLQ%groupName,该主题对应的queueId为0;消费者无法获取该队列下的主题,只能人工干预;
11.4.7)设置delayLevel
若不满足进入死信队列条件,则判断若requestHeader中的delayLevel为0,则表示由broker端控制延迟级别,则设置刚查询出来的消息的属性DELAY的值为reconsumeTimes加3,表示延迟级别从第三级开始(即首次重试延迟级别就从3开始);每重试一次,消息的reconsumeTimes属性加1;
11.4.8)新建MessageExtBrokerInner实例
a)主题是重试主题%RETRY%groupName或者死信主题%DLQ%groupName;
b)复制从commitLog中查询出来消息msgExt的一些内容到本消息;
c)queueId为重试主题或死信主题的queueId,即0;
d)重试次数为查询出来的消息msgExt的重试次数加1;
e)从查询出来的消息msgExt的ORIGIN_MESSAGE_ID属性中获取对应的value即最原始消息的消息id,若为null,则表示msgExt为第一次回退,则直接将msgExt的消息id设置到新消息实例的ORIGIN_MESSAGE_ID属性中;若不为null,则表示msgExt不是第一次回退,则直接将value值设置到新消息实例的ORIGIN_MESSAGE_ID属性中;
其实无论是broker端接收普通消息还是延迟消息,都需要新建消息实例,前者直接将topic,queueId以及属性复制到新实例中,而后者由于delayLevel大于0,从而修改topic,queueId,添加属性等;新实例的主题为%RETRY%groupName,queueId为0,添加的属性为(ORIGIN_MESSAGE_ID,原值),设置重试次数;
再次存储新建实例时,会检查消息的delay属性是否大于0,若延迟级别大于0,则会将消息的主题和队列再次修改,修改为调度主题和调度队列;所以消息回退的本质是将原始主题存储进属性,修改主题为%RETRY%groupName,再走延迟队列逻辑(此时会再次修改主题会延迟主题,再将重试主题存进属性);
11.4.9)DefaultMessageStore#putMessage
这就回到了broker端存储生产者端发送消息的方法了;其中会判断消息的DELAY属性对应的延迟级别是否大于0,若大于0表示该条消息需要延迟,则修改消息topic为调度主题SCHEDULE_TOPIC_XXXX,和调度队列queueId=延迟级别减1,新增属性REAL_TOPIC为%RETRY%groupName,新增属性REAL_QID为0;最终会将消息存入调度主题的调度队列id中;
若不大于0,则保留原始主题与原始队列id;
也就是说调度队列和调度主题不一定都是给回退的消息使用的,如果我想发送延迟消息,则直接往调度队列的主题发送消息,并且事先准备好延迟级别;
当客户端消费失败时,如果重新拉取消息的请求立马又发送给broker端,broker端找到消息后立马返回给客户端,此时客户端大概率又会消费失败,所以为了防止反复消费失败,在broker端会进行延迟处理,即延迟消息,也即这里提到的调度主题与调度队列;延迟级别由int类型的delayLevelWhenNextConsume控制,该值默认为0表示延迟级别由broker端控制,broker端通过判断消息重试次数,每重试一次,延迟级别加1,延迟级别越高,在broker端需要被hold的时间越长;若delayLevelWhenNextConsume为-1,则消费失败的消息直接发送到broker端的死信主题,此时没有任何消费者可以消费它,需要人工干预(比如后台开一个线程,去消费死信主题下的队列中的消息);若delayLevelWhenNextConsume大于0,表示延迟级别由客户端控制,指定是几,broker端就按几处理;
普通消息也可以作为延迟消息进行消费,在生产者发送时可以设置延迟级别;普通消息和消费失败而回退的消息,相同的是都使用的是SendMessageProcessor进行处理,但请求码不同,消息回退是
RequestCode.CONSUMER_SEND_MSG_BACK,普通消息是RequestCode.SEND_MESSAGE,导致最后使用的方法不同,消息回退使用的是SendMessageProcessor#asyncConsumerSendMsgBack,普通消息使用是SendMessageProcessor#asyncSendMessage;
另外回退的消息,若属于首次回退,则broker端收到回退请求后,重新查询到原消息后,复制至新建MessageExtBrokerInner实例时,会将topic修改为重试主题%RETRY%groupName,队列id改为了0,而原始主题和queueId被存进了属性中;
而普通消息是没做任何修改是直接拷贝,最终导致调用存储模块时,判断delayLevel大于0后,用调度主题SCHEDULE_TOPIC_XXXX和调度队列queueId替换topic和queueId后,普通消息属性中添加的是(REAL_TOPIC,原始主题),(REAL_QID,原始队列id),而回退的消息是(REAL_TOPIC,%RETRY%groupName),(REAL_QID,队列id是0),(RETRY_TOPIC,原始主题);
11.5)延迟消息是基于调度主题与调度队列
ScheduleMessageService类实现调度消息服务,有两个重要Map,即delayLevelTable和offsetTable,delayLevelTable保存的了每个延迟级别下对应的延迟时间长度,默认支持18个级别,从1到18,对应1秒,5秒,10秒…2小时;offsetTable保存了每个延迟级别下的队列的“消费”进度(consumeQueue中的mfq的进度),程序首先要从文件中加载数据,以初始化这两个map,每个延迟级别,都对应一个调度队列,队列id为延迟级别减1;
11.5.1)加载
在BrokerController#initialize方法中会执行messageStore.load()方法,其中会执行ScheduleMessageService.load()方法,主要是初始化delayLevelTable和offsetTable;
11.5.2)遍历delayLevelTable表,配置定时器与任务
BrokerController#start方法中,会执行messageStore.start方法,其中会执行handleScheduleMessageService方法,其中又会执行scheduleMessageService.start方法,其中会新建一个定时器实例,遍历delayLevelTable表,为每个延迟级别添加一个Runnable延迟任务,首次是100毫秒后开始判断,每个延迟级别都对应一个延迟时间,从1秒…到2小时,当延迟时间到了,则会执行延迟任务,由于DeliverDelayedMessageTimerTask类继承了Runnable接口,所以会执行其run方法,其中又会执行executeOnTimeup方法,该方法处理延迟时间到了之后的逻辑;
11.5.3)添加定时任务持久化调度队列的进度
会单独添加一个周期任务至定时器,用于持久化各个调度进度至offsetTable,首次是10秒后执行,周期是15秒。每个延迟级别对应的Runnable任务,
11.5.4)DeliverDelayedMessageTimerTask#run
每个延迟时间下对应一个DeliverDelayedMessageTimerTask实例,当时间到了,则会执行该实例的run方法;run方法中会执行executeOnTimeup方法,根据调度主题和调度队列id去找到对应的ConsumeQueue实例;
若该cq实例为null,则表示该调度队列中并无延迟消息,所以直接再次新建一个DeliverDelayedMessageTimerTask实例,再次加入到Timer#schedule方法中,当下次该调度队列的延迟时间到了,则再次进入到executeOnTimeup方法中判断;
若该cq实例不为null,则表示该调度队列中有延迟消息,此时需要根据调度队列对应的逻辑偏移量offset,找到在当前cq中的mfq中的具体哪个mappedFile,最后返回一个buffer切片bufferCQ;
读取bufferCQ上的每一条cq消息,每条消息三个字段offsetPy,sizePy,tagsCode,但这次tagsCode中存储的是截止时间,因为在构建consumeQueue时,会判断延迟级别是否大于0,若大于0则将延迟级别转换为相对应的延迟截止时间,并存入tagsCode中;(调度主题和调度队列原理和普通主题,普通队列一样,只是在将消息重新存入commitLog之前,会先判断时间是否到了,时间到了才可以存)
11.5.4.1)查询consumeQueue,遍历每条消息
根据主题SHEDULE_TOPIC_XXXX和延迟队列id,获取延迟队列的consumeQueue对象,根据offset到consumeQueue中获取SelectMappedBufferResult对象,每次检查20字节,由于reputMessageService线程在构建consumeQueue时,会判断若延迟级别是大于0,则将其转为相应的延迟截止时间,并且存入tagsCode中,所以这里会再次拿到tagsCode,然后与当前时间对比;这里为啥要进行时间对比呢?因为scheduleMessageService服务在首次启动时,会延迟1s后再启动执行任务,这个并不符合任务要在指定的调度时间后才能执行的逻辑,所以在执行任务前需要再判断一下调度时间是否已经到了,若到了,则可以执行,否则再次延迟剩余时间;
11.5.4.2)比较延迟时间是否已到
11.5.2.2.1)到了,则再次根据offsetPy从commitLog中查询出消息msgExt,拷贝消息内容至新创建的MessageExtBrokerInner实例msgInner,其中会清理延迟级别,从属性中读取(REAL_TOPIC,%RETRY%groupName),(REAL_QID,队列id是0)替换当前的主题SCHEDULE_TOPIC_XXXX和延迟队列queueId;
11.5.2.2.2)没到,则新建延迟任务,在当前时间与延迟截止时间的差值之后再次执行该任务,这样就可以循环下去,即只要未到延迟截止时间,则获取新的差值后,新建延迟任务,在差值时间后再次执行;
11.5.4.3)DefaultMessageStore#putMessage
再次将msgInner存储至commitLog中,其他线程则会继续构建consumeQueue,消费者负载均衡线程会再次分配继而使消费者消费;
判断存储结果,若成功,则继续判断下一条消息;若失败,则将当前消息再次放入调度队列中设定延迟10s后执行,更新offsetTable即延迟级别对应的队列的消费进度,直接退出当前方法;
最后调用release方法,释放掉切片buffer;
本文详细解读RocketMQ消费者的并发消费过程,包括消费者启动时清理过期消息,提交消费任务的执行流程,消费失败后的消息回退策略,以及消息回退在broker端的处理和延迟消息机制。内容涵盖了消费任务提交、消息重试恢复原主题、消费结果处理、消费失败后的回退逻辑以及broker端的延迟消息存储和调度主题的运用。
&spm=1001.2101.3001.5002&articleId=123932608&d=1&t=3&u=d18e4b46f24a4c8cbdaa7087fccd8d94)
2223

被折叠的 条评论
为什么被折叠?



