RocketMQ-消息消费介绍

82 阅读7分钟

我正在参加「掘金·启航计划」

本篇主要介绍RocketMQ消息消费流程

1、概念介绍

1.1、Consumer获取消息的方式

常见的两种方式:

方式一:Broker与Consumer建立长连接,Broker接收到消息后主动推送到Consumer

方式二:Consumer定时轮询Broker,主动获取消息

常见的两种方式可能存在的问题:

方式一 长连接:

  • Consumer一直被动接收Broker推送的消息,如果本身消费消息很慢,会造成后续大批量消息挤压,超时

方式二 定时轮询(短轮询):

  • 比如Consumer每0.5秒请求Broker主动拉取消息,很有可能Broker几个小时都没有消息,太多无用的请求
RocketMQ获取消息的方式:短连接-长轮训

简单点说是,Consumer主动从Broker拉取消息,Broker没有消息不会立即返回,会将连接挂起一段时间,等有消息后再唤醒连接返回消息,如果到指定时间一直没有消息则会返回。

短连接-长轮训的好处:

  1. Consumer主动拉取消息,控制消费速度,不会有大批量消息挤压问题
  2. Broker没有消息后会将连接挂起,不会造成频繁的请求浪费

我们实际使用时,消费消息会实现MessageListener子类接口,感觉消息像是主动推送过来的,其实是Consumer拉取回来后,再调用MessageListener接口,供我们进行消费

1.2、消费模式

通过获取消息方式,我们知道Consumer在保证自己可以消费的前提下,主动去Broker拉取消息。我们想到简单的实现方式:一个死循环,拉取消息回来然后消费,消费完再继续拉取。然后这种方式会将拉取、消费逻辑全部耦合在一起,不方便维护及扩展。

Consumer消费模式:生产者-消费者模式

有专门负责生产拉取请求的线程,有专门负责消费拉取请求的线程,两者之间互不干涉。

2、流程分析

2.1、拉取请求

PullRequest:拉取请求的封装,定义了拉取消息需要的参数,以及存放拉取回来的消息。

public class PullRequest implements MessageRequest {
    // 消费组 
    private String consumerGroup;
    // 消息队列数据
    private MessageQueue messageQueue;
    // 消息处理队列
    private ProcessQueue processQueue;
    // 下次消息拉取偏移量
    private long nextOffset;
    private boolean previouslyLocked = false;
}
​
/*
 * 消息队列
 */
public class MessageQueue  {
    // broker名称
    private String brokerName;
    // topic名称
    private String topic;
    // 队列Id
    private int queueId;
}
​
/*
 * 处理队列
 */
public class ProcessQueue {
    
    // 针对msgTreeMap的读写锁
    private final ReadWriteLock treeMapLock = new ReentrantReadWriteLock();
    // 存放拉取回来,未被消费的消息
    private final TreeMap<Long, MessageExt> msgTreeMap = new TreeMap<Long, MessageExt>();
    // 未被消费的消息总数
    private final AtomicLong msgCount = new AtomicLong();
    // 未被消费的消息总大小
    private final AtomicLong msgSize = new AtomicLong();
    // 顺序消息第2把锁:锁PrcessQueue
    // 负载均衡生产拉取请求、消费消息时会使用
    private final Lock consumeLock = new ReentrantLock();
    // 顺序消息时使用
    private final TreeMap<Long, MessageExt> consumingMsgOrderlyTreeMap = new TreeMap<Long, MessageExt>();
    private final AtomicLong tryUnlockTimes = new AtomicLong(0);
    private volatile long queueOffsetMax = 0L;
    // 处理队列是否被移除
    private volatile boolean dropped = false;
    // 顺序消息第1把锁:分布式锁,每30s会去Broker获取锁,同一个messageQueue只能被一个Consume获取
    // 拉取消息、消费消息时会判断
    private volatile boolean locked = false;
    // 当前是否正在消费【待办,什么时候被再次设为false】
    private volatile boolean consuming = false;
}

2.2、生产拉取请求

Consumer生产拉取请求不是随便指定消息队列,需要根据Group下的Consumer实例数与Topic的消息队列数做负载,选择一个消息队列,进行拉取。

举个例子:

TopicA下有4个消息队列(q1~q4),ConsumerA(集群模式)订阅TopicA,同Group下有2个实例(C1、C2),因为要做到消费负载均衡,C1、C2实例会平分q1~q4队列进行消费,结果可能C1实例有q1、q2两个拉取请求,C2实例有q3、q4两个拉取请求。

生产拉取请求具体实现:

RebalanceService:负责负载均衡,生产拉取请求

执行流程:

  1. RebalaceService#run,每20s进行一次负载均衡

  2. MQClientInstance#doRebalance,循环每个消费组下的mqConsumerInner#doRebalance(MQConsumerInner是Consumer内部的实现类,MQConsumer暴露给外部使用)

  3. mqConsumerInner.rebalanceImpl#doRebalance,循环topic下的订阅信息SubscriptionData( Consumer订阅的topic信息与重试topic:%RETRY%+GROUPNAME)

  4. rebalanceImpl#rebalanceByTopic,根据Topic负载均衡

    1. 获取topic下全部MessageQueue(如果是集群会获取所有消息队列),根据topic,consumerGroup获取到所有的client
    2. 根据不同的负载策略AllocateMessageQueueStrategy,得到当前实例分配的MessageQueue
    3. 移除本地不存在的ProcessQueue,设置dropped为true。如果是顺序消费则不会删除
    4. 新创建PullRequest,并分发#dispatchPullRequest,DefaultMQPushConsumerImpl#executePullRequestImmediately,将拉取请求设置到 拉取队列 PullMessageService.messageRequestQueue中等待被消费

2.3、消费拉取请求

PullMessageService: 负责消费拉取请求

执行流程:

  • PullMessageService#run,while循环中消费 拉取请求队列中的拉取请求,如果没有则阻塞

  • 如果有拉取请求,通过MQClinetInstance#selectConsumer获取到MQConsumerInner来拉取消息

  • 拉取消息流程,DefaultMQPushConsumerImpl#pullMessage

    1. 判断PullRequest.processQueue 是否已被移除

    2. 判断是否需要限流,以下情况需要进行消息限流:

      1. 判断堆积消费数量是否大于1000,取值ProcessQueue.msgCount。延迟50ms再放入拉取请求队列
      2. 判断堆积消息大小是否大于100M,取值ProcessQueue.msgSize。延迟50ms再放入拉取请求队列
      3. 非顺序消费,判断最大 - 最小偏移量是否大于2000(避免造成大量重复消费,消费位点按照最小offset同步给broker),ProcessQueue.msgTreeMap 最后一个 - 第一个。延迟50ms再放入拉取请求队列
      4. 顺序消息,判断是否获取到消费第一把锁。没有的话延迟3s,将请求放回拉取请求队列
    3. 获取topic对用的订阅信息

    4. 创建拉取回调,PullCallback

    5. 封装请求参数,如:MessageQueue信息、消息偏移量、一次拉取多少条(32)、Broker挂起时间(15s)等

    6. 通过MQClientAPIImpl#pullMessage,向broker发送消息拉取请求

      • 同步模式:RemotingClient#invokeSync,同步等待结果
      • 异步模式:RemotingClient#invokeAsync,有结果后调用PullCallback

2.4、处理拉取回来的消息

PullCallback处理拉取回来的消息,分为两种情况,拉取成功与失败。

拉取失败:

拉取请求再次放到拉取请求队列中,等待下次消费。

拉取成功:

拉取成功分为几种状态:

  • FOUND: 有符合的消息
  • NO_NEW_MSG、NO_MATCHED_MSG: 没有新消息、消息在broker端被过滤掉,没有匹配的消息
  • OFFSET_ILLEGAL:当请求的offset小于minOffset时,会返回该状态
不同拉取状态处理方式:

NO_NEW_MSG、NO_MATCHED_MSG:拉取请求再次放到拉取请求队列中,等待下次消费。

OFFSET_ILLEGAL:将ProcessQueue移除

FOUND:

  1. 将拉取返回的消息偏移量,更新到拉取请求大下次拉取偏移量中。

  2. 将拉取回来消息放到处理队列中,ProcessQueue#putMessage

    1. 将消息新增到msgTreeMap中
    2. 增加未消费的消息数量,msgCount
    3. 增加未消费的消息大小,msgSize
    4. 如果ProcessQueue没有正在消费,consuming为false,则返回dispatchToConsume=true且标记consuming=true
  3. 将拉取回来的消息,封装成ConsumeRequest,提交到线程池中进行处理,ConsumeMessageService#submitConsumeRequest

    不同模式下dispatchToConsume值处理方式不同:

    • 并发模式:不会关心dispatchToConsume的值
    • 顺序模式:根据dispatchToConsume判断,如果是false则不进行处理
  4. 再次将拉取请求设置到拉取请求队列

ConsumeRequest处理流程

并发ConsumeRequest处理流程:

  1. 判断队列是否已被移出,ProcessQueue.dropped

  2. 消费消息:MessageListener#consumeMessage

  3. 处理消费结果,#processConsumeResult

    1. 是否需要再次消费消息,当#consumeMessage返回:重新消费标志、null、程序异常时

      1. Consumer发送延迟消息,Broker会为消费组生成%RETRY%consumerGroup的Topic(Consumer启动时会额外定义这个Topic)
      2. 延迟时间为10s(设置延迟级别delayLevel=0,broker端会+3),最多重试16次,之后会进入死信队列
      3. 延迟消息发送失败,等待5s后,本地再次消费ConsumeMessageConcurrentlyService#submitConsumeRequest
    2. 同步消费进度,OffsetStore#updateOffset,存储到一个Map中offsetTable,定时同步到broker

顺序ConsumeRequest处理流程:

  1. 判断队列是否已被移出,ProcessQueue.dropped

  2. 顺序消费第3把锁,对MessageQueue进行加锁,防止多线程同时处理一个MessageQueue

  3. 获取顺序消费第2把锁,对ProcessQueue进行加锁,ProcessQueue.consumeLock

  4. 消费消息

  5. 处理消费结果

    1. 消息重试,不会发送延迟消息,等待1s后,本地再次消费ConsumeMessageOrderlyService#submitConsumeRequest
    2. 同步消费进度,OffsetStore#updateOffset,存储到一个Map中offsetTable,定时同步到broker