不止于方案,用代码示例讲解RabbitMQ顺序消费

RabbitMQ系列文章
深入RabbitMQ世界:探索3种队列、4种交换机、7大工作模式及常见概念
不止于纸上谈兵,用代码案例分析如何确保RabbitMQ消息可靠性?
不止于方案,用代码示例讲解RabbitMQ顺序消费
RabbitMQ常见问题持续汇总

文章导图

在这里插入图片描述

前言

关于消息队列(MQ)顺序消费的讨论比比皆是,网上一搜便是铺天盖地的文章。然而,很多文章只是浅谈思路,对于具体的代码实现却往往一笔带过,让许多开发者(包括我)感到一头雾水。所以有时候空谈方案不来点代码理解一下就等于纸上谈兵~

场景分析

在生产中经常会有一些类似报表系统这样的系统,需要做 MySQL 的 binlog 同步。比如订单系统要同步订单表的数据到大数据部门的 MySQL 库中用于报表统计分析,通常的做法是基于 Canal 这样的中间件去监听订单数据库的 binlog,然后把这些 binlog 发送到 MQ 中,再由消费者从 MQ 中获取 binlog 落地到大数据部门的 MySQL 中。

在这个过程中,可能会有对某个订单的增删改操作,比如有三条 binlog 执行顺序是新增、修改、删除;消费者愣是换了顺序给执行成修改、新增、增加,这样肯定是不行的!

场景:发送消息的顺序

正常来说,我们发送消息的时候都是按照既定的业务顺序发送的,这点是无疑的。所以发送有序本来不是啥大事,问题在于,有的时候我们的项目是集群化部署,同一个项目有多个实例,当多个不同的实例分布于不同的服务器上运行的时候,都向 MQ 发消息,此时就无法确保消息的有序了。

场景:消费消息的顺序

场景1:一个queue,多个consumer去消费

在这里插入图片描述

假如只有一个queue,有多个consumer去消费,这样就会造成顺序的错误,consumer从MQ里面读取数据是有序的,但是每个consumer的执行时间是不固定的,无法保证先读到消息的consumer一定先完成操作,这样就会出现消息并没有按照顺序执行,造成数据顺序错误。

场景2: 一个queue对应一个consumer,consumer多线程消费

在这里插入图片描述

  • 假如只有一个queue,也只有一个consumer消费者,但是消费的时候开启了MQ本身的多线程并发消费,如下所示:
@RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME,concurrency = "10")
public void handleMsg(String msg) {
    logger.info("msg:{}", msg);
}

concurrency = "10"这个相当于建立了 10 个 channel 去同时消费消息,对于这种情况,也是没法保证消费的有序的,因为本地代码执行的快慢、是否抛异常等等,都有可能会影响到消息的顺序。

  • 或者也有可能自己开启了线程池去并发处理:
//使用阿里巴巴推荐的创建线程池的方式
//通过ThreadPoolExecutor构造函数自定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
        CORE_POOL_SIZE,
        MAX_POOL_SIZE,
        KEEP_ALIVE_TIME,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(QUEUE_CAPACITY),
        new ThreadPoolExecutor.CallerRunsPolicy());


@RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME)
public void handleMsg(String msg) {
    logger.info("msg:{}", msg);
	executor.execute(()->{
        //处理业务逻辑
    });

}

结合MQ如何解决?

这里我们讲解一下结合MQ的一些特性来实现,下文还会讲到依靠代码来实现!

发送消息的顺序

这里发送消息顺序代码会在后面内容更详细介绍

刚刚上面已经提到了发送消息也有可能导致错乱,那么对于这种情况,我们可以考虑使用 Redis 分布式锁来实现,发送消息之前,先去 Redis 上获取到锁,能拿到锁,然后再去发送消息,避免并发发送。

参考代码如下:

 /**
     * 发送顺序消息,可确保(busId+exchange+routingKey)相同的情况下,消息的顺序性
     * 这里我们使用的是rabbitmq,rabbitmq中 exchange+routingKey 可以确保消息被路由到相同的队列
     * <p>
     * 这里为什么要加入busId?
     * 以订单消息举例,所有订单消息都会进入同样的队列,订单有3种类型消息(创建、支付、发货)
     * 同一个订单的这3种类型的消息是要有序的,但是不同订单之间,他们不需要有序,所以这里加了个busId,此时就可以将订单id作为busId
     *
     * @param busId      业务id
     * @param exchange
     * @param routingKey
     * @param msgBody    消息体
     */
@Override
public void sendSequentialMsg(String busId, String exchange, String routingKey, Msg<?> msg) {
	Objects.nonNull(busId);

	String groupId = String.format("busId:%s,exchange:%s,routingKey:%s", busId, StringUtils.defaultString(exchange), StringUtils.defaultString(routingKey));

	//加分布式锁,保证同一个groupId时,顺序消息发送串行执行
	this.distributeLock.accept("sendSequentialMsg:" + groupId, lockKey -> {
		this.transactionTemplate.executeWithoutResult(action -> {
			//设置顺序消息groupId
			msg.setSequentialMsgGroupId(groupId);
			//设置顺序消息的编号
			msg.setSequentialMsgNumbering(this.sequentialMsgNumberGeneratorService.get(groupId));
			this.send(exchange, routingKey, msg);
		});
	});
}

队列中消息的顺序

RabbitMQ中,消息最终会保存在队列中,在同一个队列中,消息是顺序的,先进先出原则,这个由Rabbitmq保证,通常也不需要开发关心。

提示:不同队列中的消息顺序,是没有保证的,例如:进地铁站的时候,排了三个队伍,不同队伍之间的,不能确保谁先进站。

消费消息的顺序

场景1解决:依靠MQ本身-单活模式

刚刚我们已经分析了,一个queue,多个consumer去消费会导致消息错乱,究其原因就是有多个消费者,那么从源头解决,就是保证只有一个消费者去消费消息即可!

针对这种情况,我们采用MQ本身的特性,可以设置队列的“单活模式”。

x-single-active-consumer(单活模式): 单一活跃消费者(Single Active Consumer)表示队列中可以注册多个消费者,但是只允许一个消费者消费消息,只有在此消费者出现异常时,才会自动转移到另一个消费者进行消费。单一活跃消费者适用于需要保证消息消费顺序性,同时提供高可靠能力的场景。

仅RabbitMQ 3.8.35版本以后支持单一活跃消费者特性。

注意:如果一个队列已经创建为非x-single-active-consumer,而你想更改其为x-single-active-consumer,代码是会报错的,错误信息是:声明的队列的和server上的队列不一致。把原来队列删除了即可。

代码实现

创建一个配置类来定义队列、交换机和绑定,并启用 x-single-active-consumer 参数:

@Configuration
public class RabbitConfig {

    @Bean
    public Queue myQueue() {
        Map<String, Object> args = new HashMap<>();
		//最主要的区别就是声明了这个队列是单活模式
        args.put("x-single-active-consumer", true);
        return new Queue("myQueue", true, false, false, args);
    }

    @Bean
    public DirectExchange myExchange() {
        return new DirectExchange("myExchange");
    }

    @Bean
    public Binding binding(Queue myQueue, DirectExchange myExchange) {
        return BindingBuilder.bind(myQueue).to(myExchange).with("routingKey");
    }
}

这里假设创建3个监听这个单活模式队列的消费者,实际生产环境是多实例的,本身也就会有多个,这里用一个类同时创建3个消费者进行模拟

@Service
public class MessageConsumer {

    //消费者1
    @RabbitListener(queues = "myQueue")
    public void receiveMessage(Message message) {
        System.out.println("Received message1: " + message);
    }
	
    //消费者2
	@RabbitListener(queues = "myQueue")
    public void receiveMessage(Message message) {
        System.out.println("Received message2: " + message);
    }
	//消费者3
	@RabbitListener(queues = "myQueue")
    public void receiveMessage(Message message) {
        System.out.println("Received message3: " + message);
    }
}

生产者尝试发送10条消息

@Service
public class MessageProducer {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendMessage(String message) {
		for(int i=0; i<10; i++){
            rabbitTemplate.convertAndSend("myExchange", "routingKey", message);
        }
    }
}

最终我们发现始终只有一个在消费者1在消费消息,这样就可以满足我们的要求了,一个队列虽然多个消费者,但是始终只有一个消费者在消费消息!

Received message1:...
Received message1:...
Received message1:...
Received message1:...
Received message1:...
Received message1:...
Received message1:...
Received message1:...
Received message1:...
Received message1:...

场景1优化:多个Queue,每个Queue只对应一个Consumer+单活模式

如果业务又要顺序消费,又要增加并发,通常思路就是开启多个队列,业务根据规则将消息分发到不同的队列,通过增加队列的数量来提高并发度,例如:电商订单场景,只需要保证同一个用户的订单消息的顺序性就行,不同用户之间没有关系,所以只要让同一个用户的订单消息进入同一个队列就行,其他用户的订单消息,可以进入不同的队列。

所以为了效率,我们可以设置多个队列都来处理顺序执行的消息。但是,我们需要保证每组顺序消费的消息发到同一个队列中,给这些消息设置一个统一的全局id即可,这样我们就实现了同一种类型的消息都进入了同一个队列,同时最后再结合我们刚刚说的单活模式即可!

代码实现
定义多个队列

根据业务需要,定义多个队列和与之相关的交换机,比如3个队列,那么他们并发度也相比原来的一个队列提升了2倍!

注意每个队列定义的时候也要声明为单活模式!

@Configuration
public class RabbitConfig {

    // 定义交换机
    @Bean
    public DirectExchange directExchange() {
        return new DirectExchange("order-exchange");
    }

    // 定义多个队列,假设有三个队列
    @Bean
    public Queue orderQueue1() {
		Map<String, Object> args = new HashMap<>();
		//最主要的区别就是声明了这个队列是单活模式
        args.put("x-single-active-consumer", true);
        return new Queue("orderQueue1", true, false, false, args);
    }

    @Bean
    public Queue orderQueue2() {
        Map<String, Object> args = new HashMap<>();
		//最主要的区别就是声明了这个队列是单活模式
        args.put("x-single-active-consumer", true);
        return new Queue("orderQueue2", true, false, false, args);
    }

    @Bean
    public Queue orderQueue3() {
        Map<String, Object> args = new HashMap<>();
		//最主要的区别就是声明了这个队列是单活模式
        args.put("x-single-active-consumer", true);
        return new Queue("orderQueue3", true, false, false, args);
    }

    // 将队列绑定到交换机
    @Bean
    public Binding binding1() {
        return BindingBuilder.bind(orderQueue1()).to(directExchange()).with("orderQueue1");
    }

    @Bean
    public Binding binding2() {
        return BindingBuilder.bind(orderQueue2()).to(directExchange()).with("orderQueue2");
    }

    @Bean
    public Binding binding3() {
        return BindingBuilder.bind(orderQueue3()).to(directExchange()).with("orderQueue3");
    }
}
发送消息:同类型消息路由到同一个队列

在生产者端,根据用户ID选择相应的队列,将消息发送到对应的队列中。你可以使用某种哈希算法或其他规则来决定用户的消息该发送到哪个队列。

@Service
public class OrderService {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 发送消息,根据用户ID选择队列
    public void sendOrderMessage(String userId, String message) {
        // 根据用户ID选择队列
        String routingKey = getRoutingKeyForUser(userId);
        rabbitTemplate.convertAndSend("order-exchange", routingKey, message);
    }

    private String getRoutingKeyForUser(String userId) {
        // 根据用户ID选择队列,例如简单的取模
        int queueNumber = Math.abs(userId.hashCode()) % 3 + 1;
        return "orderQueue" + queueNumber;
    }
}
消费者处理消息

在消费者端,定义多个消费者分别监听不同的队列,处理消息,由于我们声明了队列是单活模式,所以就算是集群部署,同个队列有多个消费者也能保证始终只有一个消费者在处理!

@Service
public class OrderConsumer {

    @RabbitListener(queues = "orderQueue1")
    public void receiveMessageFromQueue1(String message) {
        System.out.println("Received from Queue1: " + message);
        // 处理消息
    }

    @RabbitListener(queues = "orderQueue2")
    public void receiveMessageFromQueue2(String message) {
        System.out.println("Received from Queue2: " + message);
        // 处理消息
    }

    @RabbitListener(queues = "orderQueue3")
    public void receiveMessageFromQueue3(String message) {
        System.out.println("Received from Queue3: " + message);
        // 处理消息
    }
}

综上:我们只要在生产者端将与同一用户相关的消息路由到同一个队列,同时采用MQ的单活模式使得每个队列只由一个消费者消费,这样就能保证这些消息是按顺序处理的,也就是保证了与同一个用户相关的订单消息的有序性。

场景2解决: 不启用多线程消费

@RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME,concurrency = "10")
public void handleMsg(String msg) {
    logger.info("msg:{}", msg);
    //executor.execute(()->{
        //处理业务逻辑
    //});
}

这种场景导致消息错乱的原因很简单,就是多线程,那么我们不用自定义的线程池或者手动开启多线程处理即可,同时也不用MQ本身的并发消费,也就是保证concurrency = "1"始终只有一个线程在消费!

concurrency配置,这个配置是设置listener初始化时的线程数,即消费者的数量,即消费者同时消费消息的数量。

如果没有显性设置concurrency时,默认的线程数就是1,也就是不用额外设置!

具体的我们可以在源码org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer看到

在这里插入图片描述

解决方案总结

根据上面提到的解决方案,我们可以总结一下RabbitMQ 如果要保证消息的顺序性,同时要提高并发度,要满足一下:

  • 生产者顺序发送+多队列提高并发度同时保证同类型消息路由到同一个队列: 首先生产者需要按序发送,确认上一条消息发送成功后再发送下一条,然后需要使得同一类消息仅发送至一个队列中(可以去创建多个队列提高并发度,然后利用用户ID 或 订单 ID 对消息进行分区,保证同类型消息路由到同一个队列)
  • 单活模式: 其次要保证只有一个消费者消费这个队列,这个利用单活模式即可
  • 单线程消费: 最后保证消费者必须要消费完一条消息后,再消费另一条消息,即不能是多线程并发消费,这样才能保证消息的顺序性。

不依赖MQ如何实现?

刚刚上面我们提到的消费者顺序消费主要是结合了MQ的一些特性来实现,现在我们来讲讲只依靠代码如何来实现!

发送消息的顺序

我们首先定义一个全局的计数器,实现发送消息编号的递增管理。

/**
 * 发送顺序消息,可确保(busId+exchange+routingKey)相同的情况下,消息的顺序性
 * 这里我们使用的是rabbitmq,rabbitmq中 exchange+routingKey 可以确保消息被路由到相同的队列
 * <p>
 * 这里为什么要加入busId?
 * 以订单消息举例,所有订单消息都会进入同样的队列,订单有3种类型消息(创建、支付、发货)
 * 同一个订单的这3种类型的消息是要有序的,但是不同订单之间,他们不需要有序,所以这里加了个busId,此时就可以将订单id作为busId
 *
 * @param busId      业务id
 * @param exchange
 * @param routingKey
 * @param msgBody    消息体
 */
@Override
public void sendSequentialMsg(String busId, String exchange, String routingKey, Msg<?> msg) {
	Objects.nonNull(busId);

	String groupId = String.format("busId:%s,exchange:%s,routingKey:%s", busId, StringUtils.defaultString(exchange), StringUtils.defaultString(routingKey));

	//加分布式锁,保证同一个groupId时,顺序消息发送串行执行
	this.distributeLock.accept("sendSequentialMsg:" + groupId, lockKey -> {
		this.transactionTemplate.executeWithoutResult(action -> {
			//设置顺序消息groupId
			msg.setSequentialMsgGroupId(groupId);
			//设置顺序消息的编号
			msg.setSequentialMsgNumbering(this.sequentialMsgNumberGeneratorService.get(groupId));
			this.send(exchange, routingKey, msg);
		});
	});
}

我们看一下this.sequentialMsgNumberGeneratorService.get(groupId)代码如下:

这里调用 getAndInit(groupId) 方法: 该方法尝试根据 groupId 查找当前是第几个序号了!

  • 如果记录不存在,则初始化一个新的记录,将 numbering 设置为 0,并保存到数据库;
  • 存在了就直接返回当前numbering

然后上面从获取的记录中读取当前的 numbering 值,增加 1。更新 numbering 字段并保存回数据库。

@Override
@Transactional(rollbackFor = Exception.class)
public long get(String groupId) {
    Objects.nonNull(groupId);
    SequentialMsgNumberGeneratorPO po = this.getAndInit(groupId);
    long value = po.getNumbering() + 1;
    po.setNumbering(value);
    boolean update = this.updateById(po);
    if (!update) {
        throw BusinessExceptionUtils.businessException("系统繁忙,请稍后重试");
    }
    return value;
}

private SequentialMsgNumberGeneratorPO getAndInit(String groupId) {
    LambdaQueryWrapper<SequentialMsgNumberGeneratorPO> qw = Wrappers.lambdaQuery(SequentialMsgNumberGeneratorPO.class).eq(SequentialMsgNumberGeneratorPO::getGroupId, groupId);
    SequentialMsgNumberGeneratorPO po = this.findOne(qw);
    if (po == null) {
        po = new SequentialMsgNumberGeneratorPO();
        po.setId(IdUtil.fastSimpleUUID());
        po.setGroupId(groupId);
        po.setNumbering(0L);
        po.setVersion(0L);
        this.save(po);
    }
    return po;
}

消费消息的顺序

consume消费消息方法:

1、消息过来后,会将其放到db中搞的一个队列中进行排队、

2、从db中队列表中拉取消息进行消费(循环拉取最小的一条记录,看看是不是要消费的记录,如果是,则进行消费)

	@Override
    protected void consume(Message message, M msg, MsgConsumePO msgConsumerPO) {
        //1、消息过来后,会将其放到db中搞的一个队列中进行排队
        this.pushQueue(message, msg);

        //2、从db中队列表中拉取消息进行消费(循环拉取最小的一条记录,看看是不是要消费的记录,如果是,则进行消费)
        this.pullMsgFromQueueConsume(message, msg);
    }

核心处理:pullMsgFromQueueConsume方法:

/**
     * 从db中消息排队表拉取消息进行消费
     *
     * @param message
     * @param msg
     */
    protected void pullMsgFromQueueConsume(Message message, M msg) {
        String groupId = msg.getSequentialMsgGroupId();
        String queueName = this.getQueueName(message);
        String lockKey = String.format("consumeOrderMessage:%s:%s", groupId, queueName);

        //加分布式锁
        boolean lockResult = this.distributeLock.accept(lockKey, lk -> {
            //从db中队列表中拉取消息进行消费(循环拉取最小的一条记录,和当前消费位置对比下?看看是不是要消费的记录,如果是,则进行消费)
            while (true) {
                //从队列中拿到第一条消息进行消费
                SequentialMsgQueuePO firstMsg = this.sequentialMsgQueueService.getFirst(groupId, queueName);
                //队列中没有消息,退出循环
                if (firstMsg == null) {
                    break;
                }
                //获取队列当前消费的位置
                SequentialMsgConsumePositionPO sequentialMsgConsumePositionPO = this.sequentialMsgConsumeInfoService.getAndCreate(groupId, queueName);

                //轮到自己消费了?
                if (firstMsg.getNumbering() == sequentialMsgConsumePositionPO.getConsumeNumbering() + 1) {
                    //消费,由子类实现
                    this.sequentialMsgConsume(this.getMsg(firstMsg.getMsgJson()));
                    this.transactionTemplate.executeWithoutResult(action -> {
                        //更新消费位置
                        sequentialMsgConsumePositionPO.setConsumeNumbering(firstMsg.getNumbering());
                        boolean update = this.sequentialMsgConsumeInfoService.updateById(sequentialMsgConsumePositionPO);
                        if (!update) {
                            throw BusinessExceptionUtils.businessException("系统繁忙,请稍后重试");
                        }
                        //从队列中移除消息
                        this.sequentialMsgQueueService.delete(firstMsg.getId());
                    });
                } else if (msg.getSequentialMsgNumbering() < sequentialMsgConsumePositionPO.getConsumeNumbering()) {
                    //编号小于当前位置,说明已经消费过了,从队列中移除消息
                    this.sequentialMsgQueueService.delete(firstMsg.getId());
                } else {
                    //还未轮到自己,退出循环
                    break;
                }
            }
        });
        //加锁失败,抛个异常,触发重试
        if (!lockResult) {
            throw BusinessExceptionUtils.businessException("顺序消息消费加锁失败");
        }
    }

代码详细步骤

  1. 消息入队列
    • 当消息到达时,首先调用 pushQueue() 方法将消息放入数据库中的队列。
  2. 消息消费
    • 调用 pullMsgFromQueueConsume() 方法,从数据库的队列中拉取消息进行消费。
    • 通过加分布式锁,确保同一时间只有一个消费实例可以消费同一组消息。
    • 在锁内,循环执行以下步骤:
      • 从队列中获取第一条消息。
      • 如果队列为空,退出循环。
      • 获取当前的消费位置。
      • 判断是否轮到当前消息消费:
        • 如果是,则消费消息,更新消费位置,并从队列中删除已消费的消息。
        • 如果消息编号小于当前消费位置编号,说明消息已被消费过,直接从队列中删除。
        • 如果尚未轮到当前消息消费,退出循环。
  3. 异常处理
    • 如果加锁失败,抛出异常,触发重试机制。

在这里插入图片描述

参考文章:
https://www.tizi365.com/topic/35.html
https://mp.weixin.qq.com/s/GYz6Sy-5mvVE1LEmajoXoQ
https://www.jianshu.com/p/02fdcb9e8784
https://xie.infoq.cn/article/c84491a814f99c7b9965732b1
https://mp.weixin.qq.com/s/XotYb00zXI7AsKe_ysDZUw

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Apple_Web

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

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

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

打赏作者

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

抵扣说明:

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

余额充值