• RocketMQ:(3) Consumer


    一、消息消费概述

    消费组

      消息消费以组的模式开展,一个消费组内可以包含多个消费者,每一个消费组可订阅多个主题。

    消费模式

      消费组之间有集群模式广播模式两种消费模式。集群模式,主题下的同一条消息只允许被其中一个消费者消费。广播模式,主题下的同一条消息将被集群内的所有消费者消费一次。

    消息传送方式

      消息服务器与消费者之间的消息传送也有两种方式:推模式、拉模式。所谓的拉模式,是消费端主动发起拉消息请求,而推模式是消息到达消息服务器后,推送给消息消费者。RocketMQ消息推模式的实现基于拉模式,在拉模式上包装一层,一个拉取任务完成后开始下一个拉取任务。

      RocketMQ支持局部顺序消息消费,也就是保证同一个消息队列上的消息顺序消费。不支持消息全局顺序消费,如果要实现某一主题的全局顺序消息消费,可以将该主题的队列数设置为1,牺牲高可用性。

      RocketMQ支持两种消息过滤模式:表达式(TAG、SQL92)与类过滤模式。

    RocketMQ消息发送流程一览图

     

    二、消费者启动流程

      Step1:构建主题订阅信息SubscriptionData并加入到RebalanceImpl的订阅消息中。订阅关系来源主要有两个。
      1)通过调用DefaultMQPushConsumerImpl#subscribe(String topic, String sub Expression)方法。
      2)订阅重试主题消息。RocketMQ消息重试是以消费组为单位,而不是主题,消息重试主题名为%RETRY%+消费组名。消费者在启动的时候会自动订阅该主题,参与该主题的消息队列负载。 

      Step2:初始化MQClientInstance、RebalanceImple(消息重新负载实现类)等。
      Step3:初始化消息进度。如果消息消费是集群模式,那么消息进度保存在Broker上;如果是广播模式,那么消息消费进度存储在消费端。
      Step4:根据是否是顺序消费,创建消费端消费线程服务。ConsumeMessageService主要负责消息消费,内部维护一个线程池。
      Step5:向MQClientInstance注册消费者,并启动MQClientInstance,在一个JVM中的所有消费者、生产者持有同一个MQClientInstance, MQClientInstance只会启动一次。

     

    三、消息拉取

      消息消费有两种模式:广播模式与集群模式,广播模式比较简单,每一个消费者需要去拉取订阅主题下所有消费队列的消息。在集群模式下,同一个消费组内有多个消息消费者,同一个主题存在多个消费队列,那么消费者如何进行消息队列负载呢?每一个消费组内维护一个线程池来消费消息,一个消息队列在同一时间只允许被一个消息消费者消费,一个消息消费者可以同时消费多个消息队列。  

      PullMessageService,消息拉取服务线程,从pullRequestQueue中获取一个PullRequest消息拉取任务,如果pullRequestQueue为空,则线程将阻塞,直到有拉取任务被放入。
      PullRequest的核心属性processQueue:消息处理队列,从Broker拉取到的消息先存入ProcessQueue,然后再提交到消费者消费线程池消费。
      ProcessQueue是MessageQueue在消费端的重现、快照。PullMessageService从消息服务器默认每次拉取32条消息,按消息的队列偏移量顺序存放在ProcessQueue中,PullMessageService然后将消息提交到消费者消费线程池,消息成功消费后从ProcessQueue中移除。

    消息拉取分为3个主要步骤:

    1)消息拉取客户端消息拉取请求封装。

      Step1:从PullRequest中获取ProcessQueue,如果处理队列当前状态未被丢弃,则更新ProcessQueue的lastPullTimestamp为当前时间戳;如果当前消费者被挂起,则将拉取任务延迟1s再次放入到PullMessageService的拉取任务队列中,结束本次消息拉取。
      Step2:进行消息拉取流控。从消息消费数量与消费间隔两个维度进行控制。如果ProcessQueue当前处理的消息条数超过了pullThresholdFor-Queue=1000将触发流控,放弃本次拉取任务,并且该队列的下一次拉取任务将在50毫秒后才加入到拉取任务队列中。ProcessQueue中队列最大偏移量与最小偏离量的间距,不能超过consumeConcurrently-MaxSpan,否则触发流控。
      Step3:拉取该主题订阅信息,如果为空,结束本次消息拉取,关于该队列的下一次拉取任务延迟3s。
      Step4:构建消息拉取系统标记。调用PullAPIWrapper.pullKernelImpl方法后与服务端交互。
      Step5:根据brokerName、BrokerId从MQClientInstance中获取Broker地址,在整个RocketMQ Broker的部署结构中,相同名称的Broker构成主从结构,其BrokerId会不一样,在每次拉取消息后,会给出一个建议,下次拉取从主节点还是从节点拉取。
      Step6:如果消息过滤模式为类过滤,则需要根据主题名称、broker地址找到注册在Broker上的FilterServer地址,从FilterServer上拉取消息,否则从Broker上拉取消息。

    2)消息服务器查找并返回消息。

      Step1:根据订阅信息,构建消息过滤器。
      Step2:调用MessageStore.getMessage查找消息。
      Step3:根据主题名称与队列编号获取消息消费队列。
      Step4:消息偏移量异常情况校对下一次拉取偏移量。
      Step5:如果待拉取偏移量大于minOffset并且小于maxOffset,从当前offset处尝试拉取32条消息,根据消息队列偏移量(ConsumeQueue)从commitlog文件中查找消息。
      Step6:根据PullResult填充responseHeader的nextBegionOffset、minOffset、maxOffset。
      Step7:根据主从同步延迟,如果从节点数据包含下一次拉取的偏移量,设置下一次拉取任务的brokerId。
      Step8:根据GetMessageResult编码转换成相应结果。成功:SUCCESS、立即重试:PULL_RETRY_IMMEDIATELY、偏移量移动:PULL_OFFSET_MOVED、未找到消息:PULL_NOT_FOUND。
      Step9:如果commitlog标记可用并且当前节点为主节点,则更新消息消费进度。

    3)消息拉取客户端处理返回的消息。

      服务端消息拉取处理完毕,将返回结果到拉取消息调用方。在调用方,需要重点关注PULL_RETRY_IMMEDIATELY、PULL_OFFSET_MOVED、PULL_NOT_FOUND等情况下如何校正拉取偏移量。

      Step1:根据响应结果解码成PullResultExt对象。
      Step2:调用pullAPIWrapper的processPullResult将消息字节数组解码成消息列表填充msgFoundList,并对消息进行消息过滤(TAG)模式。
      Step3:更新PullRequest的下一次拉取偏移量,如果msgFoundList为空,则立即将PullReqeuest放入到PullMessageService的pullRequestQueue,以便PullMessageSerivce能及时唤醒并再次执行消息拉取。
      Step4:首先将拉取到的消息存入ProcessQueue,然后将拉取到的消息提交到ConsumeMessageService中供消费者消费,该方法是一个异步方法,也就是PullCallBack将消息提交到ConsumeMessageService中就会立即返回,至于这些消息如何消费,PullCallBack不关注。
      Step5:将消息提交给消费者线程之后PullCallBack将立即返回,可以说本次消息拉取顺利完成,然后根据pullInterval参数,如果pullInterval>0,则等待pullInterval毫秒后将PullRequest对象放入到PullMessageService的pullRequestQueue中,该消息队列的下次拉取即将被激活,达到持续消息拉取,实现准实时拉取消息的效果。

    消息拉取长轮询机制

      Push方式是Server端接收到消息后,主动把消息推送给Client端,实时性高。push方式主动推送有很多弊端:首先是加大Server端的工作量,进而影响Server的性能;其次,Client的处理能力各不相同,Client的状态不受Server控制,如果Client不能及时处理Server推送过来的消息,会造成各种潜在问题。
      Pull方式是Client端循环地从Server端拉取消息,主动权在Client手里,自己拉取到一定量消息后,处理妥当了再接着取。Pull方式的问题是循环拉取消息的间隔不好设定,间隔太短就处在一个“忙等”的状态,浪费资源;每个Pull的时间间隔太长,Server端有消息到来时,有可能没有被及时处理。

      “长轮询”方式通过Client端和Server端的配合,达到既拥有Pull的优点,又能达到保证实时性的目的。服务端接收到新消息后,如果队列里没有消息,并不急于返回,通过一个循环不断查看状态,每次waitForRunning一段时间(默认5S),然后再check。默认情况下当Broker一直没有新消息,第三次check的时候,等待时间超过了Request里面的BrokerSuspendTimeoutMillis,就会返回空结果。在等待的过程中,Broker收到了新的消息后会直接调用notifyMessageAriving函数返回请求结果。

      “长轮询”的核心是, Broker端HOLD 住客户端过来的请求一小段时间,在这个时间内有新消息到达,就利用现有的连接立刻返回消息给 Consumer。
    “长轮询”的 主动权还是掌握在 Consumer 手中, Broker 即使有大量消息积压,也不会主动推送给 Consumer 。长轮询方式的局限性,是在 HOLD 住 Consumer 请求的时候需要占用资源,它适合用在消息队列这种客户端连接数可控的场景中。

      RocketMQ并没有真正实现推模式,而是消费者主动向消息服务器拉取消息,RocketMQ推模式是循环向消息服务端发送消息拉取请求,如果消息消费者向RocketMQ发送消息拉取时,消息并未到达消费队列,如果不启用长轮询机制,则会在服务端等待shortPollingTimeMills时间后(挂起)再去判断消息是否已到达消息队列,如果消息未到达则提示消息拉取客户端PULL_NOT_FOUND(消息不存在),如果开启长轮询模式,RocketMQ一方面会每5s轮询检查一次消息是否可达,同时一有新消息到达后立马通知挂起线程再次验证新消息是否是自己感兴趣的消息,如果是则从commitlog文件提取消息返回给消息拉取客户端,否则直到挂起超时,超时时间由消息拉取方在消息拉取时封装在请求参数中,PUSH模式默认为15s, PULL模式通过DefaultMQPullConsumer#setBrokerSuspendMaxTimeMillis设置。

      RocketMQ轮询机制由两个线程共同来完成。

      PullRequestHoldService:每隔5s重试一次。
      DefaultMessageStore#ReputMessageService,每处理一次重新拉取,Thread.sleep(1),继续下一次检查。ReputMessageService 线程主要是根据CommitLog 将消息转发到ConsumeQueue、Index等文件,当新消息达到 CommitLog 时,ReputMessageService 线程负责将消息转发给 ConsumeQueue、IndexFile,如果 Broker 端开启了长轮询模式并且角色主节点,则最终将调用 PullRequestHoldService 线程的 notifyMessageArriving 方法唤醒挂起线程,判断当前消费队列最大偏移量是否大于待拉取偏移量,如果大于则拉取消息。长轮询模式使得消息拉取能实现准实时。

     

    四、消息队列负载与重新分布机制

      集群模式下,多个消费者如何对消息队列进行负载呢?消息队列分配遵循一个消费者可以分配多个消息队列,但同一个消息队列只会分配给一个消费者,故如果消费者个数大于消息队列数量,则有些消费者无法消费消息。

    RocketMQ是针对单个主题进行消息队列重新负载(以集群模式)

      Step1:从主题订阅信息缓存表中获取主题的队列信息;发送请求从Broker中该消费组内当前所有的消费者客户端ID,主题topic的队列可能分布在多个Broker上,那请发往哪个Broker呢?RocketeMQ从主题的路由信息表中随机选择一个Broker。Broker为什么会存在消费组内所有消费者的信息呢?消费者会向所有的Broker发送心跳包。
      Step2:首先对cidAll,mqAll排序,这个很重要,同一个消费组内看到的视图保持一致,确保同一个消费队列不会被多个消费者分配。

      RocketMQ默认提供5种分配算法。
      1)AllocateMessageQueueAveragely:平均分配,推荐指数为5颗星。
      2)AllocateMessageQueueAveragelyByCircle:平均轮询分配,推荐指数为5颗星。
      3)AllocateMessageQueueConsistentHash:一致性hash。不推荐使用,因为消息队列负载信息不容易跟踪。
      4)AllocateMessageQueueByConfig:根据配置,为每一个消费者配置固定的消息队列。
      5)AllocateMessageQueueByMachineRoom:根据Broker部署机房名,对每个消费者负责不同的Broker上的队列。

      Step3:ConcurrentMap<MessageQueue, ProcessQueue>processQueueTable,当前消费者负载的消息队列缓存表,如果缓存表中的MessageQueue不包含在mqSet中,说明经过本次消息队列负载后,该mq被分配给其他消费者,故需要暂停该消息队列消息的消费
      Step4:遍历本次负载分配到的队列集合,如果processQueueTable中没有包含该消息队列,表明这是本次新增加的消息队列,首先从内存中移除该消息队列的消费进度,然后从磁盘中读取该消息队列的消费进度,创建PullRequest对象。
      Step5:将PullRequest加入到PullMessageService中,以便唤醒PullMessageService线程。


    问题1:PullRequest对象在什么时候创建并加入到pullRequestQueue中以便唤醒PullMessageService线程。

      RebalanceService线程默认每隔20s对消费者订阅的主题进行一次队列重新分配,每一次分配都会获取主题的所有队列、从Broker服务器实时查询当前该主题该消费组内消费者列表,对新分配的消息队列会创建对应的PullRequest对象。在一个JVM进程中,同一个消费组同一个队列只会存在一个PullRequest对象。

    问题2:集群内多个消费者是如何负载主题下的多个消费队列,并且如果由新的消费者加入时,消息队列又会如何重新分布。

      由于每次进行队列重新负载时会从Broker实时查询出当前消费组内所有消费者,并且对消息队列、消费者列表进行排序,这样新加入的消费者就会在队列重新分布时分配到消费队列从而消费消息。

     

    五、消息消费过程

      回顾消息拉取,PullMessageService负责对消息队列进行消息拉取,从远端服务器拉取消息后将消息存入ProcessQueue消息队列处理队列中,然后调用ConsumeMessageService#submitConsumeRequest方法进行消息消费,使用线程池来消费消息,确保了消息拉取与消息消费的解耦。

    消息消费  

      Step1:consumeMessageBatchMaxSize,消息批次,在这里看来也就是一次消息消费任务ConsumeRequest中包含的消息条数,默认为1, msgs.size()默认最多为32条,如果msgs.size()小于consumeMessageBatchMaxSize,则直接将拉取到的消息放入到ConsumeRequest中,然后将consumeRequest提交到消息消费者线程池中,如果提交过程中出现拒绝提交异常则延迟5s再提交。

      Step2:如果拉取的消息条数大于consumeMessageBatchMaxSize,则对拉取消息进行分页,每页consumeMessageBatchMaxSize条消息,创建多个ConsumeRequest任务并提交到消费线程池。
      Step3:进入具体消息消费时会先检查processQueue的dropped,如果设置为true,则停止该队列的消费,在进行消息重新负载时如果该消息队列被分配给消费组内其他消费者后,需要droped设置为true,阻止消费者继续消费不属于自己的消息队列。
      Step4:执行消息消费钩子函数ConsumeMessageHook#consumeMessageBefore函数。
      Step5:恢复重试消息主题名。RocketMQ将消息存入commitlog文件时,如果发现消息的延时级别delayTimeLevel大于0,会首先将重试主题存入在消息的属性中,然后设置主题名称为SCHEDULE_TOPIC,以便时间到后重新参与消息消费。
      Step6:执行具体的消息消费,调用应用程序消息监听器的consumeMessage方法,进入到具体的消息消费业务逻辑,返回该批消息的消费结果。最终将返回CONSUME_SUCCESS(消费成功)或RECONSUME_LATER(需要重新消费)。
      Step7:执行消息消费钩子函数ConsumeMessageHook#consumeMessageAfter函数。
      Step8:执行业务消息消费后,在处理结果前再次验证一下ProcessQueue的isDroped状态值,如果设置为true,将不对结果进行处理,也就是说如果在消息消费过程中进入到Step4时,如果由于由新的消费者加入或原先的消费者出现宕机导致原先分给消费者的队列在负载之后分配给别的消费者,那么在应用程序的角度来看的话,消息会被重复消费。
      Step9:根据消息监听器返回的结果,计算ackIndex,如果返回CONSUME_SUCCESS, ackIndex设置为msgs.size()-1,如果返回RECONSUME_LATER, ackIndex=-1,这是为发送msg back(ACK)消息做准备的。
      Step10:如果是广播模式,业务方返回RECONSUME_LATER,消息并不会重新被消费,只是以警告级别输出到日志文件。如果是集群模式,消息消费成功,并不会执行sendMessageBack。只有在业务方返回RECONSUME_LATER时,该批消息都需要发ACK消息,如果消息发送ACK失败,则直接将本批ACK消费发送失败的消息再次封装为ConsumeRequest,然后延迟5s后重新消费。
      Step11:从ProcessQueue中移除这批消息,这里返回的偏移量是移除该批消息后最小的偏移量,然后用该偏移量更新消息消费进度,以便在消费者重启后能从上一次的消费进度开始消费,避免消息重复消费。

    消息确认(ACK)、消息重试

      如果消息监听器返回的消费结果为RECONSUME_LATER,则需要将这些消息发送给Broker延迟消息。如果发送ACK消息失败,将延迟5s后提交线程池进行消费。
      Step1:获取消费组的订阅配置信息,如果配置信息为空返回配置组信息不存在错误,如果重试队列数量小于1,则直接返回成功,说明该消费组不支持重试。
      Step2:创建重试主题,重试主题名称:%RETRY%+消费组名称,并从重试队列中随机选择一个队列,并构建TopicConfig主题配置信息。
      Step3:根据消息物理偏移量从commitlog文件中获取消息,同时将消息的主题存入属性中
      Step4:设置消息重试次数,如果消息已重试次数超过maxReconsumeTimes,再次改变newTopic主题为DLQ("%DLQ%"),该主题的权限为只写,说明消息一旦进入到DLQ队列中,RocketMQ将不负责再次调度进行消费了,需要人工干预。
      Step5:根据原先的消息创建一个新的消息对象,重试消息会拥有自己的唯一消息ID(msgId)并存入到commitlog文件中,并不会去更新原先消息,而是会将原先的主题、消息ID存入消息的属性中,主题名称为重试主题,其他属性与原先消息保持相同。
      Step6:将消息存入到CommitLog文件中。在存入CommitLog文件之前,如果消息的延迟级别delayTimeLevel大于0,替换消息的主题与队列为定时任务主题"SCHEDULE_TOPIC_XXXX",队列ID为延迟级别减1。再次将消息主题、队列存入消息的属性中。

      ACK消息存入CommitLog文件后,将依托RocketMQ定时消息机制在延迟时间到期后再次将消息拉取,提交消费线程池。ACK消息是同步发送的,如果在发送过程中出现错误,将记录所有发送ACK消息失败的消息,然后再次封装成ConsumeRequest,延迟5s执行。

    消息进度管理

      消息消费者在消费一批消息后,需要记录该批消息已经消费完毕,否则当消费者重新启动时又得从消息消费队列的开始消费,这显然是不能接受的。一次消息消费后会从ProcessQueue处理队列中移除该批消息,返回ProcessQueue最小偏移量,并存入消息进度表中。

      那消息进度文件存储在哪合适呢?

      广播模式:消息消费进度存储在消费者本地,默认每5s持久化一次。同一个消费组的所有消息消费者都需要消费主题下的所有消息,也就是同组内的消费者的消息消费行为是对立的,互相不影响,故消息进度需要独立存储,最理想的存储地方应该是与消费者绑定。
      集群模式:消息进度存储文件存放在消息服务端Broker,Broker端默认10s持久化一次消息进度。同一个消费组内的所有消息消费者共享消息主题下的所有消息,同一条消息(同一个消息消费队列)在同一时间只会被消费组内的一个消费者消费,并且随着消费队列的动态变化重新负载,所以消费进度需要保存在一个每个消费者都能访问到的地方。

     

    提高Consumer处理能力

      当Consumer的处理速度跟不上消息的产生速度,会造成越来越多的消息积压,这个时候首先查看消费逻辑本身有没有优化空间,除此之外还有三种方法可以提高Consumer的处理能力。
    1)提高消费并行度
      在同一个ConsumerGroup下(Clustering方式),可以通过增加Consumer实例的数量来提高并行度,通过加机器,或者在已有机器中启动多个Consumer进程都可以增加Consumer实例数。注意总的Consumer数量不要超过Topic下Read Queue数量,超过的Consumer实例接收不到消息。此外,通过提高单个Consumer实例中的并行处理的线程数,可以在同一个Consumer内增加并行度来提高吞吐量(设置方法是修改consumeThreadMin和consumeThreadMax)。
    2)以批量方式进行消费
      某些业务场景下,多条消息同时处理的时间会大大小于逐个处理的时间总和,比如消费消息中涉及update某个数据库,一次update10条的时间会大大小于十次update1条数据的时间。这时可以通过批量方式消费来提高消费的吞吐量。实现方法是设置Consumer的consumeMessageBatchMaxSize这个参数,默认是1,如果设置为N,在消息多的时候每次收到的是个长度为N的消息链表。
    3)检测延时情况,跳过非重要消息
      Consumer在消费的过程中,如果发现由于某种原因发生严重的消息堆积,短时间无法消除堆积,这个时候可以选择丢弃不重要的消息,使Consumer尽快追上Producer的进度。

     

  • 相关阅读:
    自由职业者:如何找到你的第一个客户 【转载】
    MPI教程地址
    多线程程序设计.....
    PE格式的深入理解(一)
    用Hook解决在VC++与C++Builder方面界面设计的一些问题
    ANSI,MBCS,Unicode与使用swprintf的陷阱
    关于单片机程序
    C#操作SharePoint列表
    CAML中比较日期时间类型[转]
    使用C#创建webservice及三种调用方式
  • 原文地址:https://www.cnblogs.com/zjxiang/p/15025917.html
Copyright © 2020-2023  润新知