RocketMQ-顺序消息原理详解(上)

本文基于RocketMQ 4.7.1版本

rocketmq提供了顺序消息的功能,可以保证消息以生产的顺序被消费,用官网上的话就是“FIFO order”。该功能在某些场景下非常有用,比如mysql的binlog日志,还有订单处理场景,订单的生成、支付、撤销必须是有序的。
本文将首先介绍消息为什么会出现乱序,然后给出一个顺序消息的例子,最后基于该例子介绍顺序消息的原理。

一、为什么会出现消息乱序消费

先来看生产者,rocketmq的一个主题下可以创建多个队列,默认情况下生产者将消息轮询发送到各个不同的队列上,这就导致本来需要有序的多个消息进入了不同队列。
下面再来看一下消费者。如果消费者使用的是DefaultMQPushConsumer,下面分两种情况来看:
(1)、一种该消费组内只有一个消费者,所有的消息都会发送到该消费者,DefaultMQPushConsumer只有一个线程拉取消息,因此可以确保按照队列顺序拉取,但是消息拉到本地后消费的时候是多线程消费的,默认是20个线程同时消费,而且消费过程是异步的,这样便导致了乱序,如下图:
在这里插入图片描述

(2)、如果消费组内有多个消费者,那么队列会被分发给不同的消费者,导致顺序消费的消息也被分发给不同的消费者,每个消费者独立处理消息,导致消息乱序,如下图:
在这里插入图片描述

如果消费者使用的是DefaultMQPullConsumer,为了确保有序,开发人员需要控制只有一个线程轮询消费所有队列的消息,而且每次只能从一个队列里面拉取一个消息消费,尽管可以确保有序消费,但是极大的降低了消费者的性能,而且造成单点,一旦消费者挂掉,消息就无法消费了。
因此为了保证消息的顺序消费,需要满足以下条件:

  1. 生产者必须将需要保持有序的消息按顺序发送到同一个主题下的同一个队列,避免使用多线程发送顺序消息;
  2. 对于消费者来说,一个队列的消息同时只能有一个线程消费。

下面通过代码来看一下rocketmq如何保证消息顺序消费。

二、顺序消息代码示例

1、生产者生产顺序消息

下面展示一下生产者如何生产顺序消息。
下面的代码来源与官网。

	public static void main(String[] args)throws Exception {
        //创建生产者,生产者对象与普通消息的生产者对象一样,也是使用DefaultMQProducer
        DefaultMQProducer producer = new DefaultMQProducer("order_producer");
        producer.setNamesrvAddr ("localhost:9876");//不知道为什么官网的示例代码没有这一行
        producer.start();
        String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
        for (int i = 0; i < 100; i++) {
            int orderId = i % 10;
            //创建一个消息,设置了tag、key
            Message msg = new Message("topicTest", tags[i % tags.length], "KEY" + i,
                    ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
            //发送消息,创建了一个队列选择器,使用队列选择器将消息发送到指定队列
            //send()的入参orderId是用于做队列选择的参数,队列选择算法是orderId%队列个数
            SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
                @Override
                public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                    Integer id = (Integer) arg;
                    int index = id % mqs.size();//队列选择算法
                    return mqs.get(index);
                }
            }, orderId);

            System.out.printf("%s%n", sendResult);
        }
        producer.shutdown();
    }

生产者通过MessageQueueSelector选择器确保需要顺序消费的消息都放入同一个队列,这样满足顺序消费的第一个条件。

2、基于DefaultMQPushConsumer顺序消费

下面展示的是DefaultMQPushConsumer如何顺序消费消息。
下面的代码来源与官网。

    public static void main(String[] args) throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order_consumer");
        //设置消费者从第一个消息开始消费
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        //指定nameserver地址,可以有多个,使用分号分隔
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("topicTest", "TagA || TagC || TagD");
        //与普通消费者不同,这里使用的监听器是MessageListenerOrderly,
        //MessageListenerOrderly的区别是:
        //MessageListenerOrderly保证一个队列只有一个线程消费
        //MessageListenerConcurrently是多个线程同时消费多个队列
        consumer.registerMessageListener(new MessageListenerOrderly() {

            AtomicLong consumeTimes = new AtomicLong(0);
            @Override
            public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs,
                                                       ConsumeOrderlyContext context) {
                context.setAutoCommit(false);//设置非自动提交
                System.out.printf(Thread.currentThread().getName() + " Receive New Messages: " + msgs + "%n");
                this.consumeTimes.incrementAndGet();
                if ((this.consumeTimes.get() % 2) == 0) {
                    return ConsumeOrderlyStatus.SUCCESS;//消息消费成功
                } else if ((this.consumeTimes.get() % 3) == 0) {
                    return ConsumeOrderlyStatus.ROLLBACK;//用于binlog日志的消费
                } else if ((this.consumeTimes.get() % 4) == 0) {
                    return ConsumeOrderlyStatus.COMMIT;//用于binlog日志的消费
                } else if ((this.consumeTimes.get() % 5) == 0) {
                    context.setSuspendCurrentQueueTimeMillis(3000);
                    return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;//将当前队列挂起
                }
                return ConsumeOrderlyStatus.SUCCESS;//消息消费成功

            }
        });

        consumer.start();

        System.out.printf("Consumer Started.%n");
    }

与普通消费者不一样,顺序消息使用的监听器是MessageListenerOrderly,MessageListenerOrderly可以保证一个队列只有一个线程消费,这样满足了顺序消费的第二个条件。

3、基于DefaultMQPullConsumer顺序消费

DefaultMQPullConsumer顺序消费的代码和之前文章中介绍的代码基本上一样,只要保证一个队列同时只有一个线程消费即可。
代码可以参考文章:《RocketMQ-如何创建消费者》中关于DefaultMQPullConsumer的介绍。

三、顺序消费原理

1、生产者生产顺序消息原理

生产者生产的顺序消息需要放入同一个队列,这一点是怎么做到的?
答案就是MessageQueueSelector。
一般的可以不设置MessageQueueSelector,如果不设置的话,rocketmq使用默认的选择器MQFaultStrategy。MQFaultStrategy是轮询选择不同的队列。
如果设置了MessageQueueSelector,那么在发出消息前,rocketmq调用MessageQueueSelector.select(final List<MessageQueue> mqs, final Message msg, final Object arg)方法选择其中一个队列。这样可以通过自定义MessageQueueSelector,将顺序消息放入同一个队列中。
下面介绍一下MessageQueueSelector.select()三个入参:

  • mqs:是该主题下的写队列集合,包含了主题,broker名和队列号;
  • msg:消息对象;
  • arg:DefaultMQProducer.send()方法的最后一个入参,作为选择队列的关键字,比如订单号,同一个订单号下的消息发送到同一个队列。

2、消费者顺序消费原理

DefaultMQPullConsumer顺序消费是开发人员自己通过编程实现的,只要满足一个队列同时只有一个线程消费即可,本文不再对DefaultMQPullConsumer进行介绍。下面主要介绍DefaultMQPushConsumer。
对于DefaultMQPushConsumer来说,通过将顺序消费代码与普通消费代码进行对比,可以发现两者的区别是监听器的不同。普通消费是MessageListenerConcurrently,顺序消费是MessageListenerOrderly。
MessageListenerConcurrently从名字就可以看出,该监听器是并发处理消息的。它内部有一个线程池,默认是20个线程,消费者将拉取请求发出去,后面的工作就交给异步线程了,异步线程收到broker返回的消息后将消息转发给MessageListenerConcurrently,MessageListenerConcurrently使用ConsumeRequest封装,然后将ConsumeRequest放入线程池中并发消费消息。
当把监听器换成MessageListenerOrderly后,异步线程收到消息,便将消息转发给MessageListenerOrderly处理,转发是通过调用MessageListenerOrderly.submitConsumeRequest()完成的,不过在转发之前,先要对消息按照位移排好序,代码如下:

//对拉取到的消息排序,dispatchToConsume表示是否启动线程池,为什么有这个参数,下文介绍
boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());
//调用MessageListenerOrderly.submitConsumeRequest()方法,启动线程池处理消息
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
    pullResult.getMsgFoundList(),
    processQueue,
    pullRequest.getMessageQueue(),
    dispatchToConsume);
//检查是否设置了拉取间隔,默认是0,如果是0,则调用
//executePullRequestImmediately方法新增一个拉取请求pullRequest,为下次拉取消息做准备
//否则,创建一个定时任务,延迟this.defaultMQPushConsumer.getPullInterval()毫秒后再新增一个拉取请求
if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
        DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
} else {
    DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
}

上面的代码是拉取回消息后,异步线程对消息进行回调处理的部分代码,从上面的代码可以看到在将消息转发给监听器前,调用了processQueue.putMessage()进行排序。下面深入putMessage()方法来看一下如何排序的:

    public boolean putMessage(final List<MessageExt> msgs) {
        boolean dispatchToConsume = false;
        try {
        	//加锁,确保只有一个线程操作msgTreeMap
            this.lockTreeMap.writeLock().lockInterruptibly();
            try {
            	//统计拉取回了多少个有效消息,或者说是拉取回了多少个与之前不重复的消息
                int validMsgCnt = 0;
                for (MessageExt msg : msgs) {
                    //遍历消息,将消息放入msgTreeMap,msgTreeMap是TreeMap类型的
                    //TreeMap会对传入的key进行排序,这里是根据消息的位移排序的,
                    //位移越小,排序越靠前
                    MessageExt old = msgTreeMap.put(msg.getQueueOffset(), msg);
                    if (null == old) {
                        validMsgCnt++;
                        this.queueOffsetMax = msg.getQueueOffset();
                        msgSize.addAndGet(msg.getBody().length);
                    }
                }
                //msgCount做一些数据统计,以及限流使用,
                //如果msgCount过大,消费者会暂停从broker拉取消息
                msgCount.addAndGet(validMsgCnt);
                //consuming表示当前线程池是否正在对消息进行消费
                if (!msgTreeMap.isEmpty() && !this.consuming) {
                    dispatchToConsume = true;
                    this.consuming = true;
                }

                if (!msgs.isEmpty()) {
                    MessageExt messageExt = msgs.get(msgs.size() - 1);
                    String property = messageExt.getProperty(MessageConst.PROPERTY_MAX_OFFSET);
                    if (property != null) {
                        long accTotal = Long.parseLong(property) - messageExt.getQueueOffset();
                        if (accTotal > 0) {
                            this.msgAccCnt = accTotal;//作为动态调整线程池个数的参数,当前版本暂未实现该功能
                        }
                    }
                }
            } finally {
                this.lockTreeMap.writeLock().unlock();
            }
        } catch (InterruptedException e) {
            log.error("putMessage exception", e);
        }
        return dispatchToConsume;
    }

putMessage()将拉取回的消息放到TreeMap中,TreeMap会根据位移大小进行排序。这个方法保证了消息按照位移从小到大排好序,后拉取的消息一定排在之前拉取的消息的后面。
putMessage()方法里面有一个变量:consuming,这个变量用于控制并发处理消息,表示当前是否有线程正在处理消息。线程处理的消息是从msgTreeMap获取的,如果consuming=true,说明当前有线程正在处理消息,那么就没有必要再次启动一个线程继续处理,因此MessageListenerOrderly.submitConsumeRequest()方法里面根据putMessage()方法的返回值做了判断。当msgTreeMap中的消息处理完后,会将consuming设置为false。
消息排好序后便将消息转发给MessageListenerOrderly.submitConsumeRequest()方法,下面来看一下submitConsumeRequest():

    public void submitConsumeRequest(
        final List<MessageExt> msgs,//消息集合,不过方法里没有使用,因为消息是从TreeMap中获取的
        final ProcessQueue processQueue,//对应的队列,相当于队列的状态对象
        final MessageQueue messageQueue,//对应的队列,队列的一些静态属性,比如主题名
        final boolean dispathToConsume) {
        //dispathToConsume=true表示当前没有线程处理消息,那么便启动线程池的一个线程处理
        //dispathToConsume=false表示当前有线程正在处理消息,那么无需再启动线程,因为消息都已经放入到TreeMap中了,线程直接从TreeMap中按顺序拉取消息
        if (dispathToConsume) {
        	//使用ConsumeRequest封装消息
            ConsumeRequest consumeRequest = new ConsumeRequest(processQueue, messageQueue);
            //consumeExecutor是一个线程池,默认线程数是20
            this.consumeExecutor.submit(consumeRequest);
        }
    }

到这里为止,消息已经排好序,而且已经调用submitConsumeRequest() 启动线程池处理消息了,下面来看一下ConsumeRequest的run()方法是如何处理消息的:

//下面代码比较多,我做了删减
public void run() {
    if (this.processQueue.isDropped()) {
        return;
    }
    //获得队列对应的锁对象,确保一个队列同时只能一个线程消费
    final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
    synchronized (objLock) {
        if (MessageModel.BROADCASTING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
                //检查是否上锁成功,锁是否超时,这个锁位于broker上
            || (this.processQueue.isLocked() && !this.processQueue.isLockExpired())) {
            final long beginTime = System.currentTimeMillis();
            for (boolean continueConsume = true; continueConsume; ) {
                if (this.processQueue.isDropped()) {
                    break;
                }
				//下面两个if判断,主要是检查当前队列是否在broker端已经加锁还有锁是否过期,
				//如果没有加锁或者过期,则发起请求对队列进行加锁,并增加一个定时任务,稍后重新对消息进行消费
                if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
                    && !this.processQueue.isLocked()) {
                    log.warn("the message queue not locked, so consume later, {}", this.messageQueue);
                    ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 10);
                    break;
                }

                if (MessageModel.CLUSTERING.equals(ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.messageModel())
                    && this.processQueue.isLockExpired()) {
                    log.warn("the message queue lock expired, so consume later, {}", this.messageQueue);
                    ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 10);
                    break;
                }

                long interval = System.currentTimeMillis() - beginTime;
                if (interval > MAX_TIME_CONSUME_CONTINUOUSLY) {
                    ConsumeMessageOrderlyService.this.submitConsumeRequestLater(processQueue, messageQueue, 10);
                    break;
                }
                //consumeBatchSize表示一次最多消费几个消息,默认是1个
                final int consumeBatchSize =
                    ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
                //从msgTreeMap中取出消息
                List<MessageExt> msgs = this.processQueue.takeMessages(consumeBatchSize);
                defaultMQPushConsumerImpl.resetRetryAndNamespace(msgs, defaultMQPushConsumer.getConsumerGroup());
                if (!msgs.isEmpty()) {
                    final ConsumeOrderlyContext context = new ConsumeOrderlyContext(this.messageQueue);
                    //代码删减
                     ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.executeHookBefore(consumeMessageContext);
                    }

                    long beginTimestamp = System.currentTimeMillis();
                    ConsumeReturnType returnType = ConsumeReturnType.SUCCESS;
                    boolean hasException = false;
                    try {
                        this.processQueue.getLockConsume().lock();//加锁,确保下面的流程只有一个线程操作
                        if (this.processQueue.isDropped()) {
                           
                            break;
                        }
                        //调用自定义监听器,消费消息
                        status = messageListener.consumeMessage(Collections.unmodifiableList(msgs), context);
                    } catch (Throwable e) {
                        
                        hasException = true;
                    } finally {
                        this.processQueue.getLockConsume().unlock();//解锁
                    }
                    //对于消费失败的打印日志
                    if (null == status
                        || ConsumeOrderlyStatus.ROLLBACK == status
                        || ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT == status) {
                        log.warn("consumeMessage Orderly return not OK, Group: {} Msgs: {} MQ: {}",
                            ConsumeMessageOrderlyService.this.consumerGroup,
                            msgs,
                            messageQueue);
                    }
                    //returnType是为回调钩子使用的
                    long consumeRT = System.currentTimeMillis() - beginTimestamp;
                    if (null == status) {
                        if (hasException) {
                            returnType = ConsumeReturnType.EXCEPTION;
                        } else {
                            returnType = ConsumeReturnType.RETURNNULL;
                        }
                    } else if (consumeRT >= defaultMQPushConsumer.getConsumeTimeout() * 60 * 1000) {
                        returnType = ConsumeReturnType.TIME_OUT;
                    } else if (ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT == status) {
                        returnType = ConsumeReturnType.FAILED;
                    } else if (ConsumeOrderlyStatus.SUCCESS == status) {
                        returnType = ConsumeReturnType.SUCCESS;
                    }

                    if (ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.hasHook()) {
                        consumeMessageContext.getProps().put(MixAll.CONSUME_CONTEXT_TYPE, returnType.name());
                    }

                    if (null == status) {
                        status = ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                    }

                    if (ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.hasHook()) {
                        consumeMessageContext.setStatus(status.toString());
                        consumeMessageContext
                            .setSuccess(ConsumeOrderlyStatus.SUCCESS == status || ConsumeOrderlyStatus.COMMIT == status);
                        ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.executeHookAfter(consumeMessageContext);
                    }
                    //统计信息
                    ConsumeMessageOrderlyService.this.getConsumerStatsManager()
                        .incConsumeRT(ConsumeMessageOrderlyService.this.consumerGroup, messageQueue.getTopic(), consumeRT);

                    continueConsume = ConsumeMessageOrderlyService.this.processConsumeResult(msgs, status, context, this);
                } else {
                    continueConsume = false;
                }
            }
        } else {
            if (this.processQueue.isDropped()) {
                return;
            }

            ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 100);
        }
    }
}

run()方法内容还是挺多的,这里总结一下:

  1. 获取队列的锁对象,对队列加锁,防止消费者有多个线程同时消费同一个队列;
  2. 检查broker端是否已经对队列加锁,这里是防止有多个消费者同时消费同一个队列的消息;
  3. 从msgTreeMap中取出一个消息;
  4. 调用回调钩子;
  5. 将消息转发给自定义监听器,消费消息;
  6. 判断消息消费结果;
  7. 调用回调钩子;
  8. 对消费结果判断是否提交位移。

消费者在上面的run()方法里面调用自定义的监听器处理消息,每次读取消息都是从msgTreeMap(TreeMap类型)中获取,因为msgTreeMap是根据消息位移排序的,而且也对队列加了锁,从而保证同时只有一个线程处理消息。
到这里为止,通过msgTreeMap排序,控制单线程处理消息,其实已经可以应对绝大部分情况了。但是因为rocketmq有一个再平衡服务,如果某个消费者网络不稳定,导致其他消费者认为其掉线了,那么可能出现同一个时刻,有两个消费者同时消费同一个队列的情况。如果出现这种情况,那么可能导致消息重复消费序,为了防止出现这种情况,rocketmq提供了对broker上的队列加锁的功能。大家可以看到run()方法里面有下面这个判断:

this.processQueue.isLocked() && !this.processQueue.isLockExpired()

这个判断便是校验是否已经对broke上的队列加锁了,如果没有加锁,则禁止消费消息,需要执行else里面的逻辑:

ConsumeMessageOrderlyService.this.tryLockLaterAndReconsume(this.messageQueue, this.processQueue, 100);

tryLockLaterAndReconsume()会创建一个定时任务,定时任务完成两件事:

  1. 向broker发送LockBatchRequestBody请求,请求将该broker上当前消费者消费的队列加锁;
  2. 加锁成功后,重新调用submitConsumeRequest(),开启线程重新处理消息。

除了上面这种方式加锁之外,消费者还有一个定时任务,每隔20s启动一次,它是由ConsumerMessageOrderlyService.lockMQPeriodically()触发的,该任务获取当前消费者正在消费的所有队列,然后向这些队列所在的broker发送LockBatchRequestBody请求,请求对这些队列加锁。
无论是通过定时任务还是其他方式加锁,每次加的锁的过期时间是30s。而且同一个消费者可以重复对一个队列进行加锁。
这里有一点需要注意,在broker上对队列加的锁,消费者是不主动释放的,也就是说一个消费者加了锁之后,只能等待锁超时,然后大家再去竞争锁。消费者只有一种情况会主动释放锁,就是消费者消息消费结束,主动调用shutdown方法关闭。
到这里顺序消费消费的原理就介绍完了,不过关于顺序消费还有一些内容,比如主动提交、监听器返回值问题,这个放到下一篇文章介绍。

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值