1. 前言

顺序消息是RocketMQ的特性之一,它可以让Consumer消费消息的顺序严格按照消息的发送顺序来进行。例如:一条订单产生的三条消息:订单创建、订单付款、订单完成。消费时要按照这个顺序依次消费才有意义,但是不同的订单之间这些消息是可以并行消费的。

顺序消息可以分为全局有序和分区有序,绝大部分场景下,分区有序就已经能够满足需求了,因此本文会重点分析。

全局有序:某个Topic下所有的消息都是有序的,所有消息按照严格的先进先出的顺序进行生产和消费,要求Topic下只能有一个分区队列,且Consumer只能有一个线程消费,适用对性能要求不高的场景。

分区有序:某个Topic下,所有消息根据ShardingKey进行分区,相同ShardingKey的消息必须被发送到同一个分区队列,因为队列本身是可以保证先进先出的,此时只要保证Consumer同一个队列单线程消费即可。

RocketMQ里的分区队列MessageQueue本身是能保证FIFO的,正常情况下不能顺序消费消息主要有两个原因:

  1. Producer发送消息到MessageQueue时是轮询发送的,消息被发送到不同的分区队列,就不能保证FIFO了。
  2. Consumer默认是多线程并发消费同一个MessageQueue的,即使消息是顺序到达的,也不能保证消息顺序消费。

综上所述,RocketMQ要想实现顺序消息,核心就是Producer同步发送,确保一组顺序消息被发送到同一个分区队列,然后Consumer确保同一个队列只被一个线程消费。

如下是根据订单号发送顺序消息的示例:

public static void main(String[] args) throws Exception {
    DefaultMQProducer producer = new DefaultMQProducer("ShiWu");
    producer.setNamesrvAddr("127.0.0.1:9876");
    producer.start();
    for (int i = 1; i <= 20; i++) {
        // 假设 i 是订单号
        for (int j = 1; j <= 3; j++) {// 每笔订单3个消息
            Message message = new Message("order", String.format("orderNo:%s,Set %d", i, j).getBytes());
            producer.send(message, new MessageQueueSelector() {
                @Override
                public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                    Integer orderNo = (Integer) arg;
                    return mqs.get(orderNo % mqs.size());
                }
            }, i);
        }
    }
}

如下是消费者消费顺序消息的示例:

public static void main(String[] args) throws Exception {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("GID_SMS");
    consumer.setNamesrvAddr("127.0.0.1:9876");
    consumer.subscribe("order", "*");
    // 注册顺序消息监听
    consumer.registerMessageListener(new MessageListenerOrderly() {
        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
            for (MessageExt msg : msgs) {
                System.err.println(new String(msg.getBody()));
            }
            return ConsumeOrderlyStatus.SUCCESS;
        }
    });
    consumer.start();
}

2. 相关组件

看源码前,先简单了解一下相关组件类。

2.1 MessageQueueSelector

分区队列选择器,它是一个接口,只有一个select方法,根据ShardingKey从Topic下所有的分区队列中,选择一个目标队列进行消息发送,必须确保相同ShardingKey选择的是同一个分区队列,常见作法是对队列数取模。

2.2 RebalanceLockManager

Consumer重平衡操作时,Broker维护的全局锁管理器。Consumer在重平衡时,会开始拉取新分配的MessageQueue里的消息,但是如果是顺序消息,在拉取消息前,必须向Broker竞争队列锁成功才能拉取。因为,此时MessageQueue很可能还在被其它Consumer实例消费,消费位点还没有上报,直接拉取会导致消息重复消费、消费顺序错乱。

RebalanceLockManager维护了一个ConcurrentMap容器,里面存放了所有MessageQueue对应的LockEntry对象,LockEntry记录了MessageQueue锁的持有者客户端ID和最后的更新时间戳,以此来判断MessageQueue的锁状态和锁超时。

2.3 ConsumeMessageOrderlyService

消费顺序消息服务类,与之对应的还有ConsumeMessageConcurrentlyService消费并发消息服务类,它俩最大的区别就是ConsumeMessageOrderlyService在获取MessageQueue里的消息并消费之前,会对MessageQueue加锁,确保同一时间单个MessageQueue最多只会被一个线程消费,因为MessageQueue里的消息是有序的,只要消费有序就能保证最终有序。

2.4 MessageQueueLock

Consumer用来维护MessageQueue对应的本地锁对象,使用ConcurrentHashMap来管理。确保同一个MessageQueue同一时间最多只会被一个线程消费,因此线程消费前必须先竞争队列本地锁。通过synchronized关键字来保证同步,因此锁对象就是一个Object对象。

3. 源码分析

分别从Producer和Consumer两个纬度来进行分析,Producer确保发送有序,Consumer确保消费有序。

3.1 发送有序

Producer发送顺序消息的时序图如下:

Spring整合RocketMQ顺序消费 rocketmq顺序消息原理_RocketMQ

核心在于重写MessageQueueSelector类,将相同ShardingKey消息发送到同一队列即可。

消息发送时,先根据Topic从Broker拉取TopicPublishInfo信息,它里面包含了Topic下所有的MessageQueue。

private TopicPublishInfo tryToFindTopicPublishInfo(final String topic) {
    // 先从本地缓存获取
    TopicPublishInfo topicPublishInfo = this.topicPublishInfoTable.get(topic);
    if (null == topicPublishInfo || !topicPublishInfo.ok()) {
        // 没有缓存,或失效
        this.topicPublishInfoTable.putIfAbsent(topic, new TopicPublishInfo());
        // 发请求从NameServer拉取,并更新缓存
        this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic);
        topicPublishInfo = this.topicPublishInfoTable.get(topic);
    }

    if (topicPublishInfo.isHaveTopicRouterInfo() || topicPublishInfo.ok()) {
        return topicPublishInfo;
    } else {
        this.mQClientFactory.updateTopicRouteInfoFromNameServer(topic, true, this.defaultMQProducer);
        topicPublishInfo = this.topicPublishInfoTable.get(topic);
        return topicPublishInfo;
    }
}

然后通过MessageQueueSelector选取一个目标队列:

MessageQueue mq = mQClientFactory.getClientConfig()
    .queueWithNamespace(selector.select(messageQueueList, userMessage, arg));

然后调用消息发送核心方法,将消息发送到Broker。

return this.sendKernelImpl(msg, mq, communicationMode, sendCallback, 
                           null, timeout - costTime);

Producer保证发送有序非常简单,只要保证相同ShardingKey的消息发送到同一队列即可。

Tips:必须使用同步发送,异步/单向发送,无法保证消息被有序写入队列。

3.2 消费有序

相较于发送有序,消费有序就复杂的多。下面是Consumer保证消费有序的时序图:

Spring整合RocketMQ顺序消费 rocketmq顺序消息原理_RocketMQ_02

1.Consumer在启动时,会立即触发一次重平衡操作,给自己分配MessageQueue。对于新分配的MessageQueue,会开始拉取消息。但是,对于顺序消息,在拉取前必须向Broker竞争锁队列,锁成功了才能开始拉取消息并消费。锁失败了,说明当前MessageQueue还在被其他Consumer消费,为了保证消息不重复和顺序消息的语义,当前Consumer应该停止拉取,等待下次重平衡(20秒)。

if (!this.processQueueTable.containsKey(mq)) {
    // 新分配的MessageQueue,需要拉取消息
    if (isOrder && !this.lock(mq)) {
        /**
         * 如果是顺序消息,需要向Broker申请锁队列,加锁成功才开始消费。
         * 因为队列可能在被其它Consumer消费,还没有提交消费位点,直接拉取会造成消息重复、消费顺序错乱。
         */
        continue;
    }
}


2.Consumer开始锁队列,怎么锁呢?全局锁状态由Broker维护,当然是向Broker发锁请求了。先查找Broker的Master主机地址,然后构建锁请求体LockBatchRequestBody,设置消费组、客户端ID、要锁的队列集合,然后发送给Broker,Broker会返回此次Consumer锁住的全部队列,基于此判断是否加锁成功。

public boolean lock(final MessageQueue mq) {
    // 查找Broker Master主机地址
    FindBrokerResult findBrokerResult = this.mQClientFactory.findBrokerAddressInSubscribe(mq.getBrokerName(), MixAll.MASTER_ID, true);
    if (findBrokerResult != null) {
        // 构建请求体
        LockBatchRequestBody requestBody = new LockBatchRequestBody();
        requestBody.setConsumerGroup(this.consumerGroup);// 消费组
        requestBody.setClientId(this.mQClientFactory.getClientId());// 客户端实例ID
        requestBody.getMqSet().add(mq);// 申请锁哪些队列

        try {
            // 发送请求,Broker返回锁住的队列集合
            Set<MessageQueue> lockedMq =
                this.mQClientFactory.getMQClientAPIImpl().lockBatchMQ(findBrokerResult.getBrokerAddr(), requestBody, 1000);
            for (MessageQueue mmqq : lockedMq) {
                ProcessQueue processQueue = this.processQueueTable.get(mmqq);
                if (processQueue != null) {
                    processQueue.setLocked(true);
                    processQueue.setLastLockTimestamp(System.currentTimeMillis());
                }
            }
            // 目标队列在里面,就说明加锁成功了
            boolean lockOK = lockedMq.contains(mq);
            log.info("the message queue lock {}, {} {}",
                     lockOK ? "OK" : "Failed",
                     this.consumerGroup,
                     mq);
            return lockOK;
        } catch (Exception e) {
            log.error("lockBatchMQ exception, " + mq, e);
        }
    }
    return false;
}

3.一旦加锁成功,就会开始构建PullRequest对象开始拉取消息,拉取成功后,在PullCallback里会将拉取到的消息填充到ProcessQueue,然后提交消费请求,让ConsumeMessageOrderlyService开始消费消息。

// 拉取到的消息保存到ProcessQueue
boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());
// 构建消费请求,让消费者去消费消息
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
    pullResult.getMsgFoundList(),
    processQueue,
    pullRequest.getMessageQueue(),
    dispatchToConsume);

提交消费请求,核心在于构建ConsumeRequest对象,它是ConsumeMessageOrderlyService的内部类,代表一个消费任务,实现了Runnable接口,构建完成会被提交到消费线程池调度执行。

ConsumeRequest的属性如下,很好理解:

class ConsumeRequest implements Runnable {
    // 待处理队列,里面包含拉取到的消息
    private final ProcessQueue processQueue;
    // 消息队列,消费前必须竞争到本地锁
    private final MessageQueue messageQueue;
}

核心在于run方法,消费消息时,先获取MessageQueue的锁对象,然后通过synchronized关键字保证只有一个线程消费。然后从ProcessQueue中获取拉取到的消息,调用MessageListener消费消息。

final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
synchronized (objLock) {
    List<MessageExt> msgs = this.processQueue.takeMessages(consumeBatchSize);
    ConsumeOrderlyStatus status = messageListener.consumeMessage(Collections.unmodifiableList(msgs), context);
}
// 贴的源码有精简

然后调用processConsumeResult方法处理消费结果,顺序消息和普通消息在处理消费结果最大的区别就是,顺序消息一旦消费失败,默认会一直重试,不会跳过,因为一旦跳过就失去顺序消息的语义了。因此,如果消费状态返回的是SUSPEND_CURRENT_QUEUE_A_MOMENT,Consumer就会将消息重新put到msgTreeMap,然后提交消费请求,稍后进行重试。

case SUSPEND_CURRENT_QUEUE_A_MOMENT:
    this.getConsumerStatsManager().incConsumeFailedTPS(consumerGroup, consumeRequest.getMessageQueue().getTopic(), msgs.size());
    // 校验最大重试次数,默认Integer.MAX_VALUE
    if (checkReconsumeTimes(msgs)) {
        // 标记消息等待重新消费
        consumeRequest.getProcessQueue().makeMessageToConsumeAgain(msgs);
        // 提交消费请求,稍后重试
        this.submitConsumeRequestLater(
            consumeRequest.getProcessQueue(),
            consumeRequest.getMessageQueue(),
            context.getSuspendCurrentQueueTimeMillis());
        continueConsume = false;
    } else {
        commitOffset = consumeRequest.getProcessQueue().commit();
    }
    break;

至此,顺序消息的消费流程就结束了。

4. 总结

顺序消费需要Producer、Broker、Consumer三者一起配合才能正常工作。首先,Producer需要确保相同ShardingKey的消息被发送到同一分区队列中,因为队列本身是能保证FIFO的,这是基础。然后,Broker需要维护全局MessageQueue的锁状态,Consumer拉取消息前,必须保证竞争锁队列成功,否则就会导致同一MessageQueue里的消息被多个Consumer实例消费,造成消息重复消费和顺序错乱。最后,Consumer在消费MessageQueue的消息前,必须确保竞争MessageQueue本地锁成功,同一个MessageQueue同一时间最多只能被一个线程消费。