RocketMQ快速入门:如何保证消息顺序消费,附带源码分析(八)

0. 引言

顺序消息在金融、电商等场景中被广泛应用,只要业务需求对流程顺序性有严格要求,就有顺序消息的应用之地。因此理解顺序消息的原理和实现路径,是我们学习rocketmq的必经之路。

1. 乱序产生在哪些阶段

在理解如何保证顺序性之前,我们要先理解什么情况会导致乱序,消息从产生到被消费的过程中,哪些阶段会导致消息乱序。

在这里插入图片描述
消息的发送实际上需要经历3个阶段:生产者发送消息到broker,broker存储消息,消费者从broker消费消息

那么我们依次来看这三个阶段是否有导致乱序的可能性

第一阶段:生产者发送消息到broker

假设我们是商品购买的场景,需要经历3个状态:创建订单、支付订单、签收订单。目前部署了多个订单管理服务节点,也就是有多个生产者,想象一个场景,生产者1先发送了订单创建消息,但是因为网络波动或者服务器异常等原因,导致发送经历了很久才到达broker,而生产者2晚1s发送了订单支付消息,传输耗时很短,反而比生产者1发送的订单创建消息更早发送到broker,这就导致支付消息反而比创建订单消息更早到达,从业务逻辑上显然不对,也无法保证顺序性了。

在这里插入图片描述
那么怎么解决呢?

首先网络波动我们肯定是无法保证的,传输过程的异常是无法干预了,那么只能从生产者着手了。

  • 通过业务时间间隔 + 业务状态控制

第一种方式我们可以通过业务时间的天然间隔来减少这类情况的发生,比如订单创建到订单支付一般会经历好几秒的时间,这段时间一般足够我们容错,同时通过业务状态控制,比如订单未创建好之前不允许支付订单

  • 通过同步发送 + 分布式锁

一般为了满足业务并发,我们都会采用多个生产者多个线程发送,那么首先要保证在同一个生产者中的顺序性,必须得是同步发送,其次要保证在不同生产者中的顺序性,可以通过分布式锁来锁定订单号等业务主键,然后控制发送顺序。

  • 同步发送 + 生产者单节点单线程

如果你的业务允许单节点单线程同步,那么就不用考虑分布式锁的问题了,但是带来的缺点就是性能上的瓶颈,需要慎用。

以上前两种手段一般需要我们结合着使用,如此,单纯从消息发送角度,可以控制顺序性,但是这样就够了吗?咱们往下看。

第二阶段:broker存储消息

我们继续结合broker存储消息的物理结构来探究存储消息时的顺序性问题。

前面我们聊过,rocketmq中同一个topic下是划分了多个queue的,queue才是存储消息的最小单位。而生产者在发送消息时是不指定具体的queue的,而是通过topic来指定发送。

那么就容易产生一种场景:
订单A的创建消息A1和支付消息A2,都是按照顺序发送到broker的,订单B也如此,但是因为一个topic下有多个queue,broker在分配消息时,就可能会将订单A的两个消息分别分发到两个队列,订单B的两个消息也分发到两个队列,如下图所示。
在这里插入图片描述
而消费者在消费时,可能存在多个消费者同时消费,而消费者可以监听不同队列,从而导致消费者同时获取到了订单A的创建消息和支付消息,或者先获取到了支付消息,从而打乱了先创建后支付的顺序性,如此也不满足顺序性。

那么怎么解决呢?

根本原因就是broker没有按照发送来的顺序进行存储,一个比较暴力的方式,就是控制队列数只有1个,这样可以保证一定是按照发送过来的顺序进行存储的。这个方法我们叫控制全局顺序性,他可以保证全局顺序,但是因为只有1个队列了,缺点就随之而来,那就是性能不足。
在这里插入图片描述

但实际上,我们多数场景并不要求严格的全局顺序,我们只需要保证局部顺序就够了, 怎么理解呢?

比如订单的这个场景,我们只需要同一个订单的消息保持顺序性即可,不同订单之间不要求顺序性。
也就是说只要订单A的消息A1在A2之前,订单B的消息B1在B2之前即可,至于A,B之间的顺序怎样都可以,不影响业务。
在这里插入图片描述
基于这样的考虑,rocketmq提出这样的解决办法:那就是把相同订单的消息放到同一个queue中,通过队列隔离来实现局部顺序性,那么对于单一订单而言,就只存在一个队列,也就保证了存储的消息顺序性。
在这里插入图片描述
同时还要保证同一个队列中的消息是FIFO(先进先出)的,即先发送的消息先被消费,不会出现跨序消费的问题,这就需要设置–order属性为true,下文我们详细讲解怎么设置。

broker挂了怎么办?
另外再考虑个场景,多节点部署时,如果某一个broker节点挂了,而之前某订单的消息又是存储在这个broker上面的,如何处理呢?

如果将新消息发送到其他broker上,那么之前的消息明显得不到消费,会出现乱序,同时如果broker恢复了,两个同时消费也会出现顺序问题。

这个问题在官方文档中也有解释:https://rocketmq.apache.org/zh/docs/4.x/producer/03message2/

即通过配置--order(-o)参数来控制是可用性优先还是顺序性优先,配置为true时会设置顺序性优先,即当broker挂掉后,如果发送的队列在这个broker上的,都会显示发送失败,以此保证强顺序性,只有等待broker恢复后再次发送,可以结合重发机制来预防broker宕机时的异常处理。

同时还要配置namesrv的orderMessageEnable 和 returnOrderTopicConfigToBroker 为true才能保证严格顺序性,下文我们详细讲解如何配置。

在这里插入图片描述

第三阶段:消费者从broker消费消息

消费者端会产生乱序的场景,就在于多个同一订单的消息被不同的消费者消费了。举个例子,创建订单需要3s, 支付订单需要1s,如果同一订单的创建消息和支付消息被不同的消费者消费了,就会导致可能订单还没来得及创建时就执行支付操作了,显然会报错,顺序性就不满足了。

而当同一订单的消息被同一个消费者接收时,可以保证先创建再支付,当然前提是消费者消费这边要是同步执行的,不能异步执行。

在这里插入图片描述
那么rocektmq消费者端要实现顺序性的原理就是确保同一种类的消息被同一消费者消费。那么如何实现呢?

大家还记得我们在生产者中如何保证顺序性的吗?通过分布式锁,所以消费者端其实也是通过加锁来实现。只是这里的分布式锁没有依赖redis、zk之类的中间件,而是通过broker来生成分布式锁。

消费者端的加锁主要分成3步:
(1)消费者start启动时,消费者向broker申请对MessageQueue加锁,将消费者与队列绑定,保证后续这个队列消息只会发送给这个消费者
(2)消费者消费消息时,会申请MessageQueue锁,确保同一时间,一个队列只有一个线程处理消息
(3)为了保证消费过程中不会重复消费,还会对ProcesQueue加锁

下面我们从源码层级来分析这3个加锁过程,来帮助大家理解,如果不需要了解的,可以直接跳过查看后面的顺序消费实现章节

源码分析

  • (1)启动start方法时,消费者向broker申请加锁
    1、首先我们来回顾一下顺序消费的实现代码
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group_test");

        consumer.setNamesrvAddr("127.0.0.1:9876");

        // 集群消费模式
        consumer.setMessageModel(MessageModel.CLUSTERING);

        // 设置topic
        consumer.subscribe("topic_order", "*");

        // 注册回调函数,处理消息
        consumer.registerMessageListener(new MessageListenerOrderly() {
              @Override
              public ConsumeOrderlyStatus consumeMessage(List<MessageExt> list, ConsumeOrderlyContext consumeOrderlyContext) {
                  byte[] body = list.get(0).getBody();
                  System.out.println("接收消息:"+new String(body, StandardCharsets.UTF_8));
                  return ConsumeOrderlyStatus.SUCCESS;
              }
        });

        // 启动消费者实例
        consumer.start();

2、我们进入DefaultMQPushConsumer类的start方法,可以看到该方法里面是调用了DefaultMQPushConsumerImpl.start
在这里插入图片描述
3、在中间位置可以看到调用了ConsumeMessageOrderlyService.start()方法,而对应消费者申请分布式锁,锁定指定的队列的代码就在其中
在这里插入图片描述
4、可以看到该方法中,是将lockMQPeriodically加锁任务放入定时任务中,每20s执行一次。也就是消费者每20s会向broker进行分布式锁的续约。
在这里插入图片描述
5、我们继续往lockMQPeriodically方法中查看,该方法指向RebalanceImpl.lockAll方法,lockAll即向broker加锁该消费者占用的队列。
在这里插入图片描述

  • (2)消费者消费消息时,会申请MessageQueue锁
    1、在消费者进行消费时,会先执行下负载均衡的代码,来对topic分配队列,我们通过start方法来跟踪:DefaultMQPushConsumerImpl.start > MQClientInstance.start > RebalanceService.start > this.rebalanceService.start() > RebalanceService.run > MQClientInstance.doRebalance > MQConsumerInner.tryRebalance > DefaultMQPushConsumerImpl.tryRebalance > RebalanceImpl.doRebalance > RebalanceImpl.rebalanceByTopic

2、这里可以看到区分了两种消费模式,广播模式下是组下所有消费者都会收到消息,我们重点关注updateProcessQueueTableInRebalance方法
在这里插入图片描述
3、当为顺序消费时,会进行加锁操作,调用lock方法

在这里插入图片描述
4、lock方法中就是通过将MessageQueue进行broker加锁,以此保证同一时间只有一个消费者消费这个队列
在这里插入图片描述

  • (3)为了保证消费过程中不会重复消费,还会对ProcesQueue加锁
    1、从DefaultMQPushConsumerImpl.pullMessage拉去消息的方法查看,可以看到这里对processQueue的加锁状态进行了判断,如果加锁了,才会去获取消费偏移量,然后进行消费,以此保证不被其他线程重复消费相同的偏移量。
    在这里插入图片描述
    2、如果没有加锁,则会30s后再来执行消费,等待processQueue获取锁

    3、那么processQueue在哪儿加的锁呢?还记得咱们分析第一阶段消费者向broker申请锁吗,其实这时就会对processQueue设置locked=true,也就是每20s进行的一次加锁操作
    在这里插入图片描述
    如此通过这三次加锁,来保证消费者消费的顺序性。

2. 实现

2.1 顺序消息发送

官方文档:https://rocketmq.apache.org/zh/docs/4.x/producer/03message2

1、顺序发送的重点,在于让同类消息发送到同一队列上,rocketmq中是通过队列选择器MessageQueueSelector来实现的

我们查看send方法,需要提供3个参数:消息、队列选择器和一个arg。前两个参数可以理解,那么这个arg是什么呢,其实就是我们用于区分消息类型的标识,比如为了防止上述的混淆,我们要将同一订单的不同状态的消息都发送到同一个队列中,那么我们就可以以订单号作为这个标识,其目的就是将同一类型的消息通过这个标识进行区分。
在这里插入图片描述

2、在发送消息之间,我们需要创建队列,并且将队列指定为顺序队列,即创建队列时指定–order参数为true
(1)我们需要通过namesrv内置的mqadmin工具来实现指定
(2)进入namesrv,在其安装目录的bin目录下执行(如果你和我一样是通过docker安装的rocketmq,那直接进入docker namesrv的容器即可, 进入后的当前目录就是bin目录)

./mqadmin updateTopic -c DefaultCluster -t topic_order -o true -n localhost:9876

-n NameServer的地址和端口
-c 指定集群名称
-t 指定主题名称
-w 指定队列的数量,如果要保证全局顺序性,可以设置队列数为1,以此来避免多队列产生的非顺序性问题

在这里插入图片描述
3、其次如果需要保证严格的顺序性,还需要在namesrv中配置orderMessageEnablereturnOrderTopicConfigToBroker 是 true

(注:默认配置文件为namesrv.properties(通过./mqnamesrv -p即可查看配置文件路径),也可通过创建自定义配置文件namesrv.conf, 启动namesrv时指定配置文件nohup sh bin/mqnamesrv -c conf/namesrv.conf &, 本文示例无需配置该项也可保证顺序性,但生产时建议配置)

orderMessageEnable=true
returnOrderTopicConfigToBroker=true

4、整体的顺序发送代码如下,这里我简单使用一个orderId作为区分标识(也叫区分键),将奇数和偶数消息分别视为一种消息,大家实际应用时可以根据自己的业务调整。

其MessageQueueSelector对象的定义,主要是实现其select方法,这里就是通过arg参数,做取余运算,进行队列的选择。当然大家也可以用arg的hashcode来作为标识处理

 public static void main(String[] args) throws Exception{
        DefaultMQProducer producer = new DefaultMQProducer("group_test");
        // 声明namesrv地址
        producer.setNamesrvAddr("localhost:9876");
        // 设置重试次数
        producer.setRetryTimesWhenSendFailed(2);
        // 启动实例
        producer.start();

        // 设置消息的topic,tag以及消息体
        Message msg = new Message("topic_test", "tag_test", "消息内容".getBytes(StandardCharsets.UTF_8));

        // 要求发送顺序:i为偶数先发,然后按照由小到大顺序发送
        for (int i = 0; i < 10; i++) {
            // 模拟偶数、奇数分别属于一类消息
            int orderId = i % 2;

            SendResult result = producer.send(msg, new MessageQueueSelector() {
                /**
                 *
                 * @param list 消息队列集合
                 * @param message 消息
                 * @param arg send方法中传入的第三参数,即orderId参数,orderId可以是Object类型
                 * @return
                 */
                @Override
                public MessageQueue select(List<MessageQueue> list, Message message, Object arg) {
                    Integer orderId = (Integer) arg;
                    int index = orderId % list.size();
                    return list.get(index);
                }
            }, orderId);
            System.out.println("发送结果:"+result.toString());
        }
        producer.shutdown();
    }

2.2 顺序消息消费

rocketmq中提供了两种消费处理形式:并发消费(MessageListenerConcurrently)和顺序消费(MessageListenerOrderly

并发消费消费者会创建多个线程同时消费队列消息,而顺序消费流程跟并发消费最大的区别在于,顺序消费对要处理的队列加锁,确保同一队列,同一时间,只允许一个消费线程处理

我们在之前消息发送的章节已经提前体验过顺序消费代码实现了,通过上述对监听器类型的描述,我们也能知道顺序消费的实现,就是实现MessageListenerOrderly监听器

public static void main(String[] args) throws InterruptedException, MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("group_test");

        consumer.setNamesrvAddr("127.0.0.1:9876");

        // 集群消费模式
        consumer.setMessageModel(MessageModel.CLUSTERING);

        // 设置topic
        consumer.subscribe("topic_order", "*");

        // 注册回调函数,处理消息
        consumer.registerMessageListener(new MessageListenerOrderly() {
              @Override
              public ConsumeOrderlyStatus consumeMessage(List<MessageExt> list, ConsumeOrderlyContext consumeOrderlyContext) {
                  byte[] body = list.get(0).getBody();
                  System.out.println("接收消息:"+new String(body, StandardCharsets.UTF_8));
                  return ConsumeOrderlyStatus.SUCCESS;
              }
        });

        // 启动消费者实例
        consumer.start();

        Thread.sleep(10000);
    }

消费到的消息如下所示,可以看到奇偶是分别保持顺序的,即:0,2,4,6,8 和1,3,5,7,9
在这里插入图片描述

3. springboot集成实现顺序消费

针对springboot框架的实现更加简单,因为之前的文章已经描述过了,这里不再累述,大家可以参考之前的文章顺序发送、顺序消费部分。

RocketMQ快速入门:集成springboot实现各类消息发送|异步、同步、顺序、单向、延迟、事务消息(六)附带源码

RocketMQ快速入门:集成spring, springboot实现各类消息消费(七)附带源码

### 回答1: RocketMQ可以通过以下方式来保证消息顺序消费: 1. 消息发送顺序:在发送消息时,可以指定一个key,RocketMQ会根据这个key来保证消息顺序性,即相同key的消息会被发送到同一个队列中,保证消费顺序。 2. 消费顺序消费:在消费者端,可以通过设置消费者组来保证顺序消费。同一个消费者组中的消费者会按照顺序依次消费消息,不同消费者组之间的消费顺序是无法保证的。 3. 单线程消费:在消费者端,可以将消费者线程数设置为1,这样就可以保证消息顺序消费。 需要注意的是,以上三种方式都只能保证单个队列内的消息顺序消费,如果一个topic有多个队列,那么不同队列之间的消息顺序是无法保证的。因此,在设计topic时,需要根据实际情况来确定队列数量,以保证消息顺序性。 ### 回答2: RocketMQ是一个开源的分布式消息队列系统,能够提供高吞吐量、可靠性、可扩展性和顺序消费顺序消费是指消费者按照消息发送的顺序一个一个地消费消息,这样可以保证消息的有序性。 RocketMQ 保证消息顺序消费的主要方式有两种: 1. 消费者组 通过消费者组来保证消息顺序消费。所谓消费者组,是指一组消费者实例的集合,这些消费者实例共同消费同一个主题(topic)的消息RocketMQ会将同一主题下的消息均匀地分配给各个消费者实例来消费,每个消费者实例只负责消费一部分消息。当消费者组中的一个消费者实例宕机或者出现其他异常情况时,RocketMQ会自动将该实例负责的消息分配给其他消费者实例来消费,不会影响消息顺序。 2. 队列选择器 RocketMQ 提供了队列选择器(QueueSelector)接口,可以自定义消息被发送到哪个队列中。通过控制消息将被发送到哪个队列,可以保证消息顺序消费。当生产者向同一个主题发送消息时,可以将相对顺序靠前的消息发送到同一个队列中,而将相对顺序靠后的消息发送到另一个队列中。然后,在消费消费消息时,按照队列顺序一个一个地消费,这样可以保证消息顺序消费。 总之,RocketMQ 能够保证消息顺序消费是因为它采用了消费者组和队列选择器等多种机制,在消费消息时逐个消费,严格按照消息的先后顺序消费消息。这样可以保证消息有序性,更加符合实际的业务需求。 ### 回答3: RocketMQ 是一种可靠的分布式消息中间件,它可以保证消息顺序性,这主要是因为 RocketMQ 支持 FIFO 的顺序过程。RocketMQ顺序消费主要通过以下方法实现: 1.消息分区:RocketMQ消息分区机制可以实现在单个消费者实例上对特定的主题、队列和标记进行顺序消息发送和消费。这个作用跟许多的关系型数据库在实现主键的双重保证,如果我们使用了这个机制,那么我们将总是在同一分区上发送并消费要按顺序排列的消息。 2.消息分组:RocketMQ 支持为不同的消费者实例创建不同的组,每个组只能消费某一个队列的消息。由于同一个分组中只有一个消费者实例能够访问队列,RocketMQ 确保了消息顺序性。 3. 定时器线程池:RocketMQ 是通过定时器来调度消息的,它的定时器线程池中同时只会有一个线程来处理队列中的消息,这个线程只会按顺序处理队列中的消息。这样可以保证消息消费时必须按照先后顺序进行。 4. 内存映射缓存:RocketMQ消息以哈希表的形式存储到了内存映射缓存中,这样避免了对磁盘的频繁操作。因为磁盘 I/O 是非常慢的,这会影响到消费的速度。所以,RocketMQ 采用了内存映射的缓存机制来减少对磁盘的 I/O 操作。 总之,RocketMQ 在实现顺序消息消费时借助于消息分区、消息分组、定时器线程池和内存映射缓存等技术手段,都是为了保证消息的排序和顺序的连续性,使得消费者能够按顺序消费消息。除此之外,一些实际场景下的问题,比如说如何处理P2P的顺序消息、如何解决强依赖的消息等问题,都需要根据实际情况进行相关的处理,以便得到更完善的顺序消息传输机制。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wu@55555

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值