RocketMQ源码学习(四) —— 消息消费

169 阅读33分钟

4.RocketMQ消息消费

4.1消息消费概述

消息消费以组的模式开展,一个消费组可以包含多个消费者,每一个消费者组可订阅多个主题,消费组之间有集群模式与广播模式两种消费模式。

  • 集群模式:主题下的同一条消息只允许被其中一个消费者消费
  • 广播模式:主题下的同一条消息将被集群内的所有消费者消费一次

消息服务器与消费者之间的消息传送也有两种方式:推模式、拉模式

  • 拉模式:消费端主动发起拉取消息请求
  • 推模式:消息到达消息服务器后,推送给消息消费者

RocketMQ消息推模式的实现基于拉模式,在拉模式上包装一层,一个拉取任务完成后开始下一个拉取任务

集群模式下,消息队列负载机制遵循一个通用的思想:一个消息队列同一时间只允许被一个消费者消费,一个消费者可以消费多个消息队列。

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

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

消息拉模式,主要是由客户端手动调用消息拉取API,而消息推模式是消息服务器主动将消息推送到消息消费端,下面主要介绍推模式:

推模式的消费者MQPushConsume的主要API:

public interface MQPushConsumer extends MQConsumer {
​
    void start() throws MQClientException;
    void shutdown();
    @Deprecated
    void registerMessageListener(MessageListener messageListener);
    // 注册并发消息事件监听器
    void registerMessageListener(final MessageListenerConcurrently messageListener);
    // 注册顺序消费事件监听器
    void registerMessageListener(final MessageListenerOrderly messageListener);
    /**
     * 基于主题订阅消息
     * @param topic 消息主题
     * @param subExpression 消息过滤表达式,TAG或SQL92表达式
     */
    void subscribe(final String topic, final String subExpression) throws MQClientException;
    // 使用类模式进行消息过滤,被弃用
    @Deprecated
    void subscribe(final String topic, final String fullClassName,
                                    final String filterClassSource) throws MQClientException;
    void subscribe(final String topic, final MessageSelector selector) throws MQClientException;
    // 取消消息订阅
    void unsubscribe(final String topic);
    void updateCorePoolSize(int corePoolSize);
    void suspend();
    void resume();
}
​
​
public interface MQConsumer extends MQAdmin {
​
     /**
     * 消息消费失败,重新发送到Broker服务器
     * @param msg 消息
     * @param delayLevel 消息延迟级别
     * @param brokerName 消息服务器名称
     */    
    void sendMessageBack(final MessageExt msg, final int delayLevel, final String brokerName)
        throws RemotingException, MQBrokerException, InterruptedException, MQClientException;
    /**
     * 获取消费者对主题topic分配了哪些消息队列
     * @param topic 主题名称
     * @return queue set
     */
    Set<MessageQueue> fetchSubscribeMessageQueues(final String topic) throws MQClientException;
}
​
// 推模式消息消费者
public class DefaultMQPushConsumer extends ClientConfig implements MQPushConsumer {
​
    private final InternalLogger log = ClientLogger.getLog();
    protected final transient DefaultMQPushConsumerImpl defaultMQPushConsumerImpl;
    // 消费者所属组
    private String consumerGroup;
    // 消息消费模式:集群模式、广播模式,默认为集群模式
    private MessageModel messageModel = MessageModel.CLUSTERING;
    // 根据消息进度从消息服务器拉取不到消息时重新计算消费策略,默认从队列当前最大偏移量开始消费
    // CONSUME_FROM_FIRST_OFFSET:从队列当前最小偏移量开始消费
    // CONSUME_FROM_TIMESTAMP:从消费者启动时间戳开始消费
    // PS:如果从消息进度服务OffsetStore读取到的MseeageQueue中的偏移量不小于0,则使用读取到的偏移量,只有在读到的偏移量小于0时,上述策略才会生效
    private ConsumeFromWhere consumeFromWhere = ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET;
    private String consumeTimestamp = UtilAll.timeMillisToHumanString3(System.currentTimeMillis() - (1000 * 60 * 30));
    // 集群模式下消息队列负载策略
    private AllocateMessageQueueStrategy allocateMessageQueueStrategy;
    // 订阅信息
    private Map<String /* topic */, String /* sub expression */> subscription = new HashMap<String, String>();
    // 消息业务监听器
    private MessageListener messageListener;
    // 消息消费进度存储器
    private OffsetStore offsetStore;
    // 消费者最小线程数
    private int consumeThreadMin = 20;
    // 消费者最大线程数,由于消费者线程池使用无界队列,故消费者线程个数最多只有consumeThreadMin个
    private int consumeThreadMax = 20;
    // 线程池数量的动态调整阈值
    private long adjustThreadPoolNumsThreshold = 100000;
    // 并发消息消费时处理队列最大跨度,默认2000,表示如果消息处理队列中偏移量最大的消息和偏移量最小的消息跨度超过2000,则延迟50ms后再拉取消息
    private int consumeConcurrentlyMaxSpan = 2000;
    // 默认值1000,每1000次流控后打印流控日志
    private int pullThresholdForQueue = 1000;
    // 在队列级别限制缓存的消息大小,默认情况下每个消息队列最多缓存100条MiB消息,
    private int pullThresholdSizeForQueue = 100;
    private int pullThresholdForTopic = -1;
    private int pullThresholdSizeForTopic = -1;
    // 推模式下拉取任务间隔时间,默认一次拉取任务完成继续拉取
    private long pullInterval = 0;
    // 消息并发消费时一次消费消息条数
    private int consumeMessageBatchMaxSize = 1;
    // 每次消息拉取所拉取的条数,默认32条
    private int pullBatchSize = 32;
    // 是否每次拉取消息都要更新订阅信息,默认为false
    private boolean postSubscriptionWhenPull = false;
    private boolean unitMode = false;
    // 最大消费重试次数。如果消息消费次数超过该值还未成功,则将该消息转移到一个失败队列,等待被删除
    private int maxReconsumeTimes = -1;
    // 延迟将该队列的消息提交到消费者线程的等待时间,默认1s
    // 对于需要缓慢拉动的情况(如流量控制场景),暂停拉动时间。
    private long suspendCurrentQueueTimeMillis = 1000;
    // 消息可能阻塞正在使用的线程的最长时间(以分钟为单位)。
    private long consumeTimeout = 15;
    // 关闭使用者时等待消息的最长时间,0表示没有等待。
    private long awaitTerminationMillisWhenShutdown = 0;
    // 异步传输数据接口
    private TraceDispatcher traceDispatcher = null;
​

4.2消费者启动流程

DefaultMQPushConsumerImpl#start

  • Step1:构建主题订阅信息SubscriptionData并加入到RebalanceImpl的订阅消息中。订阅关系来源主要有两个:

    • 通过调用DefaultMQPushConsumerImpl#subscribe(String topic, String subExpression)方法
    • 订阅重试主题消息。RocketMQ消息重试是以消费组为单位,而不是主题,消息重试主题名为%RETRY&+消费组名,消费者再启动时会自动订阅该主题,参与该主题的消息队列负载
  • Step2:初始化MQClientInstance、RebalanceImpl(消息重新负载实现类)、DefaultMQPushCOnsumer等

  • Step3:初始化消息进度。

    • 如果消息是集群模式,那么消息进度保存在Broker上;
    • 如果消息是广播模式,那么消息消费进度存储在消费端
  • Step4:根据是否是顺序消费,创建消费端消费线程服务

    • ConsumeMessageService主要负责消息消费,内部维护一个线程池
  • Step5:向MQClientInstance注册消费者,并启动MQClientInstance,在一个JVM中的所有消费者、生产者持有同一个MQClientInstance,MQClientInstance只会启动一次

4.3消息拉取

基于PUSH模式分析消息拉取机制,PULL模式下的消息拉取只需要应用程序显示调用拉取API

消息消费有两种模式:广播模式与集群模式

广播模式中每一个消费者需要去拉取订阅主题下所有消费队列的消息

集群模式下,同一个消费组内有多个消息消费者,同一个主题下存在多个消费队列,每个消费组内维护一个线程池来消费消息

RocketMQ使用一个单独的线程PullMessageService负责消息的拉取

消息拉取流程图:

image.png PullMessageService线程与RebalanceService线程交互图:

image.png

1.PullMessageService实现机制

PullMessageService继承ServiceThread

       // PullMessageService#run
       @Override
        public void run() {
            log.info(this.getServiceName() + " service started");

            // stopped声明为volatile,每执行一次业务逻辑检测一下其运行状态,可以通过其它线程将stopped设置为true从而停止该线程
            while (!this.isStopped()) {
                try {
                    // 从pullRequestQueue中获取一个PullRequest消息拉取任务,如果pullRequestQueue为空,线程会被阻塞
                    /**
                    * PullRequest拉取任务执行完成之后会将该对象重新放回pullRequestQueue
                    * 在RebalanceImpl中会创建PullRequest对象
                    */
                    PullRequest pullRequest = this.pullRequestQueue.take();
                    // 进行消息拉取
                    this.pullMessage(pullRequest);
                } catch (InterruptedException ignored) {
                } catch (Exception e) {
                    log.error("Pull Message Service Run Method exception", e);
                }
            }

            log.info(this.getServiceName() + " service end");
        }
public class PullRequest {
    private String consumerGroup;				// 消费者组
    private MessageQueue messageQueue;			// 待拉取消费队列
    // 消息处理队列,从Broker拉取到的消息先存入ProcessQueue,然后再提交消费者消费线程池消费
    private ProcessQueue processQueue;
    private long nextOffset;					// 待拉取的messageQueue偏移量
    private boolean previouslyLocked = false;	// 是否被锁定
}
// PullMessageService#pullMessage
// 消息拉取
private void pullMessage(final PullRequest pullRequest) {
    // 根据消费组名从MQClientInstance中获取消费者内部实现类MQConsumerInner
    final MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup());
    if (consumer != null) {
        // 将MQConsumerInner强转为DefaultMQPushConsumerImpl,表示只为PUSH模式服务
        DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl) consumer;
        impl.pullMessage(pullRequest);
    } else {
        log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
    }
}

2.ProcessQueue实现机制

ProcessQueue是MessageQueue在消费端的重现、快照。PullMessageService从消息服务器默认每次拉取32条消息,按消息的队列偏移量顺序存放在ProcessQueue中,PullMessageService然后将消息提交到消费者消费线程池,消息成功消费后从ProcessQueue中移除

public class ProcessQueue {
	// 读写锁,控制多线程并发修改msgTreeMap
    private final ReadWriteLock treeMapLock = new ReentrantReadWriteLock();
    // 消息存储容器,键为消息在COnsumeQueue中的偏移量,值为消息实体
    private final TreeMap<Long, MessageExt> msgTreeMap = new TreeMap<Long, MessageExt>();
    // ProcessQueue总消息数
    private final AtomicLong msgCount = new AtomicLong();
    private final AtomicLong msgSize = new AtomicLong();
    private final Lock consumeLock = new ReentrantLock();
    /**
     *msgTreeMap的子集,仅在有序消费时使用
     */
    private final TreeMap<Long, MessageExt> consumingMsgOrderlyTreeMap = new TreeMap<Long, MessageExt>();
    private final AtomicLong tryUnlockTimes = new AtomicLong(0);
    // 当前ProcessQueue中包含的最大队列偏移量
    private volatile long queueOffsetMax = 0L;
    // 当前Processqueue是否被丢弃
    private volatile boolean dropped = false;
    // 上一次开始消息拉取时间戳
    private volatile long lastPullTimestamp = System.currentTimeMillis();
    // 上一次消息消费时间戳
    private volatile long lastConsumeTimestamp = System.currentTimeMillis();
    private volatile boolean locked = false;
    private volatile long lastLockTimestamp = System.currentTimeMillis();
    private volatile boolean consuming = false;
    private volatile long msgAccCnt = 0;
	
    // 锁超时时间默认30s
    public boolean isLockExpired() {
        return (System.currentTimeMillis() - this.lastLockTimestamp) > REBALANCE_LOCK_MAX_LIVE_TIME;
    }
	// 判断PullMessageService是否空闲,默认120s
    public boolean isPullExpired() {
        return (System.currentTimeMillis() - this.lastPullTimestamp) > PULL_MAX_IDLE_TIME;
    }
    // 移除消费超时的消息,默认超过15分钟未消费的消息将延迟3个延迟级别再消费
    public void cleanExpiredMsg(DefaultMQPushConsumer pushConsumer){}
    // 添加消息,PullMessageService拉取消息后,先调用该方法将消息添加到ProcessQueue
    public boolean putMessage(final List<MessageExt> msgs) {}
    // 获取当前消息最大间隔
    public long getMaxSpan() {}
    // 将consumingMsgOrderlyTreeMap中所有消息重新放入msgTreeMap,并清除consumingMsgOrderlyTreeMap
    public void rollback() {
        try {
            this.treeMapLock.writeLock().lockInterruptibly();
            try {
                this.msgTreeMap.putAll(this.consumingMsgOrderlyTreeMap);
                this.consumingMsgOrderlyTreeMap.clear();
            } finally {
                this.treeMapLock.writeLock().unlock();
            }
        } catch (InterruptedException e) {
            log.error("rollback exception", e);
        }
    }
	// 将consumingMsgOrderlyTreeMap中的消息清除,表示成功处理该批消息
    public long commit() {
        try {
            this.treeMapLock.writeLock().lockInterruptibly();
            try {
                Long offset = this.consumingMsgOrderlyTreeMap.lastKey();
                msgCount.addAndGet(0 - this.consumingMsgOrderlyTreeMap.size());
                for (MessageExt msg : this.consumingMsgOrderlyTreeMap.values()) {
                    msgSize.addAndGet(0 - msg.getBody().length);
                }
                this.consumingMsgOrderlyTreeMap.clear();
                if (offset != null) {
                    return offset + 1;
                }
            } finally {
                this.treeMapLock.writeLock().unlock();
            }
        } catch (InterruptedException e) {
            log.error("commit exception", e);
        }

        return -1;
    }

3.并发消息拉取基本流程

  • 消息拉取客户端消息拉取请求封装,向Broker拉取消息
  • 消息服务器查找并返回消息,Broker返回消息
  • 消息拉取客户端处理返回的消息,客户端对返回消息进行处理

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

消息拉取入口:DefaultMQPushConsumerImpl#pullMessage

  • Step1:从PullRequest中获取ProcessQueue,如果处理队列当前状态未被丢弃,则更新ProcessQueue的lastPullTimestamp为当前时间戳;如果当前消费者被挂起,则将拉取任务延迟1s再次放入到PullMessageService的拉取任务队列中,结束本次消息拉取

  • Step2:进行消息拉取流控。从消息消费数量与消费间隔两个维度进行控制

    • 1、消息处理总数,如果ProcessQueue当前处理的消息条数超过了pullThresholdForQueue=1000将触发流控,放弃本次拉取任务,并且该队列的下一次拉取任务将在50ms后才加入到拉取任务队列中,每触发1000次流控后输出提示语
    • 2、ProcessQueue中队列最大偏移量与最小偏移量的间距,不能超过consumeConcurrentlyMaxSpan=2000,否则触发流控,目的是防止一条消息阻塞,消息进度无法向前推进,可能造成大量消息重复消费
  • Step3:拉取该主题订阅信息,如果为空,结束本次消息拉取,并且关于该队列的下一次拉取任务延迟3s

  • Step4:构建消息拉取系统标记PullSysFlag

  • Step5:调用PullAPIWrapper#pullKernelImpl方法与服务端交互

public PullResult pullKernelImpl(
        final MessageQueue mq,			// 从哪个消息消费队列拉取消息
        final String subExpression,		// 消息过滤表达式
        final String expressionType,	// 消息表达式类型,分为TAG、SQL92
        final long subVersion,		
        final long offset,				// 消息拉取偏移量
        final int maxNums,				// 本次拉取最大消息条数,默认32条
        final int sysFlag,				// 拉取系统标记
        final long commitOffset,		// 当前MessageQueue的消费进度(内存中)
        final long brokerSuspendMaxTimeMillis,		// 消息拉取过程中允许Broker挂起时间,默认15s
        final long timeoutMillis,					// 消息拉取超时时间
        final CommunicationMode communicationMode,	// 消息拉取模式,默认为异步拉取
        final PullCallback pullCallback				// 从Broker拉取到消息后的回调方法
    )
  • Step6:根据brokerName、BrokerId从MQClientInstance中获取Broker地址,相同名称的Broker构成主从结构,其BrokerId会不一样,在每次拉取消息后,会给出一个建议,下次拉取是从主节点拉取还是从 从节点 拉取
  • Step7:如果消息过滤模式为类过滤,则需要根据主题名称、broker地址找到注册在Broker上的FilterServer地址,从FilterServer上拉取消息,否则从Broker上拉取消息

上述步骤完成后,RocketMQ通过MQClientAPIImpl#pullMessageAsync方法异步向Broker拉取消息

2)消息服务端Broker组装消息

入口:PullMessageProcessor#processRequest

  • Step1:根据订阅消息,构建消息过滤器

  • Step2:调用MessageStore.getMessage查找消息,该方法参数为:

    • String group:消费组名称
    • String topic:主题名称
    • int queueId:队列ID
    • long offset:待拉取偏移量
    • int maxMsgNums:最大拉取消息条数
    • MessageFilter messageFilter:消息过滤器
  • Step3:根据主题名称与队列编号获取消息消费队列

  • Step4:消息偏移量异常情况校对下一次拉取偏移量

  • Step5:如果待拉取偏移量大于minOffset并且小于maxOffset,从当前offset处尝试拉取32条消息

  • Step6:根据PullRequest填充responseHeader的nextBeginOffset、minOffset、maxOffset

  • Step7:根据主从同步延迟,如果从节点数据包含下一次拉取的偏移量,设置下一次拉取任务的brokerId

  • Step8:根据GetMessageResult编码转换成关系,将GetMessageStatus编码转换成responseCode

  • Step9:如果commitlog标记可用并且当前节点为主节点,则更新消息消费进度

服务端消息拉取处理完毕,将结果返回到拉取消息调用方

3)消息拉取客户端处理消息

回到消息拉取客户端调用入口:MQClientAPIImpl#processPullResponse,NettyRemotingClient在收到服务端响应结构后会回调PullCallback的onSuccess或onException,PullCallBack对象在DefaultMQPushConsumerImpl#pullMessage中创建

  • Step1:根据响应结果解码成PullResultExt对象,将responseCode转换成PullStatus,此时只是从网络中读取消息列表到byte[] messageBinary属性
  • Step2:调用pullAPIWrapper的processPullResult将消息字节数组解码成消息列表填充msgFoundList,并对消息进行消息过滤(TAG)模式
// DefaultMQPushConsumerImpl$PullCallback#onSuccess
pullResult = DefaultMQPushConsumerImpl.this.pullAPIWrapper.processPullResult(pullRequest.getMessageQueue(),                           pullResult,subscriptionData);


public class PullResult {
    private final PullStatus pullStatus;	// 拉取结果
    private final long nextBeginOffset;		// 下次拉取偏移量
    private final long minOffset;			// 消息队列最小偏移量
    private final long maxOffset;			// 消息队列最大偏移量
    private List<MessageExt> msgFoundList;	// 具体拉取的消息列表
}

假设PullResult中的pullStatus为FOUND(找到对应的消息)

  • Step3:更新PullRequset的下一次拉取偏移量,如果msgFoundList为空,则立即将PullRequest放入到PullMessageService的pullRequestQueue,以便PullMessageService能够及时唤醒并再次执行消息拉取

    • 为什么PullStatus.FOUND,msgFoundList还会为空呢?
    • 因为RocketMQ根据TAG进行消息过滤,在服务端只是验证了TAG的hashcode,在客户端再次对消息进行过滤,故可能会出现msgFoundList为空的情况
  • Step4:首先将拉取到的消息(msgFoundList)存入ProcessQueue,然后将拉取到的消息提交到ConsumeMessageService中供消费者消费,该方法是一个异步方法,即PullCallBack将消息提交到ConsumeMessageService中就会立即返回,消息如何消费PullCallBack不关注

  • Step5:将消息提交给消费者线程之后PullCallBack将立即返回,可以说本次消息拉取顺利完成,然后根据pullInterval参数(默认为0),如果pullInterval>0,则等待pullInterval毫秒后将PullRequest对象放入到PullMssageService的pullRequestQueue中,该消息队列的下次拉取即将被激活,达到持续消息拉取,实现准实时拉取消息的效果

如果消息拉取结果为PullStatus.NO_NEW_MSG或PullStatus.NO_MATCHED_MSG,则会进行偏移量校正,使用服务端校正的偏移量进行下一次消息的拉取

如果消息拉取结果为PullStatus.OFFSET_ILLEGAL,会将ProcessQueue设置dropped为true,表示丢弃该消费队列,然后根据服务端下一次校对的偏移量尝试更新消息消费进度(内存中),然后尝试持久化消息消费进度,并将该消息队列从RebalanceImpl的处理队列中移除,意味着暂停该消息队列的消息拉取,等待下一次消息队列重新负载

4.消息拉取长轮询机制分析

RocketMQ并没有真正实现推模式,而是消费者主动向消息服务器拉取消息,RocketMQ推模式是循环向消息服务端发送消息拉取请求

如果消息消费者向RocketMQ发送消息拉取时,消息并未到达消费队列:

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

RocketMQ通过在Broker端配置longPollingEnable为true来开启长轮询模式

消息拉取时服务端从Commitlog未找到消息时的处理逻辑如下:

// PullMessageProcessor#processRequest
    /**
     * 
     * @param channel 网络通道,通过该通道向消息拉取客户端发送响应结果
     * @param request 消息拉取请求
     * @param brokerAllowSuspend Broker端是否支持挂起,处理消息拉取时默认传入true,表示未找到消息则挂起,若为false直接返回客户端消息未找到
     * @return
     */
private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean                                                                                                 brokerAllowSuspend){
	// 省略相关代码
	case ResponseCode.PULL_NOT_FOUND:
		// hasSuspendFlag在拉取消息时构建的拉取标记,默认为true
		if (brokerAllowSuspend && hasSuspendFlag) {
			long pollingTimeMills = suspendTimeoutMillisLong;
            if (!this.brokerController.getBrokerConfig().isLongPollingEnable()) {
            	pollingTimeMills = this.brokerController.getBrokerConfig().getShortPollingTimeMills();
        	}

        	String topic = requestHeader.getTopic();
        	long offset = requestHeader.getQueueOffset();
        	int queueId = requestHeader.getQueueId();
        	PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,
        	this.brokerController.getMessageStore().now(), offset, subscriptionData, messageFilter);
        	this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);
        	response = null;
        	break;
        }
}

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

  • PullRequestHoldService:每隔5s重试一次
  • DefaultMessageStore#ReputMessageService,每处理一次重新拉取,Thread.sleep(1),继续下一次检查

PullRequestHoldService线程详解:

// PullRequestHoldService#suspendPullRequest
public void suspendPullRequest(final String topic, final int queueId, final PullRequest pullRequest放入) {
    // 根据消息主题和消息队列构建key
    String key = this.buildKey(topic, queueId);
    /**
    * protected ConcurrentMap<String /*topic@queueId / , ManyPullRequest> pullRequestTable =
    *    										new ConcurrentHashMap<String, ManyPullRequest>(1024);
    * 从pullRequestTable中获取该主题@队列对应的ManyPullRequest
    */
    ManyPullRequest mpr = this.pullRequestTable.get(key);
    if (null == mpr) {
        mpr = new ManyPullRequest();
        ManyPullRequest prev = this.pullRequestTable.putIfAbsent(key, mpr);
        if (prev != null) {
            mpr = prev;
        }
    }
	// 将pullRequest放入ManyPullRequest
    // ManyPullRequest对象内部持有一个PullRequest列表,
    mpr.addPullRequest(pullRequest);
}

// PullRequestHoldService#run
@Override
public void run() {
    log.info("{} service started", this.getServiceName());
    while (!this.isStopped()) {
        try {
            // 如果开启长轮询,每5s尝试一次,判断新消息是否可达
            if (this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                this.waitForRunning(5 * 1000);
            } else {	// 如果未开启长轮询,则默认等待1s再次尝试
                this.waitForRunning(this.brokerController.getBrokerConfig().getShortPollingTimeMills());
            }

            long beginLockTimestamp = this.systemClock.now();
            
            // 核心逻辑
            this.checkHoldRequest();
            
            long costTime = this.systemClock.now() - beginLockTimestamp;
            if (costTime > 5 * 1000) {
                log.info("[NOTIFYME] check hold request cost {} ms.", costTime);
            }
        } catch (Throwable e) {
            log.warn(this.getServiceName() + " service has exception. ", e);
        }
    }

    log.info("{} service end", this.getServiceName());
}

// PullRequestHoldService#checkHoldRequest
protected void checkHoldRequest() {
    // 遍历拉取任务表
    for (String key : this.pullRequestTable.keySet()) {
        String[] kArray = key.split(TOPIC_QUEUEID_SEPARATOR); // "@"
        if (2 == kArray.length) {
            String topic = kArray[0];
            int queueId = Integer.parseInt(kArray[1]);
            // 根据主题与队列获取消息消费队列最大偏移量
            final long offset = this.brokerController.getMessageStore().getMaxOffsetInQueue(topic, queueId);
            try {
                // 如果该偏移量 > 待拉取偏移量,说明有新消息到达
                // 触发消息拉取
                this.notifyMessageArriving(topic, queueId, offset);
            } catch (Throwable e) {
                log.error("check hold request failed. topic={}, queueId={}", topic, queueId, e);
            }
        }
    }
}

PullRequestHoldService#notifyMessageArriving详解:

  • Step1:首先从ManyPullRequest中获取当前该主题、队列所有的挂起拉取任务
// PullRequestHoldService#notifyMessageArriving
List<PullRequest> requestList = mpr.cloneListAndClear();
// 该方法使用到了synchronized,表明ManyPullRequest即使是私有属性,也存在并发!
public synchronized List<PullRequest> cloneListAndClear() {
    if (!this.pullRequestList.isEmpty()) {
        List<PullRequest> result = (ArrayList<PullRequest>) this.pullRequestList.clone();
        this.pullRequestList.clear();
        return result;
    }

    return null;
}
  • Step2:如果消息队列的最大偏移量 > 待拉取偏移量,如果消息匹配则调用executeRequestWhenWakeup将消息返回给消息拉取客户端,否则等待下一次尝试
// PullRequestHoldService#notifyMessageArriving
if (newestOffset > request.getPullFromThisOffset()) {
    boolean match = request.getMessageFilter().isMatchedByConsumeQueue(tagsCode,
        new ConsumeQueueExt.CqExtUnit(tagsCode, msgStoreTime, filterBitMap));
    if (match && properties != null) {
        match = request.getMessageFilter().isMatchedByCommitLog(null, properties);
    }

    if (match) {
        try {
            this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(),
                request.getRequestCommand());
        } catch (Throwable e) {
            log.error("execute request when wakeup failed.", e);
        }
        continue;
    }
}
  • Step3:如果挂起超时时间超时,则不继续等待将直接返回客户消息未找到
// PullRequestHoldService#notifyMessageArriving
if (System.currentTimeMillis() >= (request.getSuspendTimestamp() + request.getTimeoutMillis())) {
    try {
        this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(),
            request.getRequestCommand());
    } catch (Throwable e) {
        log.error("execute request when wakeup failed.", e);
    }
    continue;
}
  • Step4:这里的核心又回到长轮询的入口代码了,核心是设置brokerAllowSuspend为false,表示不支持拉取线程挂起,即当根据偏移量无法获取消息时将不挂起线程等待新消息到来,而是直接返回告诉客户端本次消息拉取未找到消息
// PullMessageProcessor#executeRequestWhenWakeup
final RemotingCommand response = PullMessageProcessor.this.processRequest(channel, request, false);

当开启了长轮询机制,PullRequestHoldService线程每隔5s会被唤醒去尝试检测是否有新消息的到来直到超时,如果被挂起,需要等待5s,消息拉取的实时性比较差,为了避免这种情况,RocketMQ引入另一种机制:当消息到达时唤醒挂起线程触发一次检查

DefaultMessageStore$ReputMessageService详解:

ReputMessageService线程主要根据Commitlog将消息转发到ConsumeQueue、Index等文件,在doReput方法中存在关于长轮询相关实现

DefaultMessageStore$ReputMessageService#doReput
if (BrokerRole.SLAVE != DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole()
        && DefaultMessageStore.this.brokerConfig.isLongPollingEnable()
        && DefaultMessageStore.this.messageArrivingListener != null) {
    DefaultMessageStore.this.messageArrivingListener.arriving(dispatchRequest.getTopic(),
        dispatchRequest.getQueueId(), dispatchRequest.getConsumeQueueOffset() + 1,
        dispatchRequest.getTagsCode(), dispatchRequest.getStoreTimestamp(),
        dispatchRequest.getBitMap(), dispatchRequest.getPropertiesMap());
    notifyMessageArrive4MultiQueue(dispatchRequest);
}

当新消息到达CommitLog时,ReputMessageService线程负责将消息转发给ConsumeQueue、IndexFile,如果Broker端开启了长轮询模式并且角色为主节点,则最终调用PullRequestHoldService线程的notifyMessageArriving方法唤醒挂起线程,判断当前消费队列最大偏移量是否大于待拉取偏移量,如果大于则拉取消息。长轮询模式使得消息拉取能实现准实时

5.消息队列负载与重新分布机制

PullMessageService在启动时由于private final LinkedBlockingQueue pullRequestQueue = new LinkedBlockingQueue<>();中没有PullRequest对象,故该线程将阻塞

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

    • RebalanceService线程每隔20s对消费者订阅的主题进行一次队列重新分配,每一次分配都会获取主题的所有队列、从Broker服务器实时查询当前该主题该消费组内消费者列表,对新分配的消息队列会创建对应的PullRequest对象
    • 在一个JVM进程中,同一个消费组同一个队列只会存在一个PullRequest对象
  • 问题2:集群内多个消费者是如何负载主题下的多个消费队列,并且如果有新的消费者加入时,消费队列又会如何重新分布

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

RocketMQ消息队列重新分布是由RebalanceService线程来实现的,一个MQClientInstance持有一个 RebalanceService实例,并随着MQClientInstanced的启动而启动

// RebalanceService#run
@Override
public void run() {
    log.info(this.getServiceName() + " service started");

    while (!this.isStopped()) {
        // RebalanceService线程默认每隔20s执行执行一次doRebalance方法
        this.waitForRunning(waitInterval);
        this.mqClientFactory.doRebalance();
    }

    log.info(this.getServiceName() + " service end");
}

// MQClientInstance#doRebalance
public void doRebalance() {
    // 遍历已注册的消费者
    for (Map.Entry<String, MQConsumerInner> entry : this.consumerTable.entrySet()) {
        MQConsumerInner impl = entry.getValue();
        if (impl != null) {
            try {
                // 对消费者执行doRebalance方法
                // 每个DefaultMQPushConsumeerImpl都持有一个单独的RebalanceImpl对象
                impl.doRebalance();
            } catch (Throwable e) {
                log.error("doRebalance exception", e);
            }
        }
    }
}
// RebalanceImpl#doRebalance
public void doRebalance(final boolean isOrder) {
    // subTable在调用消费者DefaultMQPushConsumeerImpl#subscribe方法时填充
    Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
    if (subTable != null) {
        // 遍历订阅信息,对每个主题的队列进行重新负载
        for (final Map.Entry<String, SubscriptionData> entry : subTable.entrySet()) {
            final String topic = entry.getKey();
            try {
                // 针对单个主题进行消息队列重新负载
                this.rebalanceByTopic(topic, isOrder);
                
            } catch (Throwable e) {
                if (!topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                    log.warn("rebalanceByTopic Exception", e);
                }
            }
        }
    }

    this.truncateMessageQueueNotMyTopic();
}

RebalanceImpl#rebalanceByTopic:针对单个主题进行消息队列重新负载(集群模式下)

  • Step1:从主题订阅信息缓存表中获取主题的队列信息,发送请求从Broker中拿到该消费组内的所有消费者客户端ID
// RebalanceImpl#rebalanceByTopic
Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
// 如果mqSet、cidAll任意一个为空则忽略本次消息队列负载
  • Step2:首先对mqAll、cidAll排序,保证同一个消费组内看到的视图保持一致,确保同一个消费队列不会被多个消费者分配
// RebalanceImpl#rebalanceByTopic
List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
mqAll.addAll(mqSet);
Collections.sort(mqAll);
Collections.sort(cidAll);
AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;
List<MessageQueue> allocateResult = null;
try {
    // 消息队列分配算法
    allocateResult = strategy.allocate(
        this.consumerGroup,
        this.mQClientFactory.getClientId(),
        mqAll,
        cidAll);
} catch (Throwable e) {
    log.error("AllocateMessageQueueStrategy.allocate Exception. allocateMessageQueueStrategyName={}", strategy.getName(),
        e);
    return;
}

RocketMQ默认提供5种分配算法:

  • AllocateMessageQueueAveragely:平均分配
  • AllocateMessageQueueAveragelyByCircle:平均轮询分配
  • AllocateMessageQueueConsistentHash:一致性hash,不推荐使用,因为消息队列负载信息不容易跟踪
  • AllocateMessageQueueByConfig:根据配置,为每一个消费者配置固定的消息队列
  • AllocateMessageQueueByMachineRoom:根据Broker部署机房名,对每个消费者负责不同的Broker上的队列

对比消息队列是否发生变化,主要思路是遍历当前负载队列集合,如果队列不在新分配队列集合中,需要将该队列停止消费并保存消费进度;遍历已分配的队列,如果队列不在队列负载表(processQueueTable)则需要创建该队列拉取任务PullRequest,然后添加到PullMessageService线程的pullRequestQueue中,pullMessageService才会继续拉取任务

  • Step3:检查processQueueTable表中的MessageQueue是否存在消息分配结果集合中,如果不存在,暂停该消息队列的消息消费
// RebalanceImpl#rebalanceByTopic
Set<MessageQueue> allocateResultSet = new HashSet<MessageQueue>();
if (allocateResult != null) {
    allocateResultSet.addAll(allocateResult);
}

boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);

// RebalanceImpl#updateProcessQueueTableInRebalance
// processQueueTable:当前消费者负载的消息队列缓存表
Iterator<Entry<MessageQueue, ProcessQueue>> it = this.processQueueTable.entrySet().iterator();
while (it.hasNext()) {
    Entry<MessageQueue, ProcessQueue> next = it.next();
    MessageQueue mq = next.getKey();
    ProcessQueue pq = next.getValue();

    if (mq.getTopic().equals(topic)) {
        // mqSet = allocateResultSet,如果缓存表中的MessageQueue不包含在mqSet中
        if (!mqSet.contains(mq)) {	// 说明经过本次消息队列负载后,该mq被分配给其它消费者
            pq.setDropped(true);	// 需要暂停该消息队列消息的消费
            // 判断是否将MessageQueue、ProcessQueue从缓存表中移除
            if (this.removeUnnecessaryMessageQueue(mq, pq)) { 
                it.remove();
                changed = true;
                log.info("doRebalance, {}, remove unnecessary mq, {}", consumerGroup, mq);
            }
        } 
    }
}
  • Step4:处理消息分配表中新增的消息,从磁盘读取该消息队列的消费进度,创建并初始化PullRequest对象
// RebalanceImpl#updateProcessQueueTableInRebalance
List<PullRequest> pullRequestList = new ArrayList<PullRequest>();
for (MessageQueue mq : mqSet) {	// 遍历本次负载分配到的消息队列集合
    if (!this.processQueueTable.containsKey(mq)) {	// 如果是新增的消息
        if (isOrder && !this.lock(mq)) {
            log.warn("doRebalance, {}, add a new mq failed, {}, because lock failed", consumerGroup, mq);
            continue;
        }

        this.removeDirtyOffset(mq);	// 从内存中移除该消息队列的消费进度
        ProcessQueue pq = new ProcessQueue();	

        long nextOffset = -1L;
        try {
            /**
            * 从磁盘中读取该消息队列的消费进度
            * 如果读取到的偏移量>0,直接返回,
            * 	若读取到的偏移量<-1,表示该消息进度文件中存储了错误的偏移量,返回-1,
            * 	若读取到的偏移量<0,根据不同消费策略特殊处理
            * CONSUME_FROM_LAST_OFFSET(默认):若=-1,返回该消息队列当前最大偏移量
            * CONSUME_FROM_FIRST_OFFSET:若=-1,直接返回0
            * CONSUME_FROM_TIMESTAMP;若=-1,尝试操作消息存储时间戳为消费者启动的时间戳,若找到直接返回该值,否则返回0
            */
            nextOffset = this.computePullFromWhereWithException(mq);
        } catch (Exception e) {
            log.info("doRebalance, {}, compute offset failed, {}", consumerGroup, mq);
            continue;
        }

        if (nextOffset >= 0) {
            ProcessQueue pre = this.processQueueTable.putIfAbsent(mq, pq);
            if (pre != null) {
                log.info("doRebalance, {}, mq already exists, {}", consumerGroup, mq);
            } else {
                log.info("doRebalance, {}, add a new mq, {}", consumerGroup, mq);
                // 创建PullRequest对象
                PullRequest pullRequest = new PullRequest();
                pullRequest.setConsumerGroup(consumerGroup);
                pullRequest.setNextOffset(nextOffset);
                pullRequest.setMessageQueue(mq);
                pullRequest.setProcessQueue(pq);
                pullRequestList.add(pullRequest);
                changed = true;
            }
        } else {
            log.warn("doRebalance, {}, add new mq failed, {}", consumerGroup, mq);
        }
    }
}
  • Step5:将PullRequest对象加入到PullMessageService中,以便唤醒PullMessageService线程
// RebalanceImpl#updateProcessQueueTableInRebalance
this.dispatchPullRequest(pullRequestList);

public void dispatchPullRequest(List<PullRequest> pullRequestList) {
    for (PullRequest pullRequest : pullRequestList) {
        this.defaultMQPushConsumerImpl.executePullRequestImmediately(pullRequest);
        log.info("doRebalance, {}, add a new pull request {}", consumerGroup, pullRequest);
    }
}

6.消息消费过程

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

消息消费使用到的线程池中的任务队列是LinkedBlockingQueue

RocketMQ使用ConsumeMessageService来实现消息消费的处理逻辑,支持顺序消费与并发消费

消息消费类图:本节重点关注并发消费的消费流程

image.png 核心方法描述如下:

  • ConsumeMessageDirectlyResult consumeMessageDirectly(final MessageExt msg, final String brokerName):直接消费消息,主要用于通过管理命令收到消费消息

    • MessageExt msg:消息
    • String brokerName:Broker名称
  • void submitConsumeRequest( // 提交消息消费 final List msgs, :消息列表,默认一次从服务器最大拉取32条 final ProcessQueue processQueue, :消息处理队列 final MessageQueue messageQueue, :消息所属消费队列 final boolean dispathToConsume); :是否转发到消费线程池,并发消费时忽略该参数

ConsumeMessageConcurrentlyService(并发消息消费核心参数一览):

public class ConsumeMessageConcurrentlyService implements ConsumeMessageService {
    // 消息推模式实现类
    private final DefaultMQPushConsumerImpl defaultMQPushConsumerImpl;
    // 消费者对象
    private final DefaultMQPushConsumer defaultMQPushConsumer;
    // 并发消息业务事件类
    private final MessageListenerConcurrently messageListener;
    // 消息消费任务队列
    private final BlockingQueue<Runnable> consumeRequestQueue;
    // 消息消费线程池
    private final ThreadPoolExecutor consumeExecutor;
    // 消费组
    private final String consumerGroup;
	// 添加消费任务到消息消费线程池(consumeExecutor)延迟调度器
    private final ScheduledExecutorService scheduledExecutorService;
    // 定时删除过期消息线程池
    private final ScheduledExecutorService cleanExpireMsgExecutors;

从服务器拉取到消息后回调PullCallBack回调方法后,先将消息放入到ProcessQueue中,然后把消息提交到消费线程池中执行,也就是调用ConsumeMessageService#submitConsumeRequest开始进入到消息消费的流程中

  • Step1:如果拉取的消息条数 <= consumeBatchSize,将拉取到的消息放入到ConsumeRequest中,并提交到消息消费者线程池中
// ConsumeMessageConcurrentlyService#submitConsumeRequest
// 消息批次,一次消息消费任务ConsumeRequest中包含的消息条数,默认为1,
final int consumeBatchSize = this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
// msgs.size()默认最多为32条
if (msgs.size() <= consumeBatchSize) {
    // 直接将拉取到的消息放入到ConsumeRequest中,
    ConsumeRequest consumeRequest = new ConsumeRequest(msgs, processQueue, messageQueue);
    try {
        // 然后将ConsumeRequest提交到消息消费者线程池中
        this.consumeExecutor.submit(consumeRequest);
    } catch (RejectedExecutionException e) {
        // 如果提交过程中出现拒绝提交异常则延迟5s再提交
        // 但实际过程中消费者线程池使用的任务队列为LinkedBlockingQueue无界队列,故不会出现拒绝提交异常
        this.submitConsumeRequestLater(consumeRequest);
    }
} 
  • Step2:如果拉取的消息条数大于consumeBatchSize,对其进行分页并按页创建ConsumeRequest加入到消费线程池
// ConsumeMessageConcurrentlyService#submitConsumeRequest
if (msgs.size() > consumeBatchSize) {	// 如果拉取的消息条数大于consumeBatchSize、
    // 对拉取消息进行分页,每页consumeBatchSize条消息
    for (int total = 0; total < msgs.size(); ) {
        List<MessageExt> msgThis = new ArrayList<MessageExt>(consumeBatchSize);
        for (int i = 0; i < consumeBatchSize; i++, total++) {
            if (total < msgs.size()) {
                msgThis.add(msgs.get(total));
            } else {
                break;
            }
        }
		// 创建多个ConsumeRequest任务并提交到消费线程池
        ConsumeRequest consumeRequest = new ConsumeRequest(msgThis, processQueue, messageQueue);
        try {
            this.consumeExecutor.submit(consumeRequest);
        } catch (RejectedExecutionException e) {
            for (; total < msgs.size(); total++) {
                msgThis.add(msgs.get(total));
            }

            this.submitConsumeRequestLater(consumeRequest);
        }
    }
}
  • Step3:进入具体消息时检查proceQueue的dropped,如果为true。则停止该队列的消费,阻止消费者消费不属于自己的消费队列
// ConsumeMessageConcurrentlyService$ConsumeRequest#run
if (this.processQueue.isDropped()) {
    log.info("the message queue not be able to consume, because it's dropped. group={} {}", ConsumeMessageConcurrentlyService.this.consumerGroup, this.messageQueue);
    return;
}
  • Step4:执行消息消费钩子函数ConsumeMessageHook#consumeMessageBefore函数
  • Step5:恢复重试消息主题名
// ConsumeMessageConcurrentlyService#resetRetryAndNamespace
// RocketMQ将消息存入commitlog文件时,如果发现消息的延时级别delayTimeLevel大于0,会首先将重试主题存入Message的properties中,然后设置主题名称为SCHEDULE_TOPIC,以便时间到后重新参与消息消费
public void resetRetryAndNamespace(final List<MessageExt> msgs, String consumerGroup) {
    final String groupTopic = MixAll.getRetryTopic(consumerGroup);
    for (MessageExt msg : msgs) {
        String retryTopic = msg.getProperty(MessageConst.PROPERTY_RETRY_TOPIC);
        if (retryTopic != null && groupTopic.equals(msg.getTopic())) {
            msg.setTopic(retryTopic);
        }

        if (StringUtils.isNotEmpty(this.defaultMQPushConsumer.getNamespace())) {
            msg.setTopic(NamespaceUtil.withoutNamespace(msg.getTopic(), this.defaultMQPushConsumer.getNamespace()));
        }
    }
}
  • Step6:执行具体的消息消费,调用消息监听器(MessageListenerConcurrently)的consumeMessage方法,进入到具体的消息消费业务逻辑,返回该批消息的消费结果。最终返回CONSUME_SUCCESS(消费成功)或RECONSUME_LATER(需要重新消费)
  • Step7:执行消息消费钩子函数ConsumeMessageHook#consumeMessageAfter函数
  • Step8:执行业务消息消费后,如果ProcessQueue的dropped不为true,则处理消息消费结果
// ConsumeMessageConcurrentlyService$ConsumeRequest#run
if (!processQueue.isDropped()) {
    ConsumeMessageConcurrentlyService.this.processConsumeResult(status, context, this);
} else {
    log.warn("processQueue is dropped without process consume result. messageQueue={}, msgs={}", messageQueue, msgs);
}
  • Step9:根据消息监听器返回的结果,计算ackIndex,为下文发送msg back(ACK)消息做准备的
// ConsumeMessageConcurrentlyService#processConsumeResult
switch (status) {
    case CONSUME_SUCCESS:
        if (ackIndex >= consumeRequest.getMsgs().size()) {
            // ackIndex设置为msg.size()-1
            ackIndex = consumeRequest.getMsgs().size() - 1;
        }
        int ok = ackIndex + 1;
        int failed = consumeRequest.getMsgs().size() - ok;
        this.getConsumerStatsManager().incConsumeOKTPS(consumerGroup,                                                                   consumeRequest.getMessageQueue().getTopic(), ok);
        this.getConsumerStatsManager().incConsumeFailedTPS(consumerGroup,                                                               consumeRequest.getMessageQueue().getTopic(), failed);
        break;
    case RECONSUME_LATER:
        // 如果消息结果是需要重新消费,ackIndex设置为-1
        ackIndex = -1;
        this.getConsumerStatsManager().incConsumeFailedTPS(consumerGroup, consumeRequest.getMessageQueue().getTopic(),
                consumeRequest.getMsgs().size());
        break;
    default:
        break;
}
  • Step10:根据消息消费模式执行不同的逻辑

    • 如果是广播模式:业务方返回RECONSUME_LATER(需要重新消费),消息并不会重新消费,只是以警告级别输出到日志文件
    • 如果是集群模式:消息消费成功,不做处理;消息重新消费,发送ACK消息失败,延迟5s重新消费
// ConsumeMessageConcurrentlyService#processConsumeResult
switch (this.defaultMQPushConsumer.getMessageModel()) {
    case BROADCASTING:
        for (int i = ackIndex + 1; i < consumeRequest.getMsgs().size(); i++) {
            MessageExt msg = consumeRequest.getMsgs().get(i);
            log.warn("BROADCASTING, the message consume failed, drop it, {}", msg.toString());
        }
        break;
    case CLUSTERING:
        List<MessageExt> msgBackFailed = new ArrayList<MessageExt>(consumeRequest.getMsgs().size());
        // 消息消费成功时,由于ackIndex = msg.size()-1,所以并不会进入for循环
        // 当需要重新消费,该批消息会执行sendMessageBack方法,需要发ACK消息
        for (int i = ackIndex + 1; i < consumeRequest.getMsgs().size(); i++) {
            MessageExt msg = consumeRequest.getMsgs().get(i);
            
            // 发送ACK消息
            boolean result = this.sendMessageBack(msg, context);
            
            if (!result) {	// 如果消息发送ACK失败,加入到msgBackFailed链表中
                msg.setReconsumeTimes(msg.getReconsumeTimes() + 1);
                msgBackFailed.add(msg);
            }
        }

        if (!msgBackFailed.isEmpty()) {
            // 删除消费失败的消息
            consumeRequest.getMsgs().removeAll(msgBackFailed);
            // 延迟5s后重新消费
            this.submitConsumeRequestLater(msgBackFailed, consumeRequest.getProcessQueue(), consumeRequest.getMessageQueue());
        }
        break;
    default:
        break;
}
  • Step11:从processQueue中移除这批消息,用返回偏移量更新消息消费进度,以便消费者重启后能以上次消费进度开始消费,避免消息重复消费
// ConsumeMessageConcurrentlyService#processConsumeResult
// 返回的offset是移除该批消息后的最小偏移量
long offset = consumeRequest.getProcessQueue().removeMessage(consumeRequest.getMsgs());
if (offset >= 0 && !consumeRequest.getProcessQueue().isDropped()) {
    this.defaultMQPushConsumerImpl.getOffsetStore().updateOffset(consumeRequest.getMessageQueue(), offset, true);
}

当消息监听器返回RECONSUME_LATER时,消息消费进度也会向前推进,这是因为RocketMQ会创建一条与原先消息属性相同的消息,拥有一个唯一的新msgId,并存储原消息ID,该消息会存入commitlog文件中,与原消息没有任何关联,那该消息当然也会进入到ConsumeQueue队列中,将拥有一个全新的队列偏移量

消息确认(ACK)

如果消息监听器(MessageListenerConcurrently)返回的消费结果为RECONSUME_LATER,则需要将这些消息发送给Broker延迟消息,如果发送ACK消息失败,将延迟5s后提交线程池进行消费

ACK消息发送的网络客户端入口:MQClientAPIImpl#consumerSendMessageBack,

命令编码:RequestCode.CONSUMER_SEND_MSG_BACK

public class ConsumerSendMsgBackRequestHeader implements CommandCustomHeader {
    @CFNotNull
    private Long offset;	// 消息物理偏移量
    @CFNotNull
    private String group;	// 消费组名
    @CFNotNull
    private Integer delayLevel;	// 延迟级别
    private String originMsgId;	// 消息ID
    private String originTopic;	// 消息主题
    @CFNullable
    private boolean unitMode = false;
    private Integer maxReconsumeTimes;	// 最大重新消费次数,默认16次
}

客户端以异步方式发送RequestCode.CONSUMER_SEND到服务端,

服务端命令处理器:SendMessageProcessor#asyncConsumerSendMsgBack

  • Step1:获取消费组的订阅配置信息
// SendMessageProcessor#asyncConsumerSendMsgBack

SubscriptionGroupConfig subscriptionGroupConfig = this.brokerController.getSubscriptionGroupManager().
    									findSubscriptionGroupConfig(requestHeader.getGroup());

// 消费组订阅信息配置信息存储在Broker的${ROCKET_HOME}/store/config/subscriptionGroup.json
// 默认情况下BrokerConfig.autoCreateSubscriptionGroup默认为true,表示第一次使用如果不存在,使用默认值自动创建一个
public class SubscriptionGroupConfig {
	// 消费组名
    private String groupName;
	// 是否可以消费,默认为true
    private boolean consumeEnable = true;
    // 是否允许从队列最小偏移量开始消费
    private boolean consumeFromMinEnable = true;
	// 该消费组是否能以广播模式消费,如果为false,则只能以集群模式消费
    private boolean consumeBroadcastEnable = true;
	// 重试队列个数,默认为1,每一个Broker上一个重试队列
    private int retryQueueNums = 1;
	// 消息最大重试次数,默认为16
    private int retryMaxTimes = 16;
	// masterId
    private long brokerId = MixAll.MASTER_ID;
	// 如果消息阻塞(主),将转向该brokerId的服务器上拉取消息,默认为1
    private long whichBrokerWhenConsumeSlowly = 1;
	// 将消费发生变化时是否立即进行消息队列重新赋值
    private boolean notifyConsumerIdsChangedEnable = true;
}
  • Step2:创建重试主题,重试主题名称:%RETRY+消费组名称,并从重试队列中随机选择一个队列,构建TopicConfig主题配置信息
// SendMessageProcessor#asyncConsumerSendMsgBack
String newTopic = MixAll.getRetryTopic(requestHeader.getGroup());
int queueIdInt = ThreadLocalRandom.current().nextInt(99999999) %                                                                                             subscriptionGroupConfig.getRetryQueueNums();
  • Step3:根据消息物理偏移量从commitlog文件中获取消息,同时将消息的主题存入属性中
// SendMessageProcessor#asyncConsumerSendMsgBack
MessageExt msgExt = this.brokerController.getMessageStore().lookMessageByOffset(requestHeader.getOffset());
if (null == msgExt) {
    response.setCode(ResponseCode.SYSTEM_ERROR);
    response.setRemark("look message by offset failed, " + requestHeader.getOffset());
    return CompletableFuture.completedFuture(response);
}

final String retryTopic = msgExt.getProperty(MessageConst.PROPERTY_RETRY_TOPIC);
if (null == retryTopic) {
    // 存入消息主题
    MessageAccessor.putProperty(msgExt, MessageConst.PROPERTY_RETRY_TOPIC, msgExt.getTopic());
}
msgExt.setWaitStoreMsgOK(false);
  • Step4:设置消息重试次数,如果超过重新次数maxReconsumeTimes=16,改变newTopic主题为DLQ("%DLQ%"),该主题的权限为只写,说明消息一旦进入到DLQ队列中,RocketMQ将不负责再次调度进行消费了,需要人工干预
// SendMessageProcessor#asyncConsumerSendMsgBack
if (msgExt.getReconsumeTimes() >= maxReconsumeTimes
    || delayLevel < 0) {
    newTopic = MixAll.getDLQTopic(requestHeader.getGroup());
    queueIdInt = ThreadLocalRandom.current().nextInt(99999999) % DLQ_NUMS_PER_GROUP;

    topicConfig = this.brokerController.getTopicConfigManager().createTopicInSendMessageBackMethod(newTopic,
            DLQ_NUMS_PER_GROUP,
            PermName.PERM_WRITE | PermName.PERM_READ, 0);
  • Step5:根据原先的消息创建一个新的消息对象,重试消息会拥有自己的唯一消息ID(msgId)并存入到commitlog文件中,并不会更新原先消息,而是将原先的主题、消息ID存入消息的属性中,主题名称为重试主题,其它属性与原消息保持相同
// SendMessageProcessor#asyncConsumerSendMsgBack
MessageExtBrokerInner msgInner = new MessageExtBrokerInner();
msgInner.setTopic(newTopic);
msgInner.setBody(msgExt.getBody());
msgInner.setFlag(msgExt.getFlag());
MessageAccessor.setProperties(msgInner, msgExt.getProperties());
msgInner.setPropertiesString(MessageDecoder.messageProperties2String(msgExt.getProperties()));
msgInner.setTagsCode(MessageExtBrokerInner.tagsString2tagsCode(null, msgExt.getTags()));

msgInner.setQueueId(queueIdInt);
msgInner.setSysFlag(msgExt.getSysFlag());
msgInner.setBornTimestamp(msgExt.getBornTimestamp());
msgInner.setBornHost(msgExt.getBornHost());
msgInner.setStoreHost(msgExt.getStoreHost());
msgInner.setReconsumeTimes(msgExt.getReconsumeTimes() + 1);

String originMsgId = MessageAccessor.getOriginMessageId(msgExt);
MessageAccessor.setOriginMessageId(msgInner, UtilAll.isBlank(originMsgId) ? msgExt.getMsgId() : originMsgId);
  • Step6:将消息存入到CommitLog文件中,消息重试机制依托于定时任务实现
// CommitLOg#asyncPutMessage
// 如果延迟级别 > 0,
if (msg.getDelayTimeLevel() > 0) {
    if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
        msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
    }
	// 替换消息主题为"SCHEDULE_TOPIC_XXXX"
    topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
    // 替换队列ID为延迟级别-1
    int queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

    // 保存真实的主题、队列Id
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
    msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
	
    // 将更改后的主题、队列Id存入消息的属性中
    msg.setTopic(topic);
    msg.setQueueId(queueId);
}

ACK消息存入CommitLog文件后,将依托RocketMQ定时消息机制在延迟时间到期后再次将消息拉取,提交消费线程池

消费进度管理

消息消费者在消费一批消息后,需要记录该批消息已经消费完成,否则当消费者重新启动时又得从消息队列的开始消费

一次消息消费后会从ProcessQueue处理队列中移除该批消息,返回ProcessQueue最小偏移量,并存入消息进度表中

消息进度文件应该存储在哪呢?

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

RocketMQ消息消费进度接口:

public interface OffsetStore {
	// 从消息进度存储文件加载消息进度到内存
    void load() throws MQClientException;

     /**
     * 更新内存中消息消费进度
     * @param mq 消息消费队列
     * @param offset 消息消费偏移量
     * @param increaseOnly true表示offset必须大于内存中当前的消费偏移量才更新
     */
    void updateOffset(final MessageQueue mq, final long offset, final boolean increaseOnly);

     /**
     * 读取消息消费进度
     * @param mq 消息消费队列
     * @param type 读取方式,可选值:从内存中、从磁盘中、先从内存读取,再从磁盘读取
     * @return
     */
    long readOffset(final MessageQueue mq, final ReadOffsetType type);

    /**
     * 持久化指定消息队列进度到磁盘
     */
    void persistAll(final Set<MessageQueue> mqs);
    void persist(final MessageQueue mq);

    /**
     * 将消息队列的消息消费进度从内存中移除
     */
    void removeOffset(MessageQueue mq);

    /**
     * 克隆该主题下所有消息队列的消息消费进度
     */
    Map<MessageQueue, Long> cloneOffsetTable(String topic);

    /**
     * 更新存储在Broker端的消息消费进度,使用集群模式
     */
    void updateConsumeOffsetToBroker(MessageQueue mq, long offset, boolean isOneway);
}

1 )广播模式消费进度存储

广播模式消息消费进度存储在消费者本地

其实现类:org.apache.rocketmq.client.consumer.store.LocalFileOffsetStore

public class LocalFileOffsetStore implements OffsetStore {
    // 消息进度存储目录
    public final static String LOCAL_OFFSET_STORE_DIR = System.getProperty(
        "rocketmq.client.localOffsetStoreDir",
        System.getProperty("user.home") + File.separator + ".rocketmq_offsets");
    private final static InternalLogger log = ClientLogger.getLog();
    private final MQClientInstance mQClientFactory;	// 消息客户端
    private final String groupName;	//消息消费组
    private final String storePath;	// 消息进度存储文件
    private ConcurrentMap<MessageQueue, AtomicLong> offsetTable =
        new ConcurrentHashMap<MessageQueue, AtomicLong>();	// 消息消费进度(内存)
}

广播模式持久化消息进度就是将offsetTable序列化到磁盘文件中,会在MQClientInstance中启动一个定时任务,默认每5s持久化一次

2)集群模式消费进度存储

集群模式消息进度存储文件存放在消息服务端Broker

其实现类:org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore

image.png

Broker默认10s持久化一次消息进度

消息消费注意点

消费者线程池每处理完一个消息消费任务(ConsumeRequest)时会从ProcessQueue中移除本批消费的消息,并返回ProcessQueue中最小的偏移量,用该偏移量更新消息队列消费进度

但当消费偏移量很小的消息时发送了死锁导致一直无法被消费,不是会导致消息进度无法向前推进吗?

  • 为了避免这种情况,RocketMQ引入了一种消息拉取流控措施:consumeConcurrentlyMaxSpan = 2000,消息处理队列中最大消息偏移量与最小偏移量不能超过该值,如果超过会触发流控,将延迟该消息队列的消息拉取(放弃本次拉取任务,并且该队列的下一次拉取任务将在50ms后才加入到拉取任务队列中)

触发消息消费进度更新的另外一个是在进行消息负载时,如果消息消费队列被分配给其它消费者时,此时会将该ProcessQueue状态设置为dropped,持久化该消息队列的消费进度,并从内存中移除

7.定时消息机制

RocketMQ并不支持任意的时间精度,如果要支持任意时间精度的定时调度,不可避免地需要在Broker层做消息排序,再加上持久化方面的考量,将不可避免地带来巨大的性能消耗,所以RocketMQ只支持特定级别的延迟消息

消息重试就是基于定时任务实现的,在将消息存入commitlog文件之前需要判断消息的重试次数,如果大于0,会将消息的主题设置为SCHEDULE_TOPIC_XXXX。RocketMQ定时消息实现类为ScheduleMessageService,该类的实例在DefaultMessageStore中创建,通过调用load方法加载并调用start方法进行启动

定时消息设计关键点:

  • 定时消息单独一个主题:SCHEDULE_TOPIC_XXXX,该主题下队列数量等于MessageStoreConfig#messageDelayLevel配置的延迟级别数量,其对应关系为queueId等于延迟级别-1,ScheduleMessageService为每一个延迟级别创建一个定时Timer根据延迟级别对应的延迟时间进行延迟调度。在消息发送时,如果消息的延迟级别delayLevel大于0,将消息的原主题名称、队列ID存入消息的属性中,然后改变消息的主题、队列与延迟主题与延迟主题所属队列,消息最终转发到延迟队列的消费队列
  • 消息存储时如果消息的延迟级别属性delayLevel大于0,则会备份原主题、原队列到消息属性中,通过为不同的延迟级别创建不同的调度任务,当时间到达后执行调度任务,调度任务就是根据延迟拉取消息消费进度从延迟队列中拉取消息,然后从commitlog中加载完整消息,清除延迟级别属性并恢复原先的主题、队列,再次创建一条新消息存入到commitlog中并转发到消息消费队列供消息消费者消费
// ScheduleMessageService方法的调用顺序:构造方法 -> load() -> start()
public class ScheduleMessageService extends ConfigManager {
    private static final InternalLogger log = InternalLoggerFactory.getLogger(LoggerName.STORE_LOGGER_NAME);

    private static final long FIRST_DELAY_TIME = 1000L;		// 第一次调度时延迟的时间,默认1s
    private static final long DELAY_FOR_A_WHILE = 100L;		// 每一延时级别调度一次后延迟该时间间隔后再次放入调度池
    private static final long DELAY_FOR_A_PERIOD = 10000L;	// 发送异常后延迟该时间后再继续参与调度
    private static final long WAIT_FOR_SHUTDOWN = 5000L;
    private static final long DELAY_FOR_A_SLEEP = 10L;

    private final ConcurrentMap<Integer /* level */, Long/* delay timeMillis */> delayLevelTable =
        new ConcurrentHashMap<Integer, Long>(32);	// 延迟级别

    private final ConcurrentMap<Integer /* level */, Long/* offset */> offsetTable =
        new ConcurrentHashMap<Integer, Long>(32);			// 延迟级别消息消费进度
    private final DefaultMessageStore defaultMessageStore;	// 默认消息存储器
    private final AtomicBoolean started = new AtomicBoolean(false);
    private ScheduledExecutorService deliverExecutorService;
    private MessageStore writeMessageStore;
    private int maxDelayLevel;
    private boolean enableAsyncDeliver = false;
    private ScheduledExecutorService handleExecutorService;
    private final Map<Integer /* level */, LinkedBlockingQueue<PutResultProcess>> deliverPendingTable =
        new ConcurrentHashMap<>(32);
    
    // ScheduleMessageService#load
	@Override
    public boolean load() {
        boolean result = super.load();
        result = result && this.parseDelayLevel();
        result = result && this.correctDelayOffset();
        return result;
    }

start方法:根据延迟级别创建对应的定时任务,启动定时任务持久化延迟消息队列进度存储

  • Step1:遍历延迟级别,根据延迟级别level从offsetTable中获取消费队列的消费进度
// ScheduleMessageService#start
for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
    Integer level = entry.getKey();
    Long timeDelay = entry.getValue();
    Long offset = this.offsetTable.get(level);
    if (null == offset) {	// 如果该延迟级别对应的消费进度不存在,设置为0
        offset = 0L;
    }

    if (timeDelay != null) {
        if (this.enableAsyncDeliver) {
            // 每个定时任务第一次启动时默认延迟1s先执行定时任务,第二次调度才使用相应的延迟时间
            this.handleExecutorService.schedule(new HandlePutResultTask(level), FIRST_DELAY_TIME, TimeUnit.MILLISECONDS);
        }
        this.deliverExecutorService.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME, TimeUnit.MILLISECONDS);
    }
}
// 延迟级别与消息消费队列的映射关系为:消息队列ID = 延迟级别 - 1
public static int queueId2DelayLevel(final int queueId) {
    return queueId + 1;
}

public static int delayLevel2QueueId(final int delayLevel) {
    return delayLevel - 1;
}
  • Step2:创建定时任务,每隔10s持久化一次延迟队列的消息消费进度
// ScheduleMessageService#start
this.deliverExecutorService.scheduleAtFixedRate(new Runnable() {

    @Override
    public void run() {
        try {
            if (started.get()) {
                // 每隔10s持久化延迟队列的消息消费进度
                ScheduleMessageService.this.persist();
            }
        } catch (Throwable e) {
            log.error("scheduleAtFixedRate flush exception", e);
        }
    }
}, 10000, this.defaultMessageStore.getMessageStoreConfig().getFlushDelayOffsetInterval(), TimeUnit.MILLISECONDS);

定时调度逻辑

定时调度任务的实现类:ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup

  • Step1:根据延迟主题名称和队列ID查找消息消费队列
// ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
ConsumeQueue cq = ScheduleMessageService.this.defaultMessageStore.findConsumeQueue( 	                                               TopicValidator.RMQ_SYS_SCHEDULE_TOPIC,delayLevel2QueueId(delayLevel));
  • Step2:根据offset从消息消费队列中获取当前队列中所有有效的消息
// ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
SelectMappedBufferResult bufferCQ = cq.getIndexBuffer(this.offset);
  • Step3:遍历ConsumeQueue,每个ConsumeQueue条目为20个字节,按序解析出物理偏移量、消息长度、消息哈希,为从commitlog加载具体的消息做准备
// ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
long nextOffset = this.offset;
try {
    int i = 0;
    ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
    for (; i < bufferCQ.getSize() && isStarted(); i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
        long offsetPy = bufferCQ.getByteBuffer().getLong();
        int sizePy = bufferCQ.getByteBuffer().getInt();
        long tagsCode = bufferCQ.getByteBuffer().getLong();
        long now = System.currentTimeMillis();
        long deliverTimestamp = this.correctDeliverTimestamp(now, tagsCode);
        nextOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
        // ....
    }
  • Step4:根据消息物理偏移量与消息长度从commitlog文件中查找消息
// ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
MessageExt msgExt = ScheduleMessageService.this.defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
  • Step5:根据消息重新构建新的消息对象,清除消息的延迟级别属性(delayLevel)、并恢复消息原先的消息主题与消息消费队列,消息的消费次数reconsumeTimes并不会丢失
// ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
MessageExtBrokerInner msgInner = ScheduleMessageService.this.messageTimeup(msgExt);

// ScheduleMessageService$DeliverDelayedMessageTimerTask#messageTimeup
msgInner.setReconsumeTimes(msgExt.getReconsumeTimes());
msgInner.setWaitStoreMsgOK(false);
MessageAccessor.clearProperty(msgInner, MessageConst.PROPERTY_DELAY_TIME_LEVEL);
msgInner.setTopic(msgInner.getProperty(MessageConst.PROPERTY_REAL_TOPIC));
String queueIdStr = msgInner.getProperty(MessageConst.PROPERTY_REAL_QUEUE_ID);
int queueId = Integer.parseInt(queueIdStr);
msgInner.setQueueId(queueId);
  • Step6:将消息再次存入到commitlog,并转发到主题对应的消息队列上,供消费者再次消费
// ScheduleMessageService$DeliverDelayedMessageTimerTask#executeOnTimeup
PutMessageResult result = ScheduleMessageService.this.writeMessageStore.putMessage(msgInner);
  • Step7:更新延迟队列拉取进度

8.消息过滤机制

RocketMQ支持表达式过滤与类过滤两种模式,其中表达式又分为TAG和SQL92。

类模式允许提交一个过滤类到FilterServer,消息消费者从FilterServer拉取消息,消息经过FilterServer时会执行过滤逻辑

表达式模式分为TAG与SQL92表达式,SQL92表达式以消息属性过滤上下文,实现SQL条件过滤表达式,而TAG模式就是简单为消息定义标签,根据消息属性tag进行匹配

public interface MessageFilter {
    /**
     * 根据ConsumeQueue判断消息是否匹配
     * @param tagsCode 消息tag的hashcode
     * @param cqExtUnit consumequeue条目扩展属性
     */
    boolean isMatchedByConsumeQueue(final Long tagsCode,
        final ConsumeQueueExt.CqExtUnit cqExtUnit);

    /**
     * 根据存储在commitlog文件的内容判断消息是否匹配
     * @param msgBuffer 消息内容,如果为空,返回true
     * @param properties 消息属性,主要用于表达式SQL92过滤模式
     */
    boolean isMatchedByCommitLog(final ByteBuffer msgBuffer,
        final Map<String, String> properties);
}

RocketMQ是在订阅时进行过滤消息

消息发送者在消息发送时如果设置了消息的tag属性,存储在消息属性中,先存储在CommitLog文件中,然后转发到消息消费队列,消息消费队列会用8个字节存储消息tag的hashcode(ConsumeQueue设置为定长结构,加快消息的加载性能)。在Broker端拉取消息时,遍历ConsumeQueue,只对比消息tag的hashcode,如果匹配则返回,否则忽略该消息。ConsumeQueue在收到消息后,同样需要先对消息进行过滤,只是此时比较的是消息tag的值而不是hashcode

消息过滤流程:

  • Step1:消费者订阅消息主题与消息过滤表达式,构建订阅信息并加入到RebalanceImpl中,以便RebalanceImpl进行消息队列负载
// DefaultMQPushConsumerImpl#subscribe
public void subscribe(String topic, String subExpression) throws MQClientException {
    try {
        // 订阅消息主题与消息过滤表达式
        SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData(topic, subExpression);
        // 将订阅数据加入到RebalanceImpl中
        this.rebalanceImpl.getSubscriptionInner().put(topic, subscriptionData);
        if (this.mQClientFactory != null) {
            this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
        }
    } catch (Exception e) {
        throw new MQClientException("subscription exception", e);
    }
}
// SubscriptionData类中的属性
public class SubscriptionData implements Comparable<SubscriptionData> {
    public final static String SUB_ALL = "*";	// 过滤模式,默认为全匹配
    private boolean classFilterMode = false;	// 是否是类过滤模式,默认为false
    private String topic;		// 消息主题名称
    private String subString;	// 消息过滤表达式,多个用双竖线分隔开,例如:"TAGA||TAGB"
    private Set<String> tagsSet = new HashSet<String>();	// 消息过滤tag集合,消费端过滤时进行消息过滤的依据
    private Set<Integer> codeSet = new HashSet<Integer>();	// 消息过滤tag hashcode集合
    private long subVersion = System.currentTimeMillis();
    private String expressionType = ExpressionType.TAG;		// 过滤类型,TAG或SQL92
}
  • Step2:根据订阅消息构建消息拉取标记,设置subExpression、classFilter等与消息过滤相关
// DefaultMQPushConsumerImpl#pullMessage
String subExpression = null;
boolean classFilter = false;
SubscriptionData sd = this.rebalanceImpl.getSubscriptionInner().get(pullRequest.getMessageQueue().getTopic());
if (sd != null) {
    if (this.defaultMQPushConsumer.isPostSubscriptionWhenPull() && !sd.isClassFilterMode()) {
        subExpression = sd.getSubString();
    }

    classFilter = sd.isClassFilterMode();
}

int sysFlag = PullSysFlag.buildSysFlag(
    commitOffsetEnable, // commitOffset
    true, // suspend
    subExpression != null, // subscription
    classFilter // class filter
);
  • Step3:根据主题、消息过滤表达式构建订阅消息实体,如果不是TAG模式,构建过滤数据
// PullMessageProcessor#processRequest
subscriptionData = FilterAPI.build(
    requestHeader.getTopic(), requestHeader.getSubscription(), requestHeader.getExpressionType()
);
if (!ExpressionType.isTagType(subscriptionData.getExpressionType())) {
    consumerFilterData = ConsumerFilterManager.build(
        requestHeader.getTopic(), requestHeader.getConsumerGroup(), requestHeader.getSubscription(),
        requestHeader.getExpressionType(), requestHeader.getSubVersion()
    );
    assert consumerFilterData != null;
}
  • Step4:构建消息过滤对象
// PullMessageProcessor#processRequest
MessageFilter messageFilter;
if (this.brokerController.getBrokerConfig().isFilterSupportRetry()) {
    // 构建支持对重试主题的过滤对象
    messageFilter = new ExpressionForRetryMessageFilter(subscriptionData, consumerFilterData,
        this.brokerController.getConsumerFilterManager());
} else {
    // 构建不支持对重试主题的属性过滤对象
    messageFilter = new ExpressionMessageFilter(subscriptionData, consumerFilterData,
        this.brokerController.getConsumerFilterManager());
}
  • Step5:根据消息偏移量拉取消息后,首先根据ConsumeQueue条目进行消息过滤,如果不匹配直接跳过该条消息,继续拉取下一条消息
// DefaultMessageStore#getMessage
if (messageFilter != null
    && !messageFilter.isMatchedByConsumeQueue(isTagsCodeLegal ? tagsCode : null, extRet ? cqExtUnit : null)) {
    if (getResult.getBufferTotalSize() == 0) {
        status = GetMessageStatus.NO_MATCHED_MESSAGE;
    }

    continue;
}

// ExpressionMessageFilter#isMatchedByConsumeQueue
// 基于TAG模式,根据ConsumeQueue进行消息过滤时只对比tag的hashcode
if (null == subscriptionData) {	// 如果订阅消息为空,返回true,不过滤
    return true;
}
if (subscriptionData.isClassFilterMode()) {	// 如果是类过滤模式,返回true
    return true;
}
// 如果是TAG过滤模式
if (ExpressionType.isTagType(subscriptionData.getExpressionType())) {
    if (tagsCode == null) {	// 消息的tagCode为空,说明消息在发送时没有设置tag。返回true
        return true;
    }
    // 如果订阅数据的过滤表达式等于"*",表示不过滤,返回true
    if (subscriptionData.getSubString().equals(SubscriptionData.SUB_ALL)) {
        return true;
    }
    // 获取订阅数据的TAG hashcodes集合中包含消息的tagsCode,返回true
    return subscriptionData.getCodeSet().contains(tagsCode.intValue());
} 
  • Step6:如果消息根据ConsumeQueue条目通过过滤,则需要从CommitLog文件中加载整个消息体,然后根据属性进行过滤。如果过滤方式是TAG模式,默认返回true
// DefaultMessageStore#getMessage
if (messageFilter != null
    && !messageFilter.isMatchedByCommitLog(selectResult.getByteBuffer().slice(), null)) {
    if (getResult.getBufferTotalSize() == 0) {
        status = GetMessageStatus.NO_MATCHED_MESSAGE;
    }
    // release...
    selectResult.release();
    continue;
}

// ExpressionMessageFilter#isMatchedByCommitLog
// 该方法主要为表达式模式SQL92服务的
public boolean isMatchedByCommitLog(ByteBuffer msgBuffer, Map<String, String> properties) {
    if (subscriptionData == null) {	// 如果订阅消息为空,返回true,不过滤
        return true;
    }
    if (subscriptionData.isClassFilterMode()) {	// 如果是类过滤模式,返回true
        return true;
    }
    if (ExpressionType.isTagType(subscriptionData.getExpressionType())) {	// 如果是TAG模式,返回true
        return true;
    }
    // ....
}

至此,消息拉取服务端的消息过滤流程结束,RocketMQ会在消息接收端再次进行消息过滤

从消息拉取流程知道,消息拉取线程PullMessageService默认使用异步方式从服务器拉取消息,消息消费端会通过PullAPIWrapper从响应结果解析出拉取到的消息。如果消息过滤模式为TAG模式,并且订阅TAG集合不为空,则对消息的tag进行判断,如果集合中包含消息的TAG,则返回给消息消费者消费,否则跳过

9.顺序消息

RocketMQ支持局部消息顺序消费,可以确保同一个消息消费队列中的消息被顺序消费,如果需要做到全局顺序消费则可以将主题配置成一个队列

顺序消息消费与并发消息消费的关键区别:

  • 顺序消息在创建消息队列拉取任务时需要在Broker服务器锁定该消息队列

消息消费包含如下4个步骤:

  • 消息队列负载
  • 消息拉取
  • 消息消费
  • 消息消费进度存储

1)消息队列负载(加锁)

RocketMQ首先需要通过RebalanceService线程实现消息队列的负载,集群模式下同一个消费组内的消费者共同承担其订阅主题下消息队列的消费,同一个消息消费队列在同一时刻只会被消费组内一个消费者消费,一个消费者同一时刻可以分配多个消费队列

如果经过消息队列重新负载后,分配到新的消息队列时,首先需要尝试向Broker发起锁定该消息队列的请求,如果返回加锁成功则成功创建该消息队列的拉取任务,否则将跳过;等待其它消费者释放该消息队列的锁,然后在下一次队列重新负载时再次尝试加锁

2)消息拉取

RocketMQ消息拉取由PullMessageService线程负责,根据消息拉取任务循环拉取消息。如果消息处理队列未被锁定,则延迟3s后再将PullRequest对象放入到拉取任务中,如果该处理队列是第一次拉取任务,则首先计算拉取偏移量,然后向消息服务端拉取消息。

3)消息消费

顺序消息消费的实现类:org.apache.rocketmq.client.impl.consumer.ConsumeMessageOrderlyService

如果消费模式为集群模式,启动定时任务,默认每隔20s执行一次锁定分配给自己的消息消费队列

集群模式下顺序消息消费在创建拉取任务时并未将ProcessQueue的locked状态设置为true,在未锁定消息队列之前无法执行消息拉取任务,ConsumeMessageOrderlyService以每列20s的频率对分配给自己的消息队列进行自动加锁操作,从而消费加锁成功的消息消费队列

加锁逻辑实现流程:

  • Step1:将消息队列按照Broker组织成Map集合,方便向Broker发送锁定消息队列请求
protected final ConcurrentMap<MessageQueue, ProcessQueue> processQueueTable = new                                                                            ConcurrentHashMap<MessageQueue, ProcessQueue>(64);
  • Step2:向Broker(主节点)发送锁定消息队列,该方法返回成功被当前消费者锁定的消息消费队列
this.mQClientFactory.getMQClientAPIImpl().lockBatchMQ(findBrokerResult.getBrokerAddr(), requestBody,                                                            1000);ConcurrentHashMap<MessageQueue, ProcessQueue>(64);
  • Step3:将锁定成功的消息消费队列相对应的处理队列设置为锁定状态,同时更新加锁时间
  • Step4:遍历当前处理队列中的消息消费队列,如果当前消费者不持有该消息队列的锁,将处理队列锁标志设置为false,暂停该消息消费队列的消息拉取与消费

顺序消息的ConsumeRequest消费任务不会直接消费本次拉取的消息,而是在消息消费时从处理队列中拉取消息。根据消息队列获取一个对象,然后消息消费时先申请独占objLock(使用synchronized),顺序消息消费的并发度为消息队列MessageQueue,每个消息队列对应一个锁对象,也就是一个消息消费队列同一时刻只会被一个消费线程池中一个线程消费

顺序消息消费的处理逻辑:每一个ConsumeRequest消费任务不是以消费条数来计算的,而是根据消费时间,默认当消费时长大于MAX_TIME_CONSUME_CONTINUOUSLY=60s后,本次消费任务结束,由消费组内其它线程继续消费

顺序消息加锁处理类:org.apache.rocketmq.broker.client.rebalance.RebalanceLockManager

public class RebalanceLockManager {
    // 锁最大存活时间,默认为60s
    private final static long REBALANCE_LOCK_MAX_LIVE_TIME = Long.parseLong(System.getProperty(
        "rocketmq.broker.rebalance.lockMaxLiveTime", "60000"));
    
    private final Lock lock = new ReentrantLock();
    // 锁容器对象,以消费组分组,每个消息队列对应一个锁对象,表示当前该消息队列被消费组哪个消费者所持有
    private final ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable =
        new ConcurrentHashMap<String, ConcurrentHashMap<MessageQueue, LockEntry>>(1024);

4.4总结

  • 消息队列负载由RebalanceService线程默认每隔20s进行一次消息队列 负载,根据当前消费组内消费者个数与主题队列数量按照某一种负载算法进行队列分配,分配原则为同一个消费者可以分配多个消息消费队列,同一个消息消费队列同一时间只会分配给一个消费者
  • 消息拉取由PullMessageService线程根据RebalanceService线程创建的拉取任务进行拉取,默认一批拉取32条消息,提交给消费者线程池后继续下一次的消息拉取,如果消息消费过慢产生消息堆积会触发消息消费拉取流控
  • 并发消息消费指的是消费线程池中的线程可以并发地对同一个消息消费队列的消息进行消费,消费成功后,取出消息处理队列中最小的消息偏移量作为消息消费进度偏移量存在于消息消费进度存储文件中:集群模式消息进度存储在Broker、广播模式消息进度存储在消费者端。如果业务方返回RECONSUME_LATER,则RocketMQ启用消息消费重试机制,将原消息的主题与队列存储在消息属性中,等待指定时间后,RocketMQ将自动将该消息重新拉取并再次将消息存储在commitlog进而转发到原主题的消息消费队列供消费者消费,消息消费重试主题为%RETRY%消费组名
  • 顺序消息消费一般使用集群模式,是指消息消费者内的线程池中的线程对消息消费队列只能串行消费,消费消息时必须成功锁定消息消费队列,在Broker端会存储消息消费队列的锁占用情况。