RabbitMQ 可靠性、重复消费、顺序性、消息积压解决方案

17,915 阅读16分钟

前言

上篇文章介绍了 为什么引入消息队列? 引入 MQ 给我们解决了一些问题,但同时又引入了一些复杂的问题,这些问题是大型项目中必须解决的重点,更重要的是,面试也经常问。实际上消息队列可以说是没法百分之百保证可靠性的!RabbitMQ 提供的相关机制也只是在于缩小消息丢失的概率,或者说提供了消息丢失后的我们可以记录日志的功能。 在解决这些问题时有必要明白一点,其实小公司业务量不大,并发量不高的情况下这些问题是几乎不会发生的......即使偶尔出现,开发人员手动修复数据处理就好。所以可结合公司实际业务场景看有没有必要解决这些问题

消息可靠性

以创建订单为例,可能会出现这样的业务场景

image.png

  • MQ 挂了,消息没发出去。创建订单后面几个优惠券、积分的下游系统全都没有执行业务结算怎么办?
  • MQ 是高可用的,消息发出去了,但是优惠券结算业务报错了怎么办?因为这个是异步的,也不好去回滚
  • 消息正常发出去,消费者也接收到了,商户系统、优惠券系统都正常执行完了,积分业务报错了导致积分没结算,那这个订单的数据就不一致了

要解决上述问题,就是要保证消息一定要可靠的被消费,那么我们可以来分析下消息有哪些步骤会出问题。RabbitMQ 发送消息是这样的:

image.png

消息被生产者发到指定的交换机根据路由规则路由到绑定的队列,然后推送给消费者。在这个过程中有可能会发生消息出问题的场景:

  • 生产者消息没到交换机,相当于生产者弄丢消息
  • 交换机没有把消息路由到队列,相当于生产者弄丢消息
  • RabbitMQ 宕机导致队列、队列中的消息丢失,相当于 RabbitMQ 弄丢消息
  • 消费者消费出现异常,业务没执行,相当于消费者弄丢消息

生产者弄丢消息

RabbitMQ 提供了确认和回退机制,有一个异步监听机制,每次发送消息,如果成功/未成功发送到交换机都可以触发一个监听,从交换机路由到队列失败也会有一个监听。只需要开启这两个监听机制即可,以 SpringBoot 整合 RabbitMQ 为例

引入依赖 starter

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>

配置文件

rabbitmq:
    publisher-returns: true
    publisher-confirm-type: correlated #新版本 publisher-confirms: true 已过时

然后编写监听回调

@Configuration
@Slf4j
public class RabbitMQConfig {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void enableConfirmCallback() {
        //confirm 监听,当消息成功发到交换机 ack = true,没有发送到交换机 ack = false
        //correlationData 可在发送时指定消息唯一 id
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if(!ack){
                //记录日志、发送邮件通知、落库定时任务扫描重发
            }
        });
        
        //当消息成功发送到交换机没有路由到队列触发此监听
        rabbitTemplate.setReturnsCallback(returned -> {
            //记录日志、发送邮件通知、落库定时任务扫描重发
        });
    }
}

测试的时候可以在发送消息时故意写错交换机、路由键的名称,然后就会回调到我们刚刚写的监听方法, cause 会给我们展示具体没有发到交换机的原因;returned 对象中包含了消息相关信息。

实际上据我了解一些企业并不会在这两个监听里面去做重发,为什么呢?成本太高了......首先 RabbitMQ 本身丢失的可能性就非常低,其次如果这里需要落库再用定时任务扫描重发还要开发一堆代码,分布式定时任务......再其次定时任务扫描肯定会增加消息延迟,不是很有必要。真实业务场景是记录一下日志就行了,方便问题回溯,顺便发个邮件给相关人员,如果真的极其罕见的是生产者弄丢消息,那么开发往数据库补数据就行了。

2024-07-10 修改 本地消息表

最近也是在复习面试题看到这篇文章,才发现本文还是有不少问题的。所以这里对有问题的地方做出补充或者修改。

上一段写的 RabbitMQ 提供的发送确认机制我认为不是我们和业务绑定的最优解。不管公司使用什么消息队列产品,消息体的业务端持久化(本地消息表)是必要的,区别在于是自己做还是 MQ 产品帮你做。也就是说,发送 MQ 之前我们必须记录这条消息的一些属性:消息体、用户id、发送时间、发送状态等等后面追溯所必要的字段。具体的流程如下:

image.png

  • 初始化消息体

在这个步骤中,我们要把消息发送记录保存到数据库。首先定义消息发送实体

@Data
public class MessageSendRecord {
    /**
     * 主键id
     * */
    private Long id;
    
    /**
     * 消息id uuid
     * */
    private String messageId;
    
    /**
     * 消息发送状态 INIT、SENDING、SUCCESS
     * */
    private String sendStatus;
    
    /**
     * 会员memberId
     * */
    private String memberId;
    
     /**
     * json 消息体
     * */
    private String message;
    //...
}

在业务方法中落库消息发送记录

@Transactional
public void service(){
    MessageSendRecord record = new MessageSendRecord();
    record.setMessageId(UUID.randomUUID().toString());
    record.setMessage(message);
    record.setSendStatus("INIT");
    record.setMemberId("memberId");
    //持久化发送记录
    messageSendRecordMapper.insert(record);
    //发送 Spring 本地事件
    publisher.publishEvent(event);
}

注意上一步我们还没有发消息,所以 MessageSendRecord 的状态是 INIT

要控制流程图中事务提交后调用发送消息的 API,我们可以使用 Spring 事件(关于 Spring 事件不懂的可以查看我以前的文章有专门介绍过),控制事件监听器在业务方法的事务提交后执行。

  • 事务提交后
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void listener(XXEvent event){
    //1、修改 MessageSendRecord 的 status 为 SENDING
    //...

    //2、发送消息API
    //rabbitTemplate.send(..);

    //3、修改 MessageSendRecord 的 status 为 SUCCESS
}

这样我们后面追溯消息就能清楚的知道哪些消息是发送成功的,哪些是没有发送成功的。

这样的方式会影响一定的吞吐量,但是对于我们业务上的追溯帮助来说,绝对是值得的。MQ 的发送记录一定要有迹可循,否则后续业务补偿等工作是很难进行的。

RabbitMQ 弄丢消息

不开启持久化的情况下 RabbitMQ 重启之后所有队列和消息都会消失,所以我们创建队列时设置持久化,发送消息时再设置消息的持久化即可(设置 deliveryMode 为 2 就行了)。一般来说在实际业务中持久化是必须开的。

消费者弄丢消息

所谓消费端弄丢消息就是消费端执行业务代码报错了,那么该做的业务其实没有做。比如创建订单成功了,优惠券结算报错了,默认情况下 RabbitMQ 只要把消息推送到消费者就会认为消息已经被消费,就从队列中删除了,但是优惠券还没有结算,这样就相当于消息变相丢失了。这种情况还是很常见的,毕竟我们开发人员不能保证自己的代码不报错,这种问题一定得解决。 否则用户下了订单,优惠券没有扣减,你这个月的绩效估计是没了......

RabbitMQ 给我们提供了消费者应答 (ack) 机制,默认情况下这个机制是自动应答,只要消息推送到消费者就会自动 ack ,然后 RabbitMQ 删除队列中的消息。启用手动应答之后我们在消费端调用 API 手动 ack 确认之后,RabbitMQ 才会从队列删除这条消息。

首先在配置文件中开启手动 ack

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual #手动应答

然后在消费端代码中手动应答签收消息

@RabbitListener(queues = "queue")
public void listen(String object, Message message, Channel channel) {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();
    log.info("消费成功:{},消息内容:{}", deliveryTag, object);
    try {
        /**
         * 执行业务代码...
         * */
        channel.basicAck(deliveryTag, false);
    } catch (IOException e) {
        log.error("签收失败", e);
        try {
            channel.basicNack(deliveryTag, false, true);
        } catch (IOException exception) {
            log.error("拒签失败", exception);
        }
    }
}

踩坑经验

如果生产环境你用上述方案的代码,一旦发生一次消费报错你就会崩溃。因为 basicNack 方法的第三个参数代表是否重回队列,如果你填 false 那么消息就直接丢弃了,相当于没有保障消息可靠。如果你填 true ,当发生消费报错之后,这个消息会被重回消息队列顶端,继续推送到消费端,继续消费这条消息,通常代码的报错并不会因为重试就能解决,所以这个消息将会出现这种情况:继续被消费,继续报错,重回队列,继续被消费......死循环

所以真实的场景一般是三种选择

  • 当消费失败后将此消息存到 Redis,记录消费次数,如果消费了三次还是失败,就丢弃掉消息,记录日志落库保存
  • 直接填 false ,不重回队列,记录日志、发送邮件等待开发手动处理
  • 不启用手动 ack ,使用 SpringBoot 提供的消息重试

SpringBoot 提供的消息重试

其实很多场景并不是一定要启用消费者应答模式,因为 SpringBoot 给我们提供了一种重试机制,当消费者执行的业务方法报错时会重试执行消费者业务方法。

启用 SpringBoot 提供的重试机制

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true
          max-attempts: 3 #重试次数

消费者代码

@RabbitListener(queues = "queue")
public void listen(String object, Message message, Channel channel) throws IOException {
    try {
        /**
         * 执行业务代码...
         * */
        int i = 1 / 0; //故意报错测试
    } catch (Exception e) {
        log.error("签收失败", e);
        /**
         * 记录日志、发送邮件、保存消息到数据库,落库之前判断如果消息已经落库就不保存
         * */
        throw new RuntimeException("消息消费失败");
    }
}

注意一定要手动 throw 一个异常,因为 SpringBoot 触发重试是根据方法中发生未捕捉的异常来决定的。值得注意的是这个重试是 SpringBoot 提供的,重新执行消费者方法,而不是让 RabbitMQ 重新推送消息。

重试无法解决

值得注意的是很多时候消息消费时候并不是由于网络抖动的原因,而是由于代码 bug。这种情况下,我们重试多少次也都还是不会消费成功,必须得修改代码,发布之后重新消费消息。这其实是一个很常见的问题,同时也是一个比较棘手的问题。

因为我们要重新消费消息就要知道这条消息的消息体、对应的用户等信息,那么我们只能去捞日志。在数据量大或者日志多的情况下这无疑对消息的追溯很不友好。

对此我们可以采取的方案是发消息之前落库,使用 Spring 事件机制,我们先把消息体、用户id等信息落库,待事务提交之后发送消息,发送成功之后再更新消息的发送状态。

消息落库的优劣

消息落库的优势一目了然,对于消息的追溯很友好,如果发现哪个消息发送失败,或者消费失败,直接从消息表获取这条消息的消息体,重新执行一次消费方法即可。

同时劣势也是一目了然,由于消息要落库,就存在数据库磁盘IO,在极大的并发下,接口吞吐量会被降低,数据库也会抵挡不住压力。由于我们使用消息队列就是用来进行削峰、异步、解耦的,这样一来似乎让我们并没有真正的削峰,对于这个问题我们也可以考虑将消息库表水平拆分,这样可以将压力均分下去。

消息可靠性总结

其实认真研究下来你会发现所谓的消息可靠性本身就是无法保证的......所谓的各种可靠性机制只是为了以后消息丢失提供可查询的日志而已,不过通过这些机制耗费一些(巨大)成本的确是能够缩小消息丢失的可能性

消息顺序性

有些业务场景会需要让消息顺序消费,比如使用 canal 订阅 MySQL 的 binary 日志来更新 Redis,通常我们会把 canal 订阅到的数据变化发送到消息队列。

image.png

如果不保证 RabbitMQ 的顺序消费, Redis 中就有可能会出现脏数据。

单个消费者实例

其实队列本身是有顺序的,但是生产环境服务实例一般都是集群,当消费者是多个实例时,队列中的消息会分发到所有实例进行消费(同一个消息只能发给一个消费者实例),这样就不能保证消息顺序的消费,因为你不能确保哪台机器执行消费端业务代码的速度快

image.png

所以对于需要保证顺序消费的业务,我们可以只部署一个消费者实例,然后设置 RabbitMQ 每次只推送一个消息,再开启手动 ack 即可,配置如下

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 #每次只推送一个消息
        acknowledge-mode: manual

这样 RabbitMQ 每次只会从队列推送一个消息过来,处理完成之后我们 ack 回应,再消费下一个,就能确保消息顺序性。

多个消费者实例

RabbitMQ 多消费实例情况下要想保证消息的顺序性,非常困难,细节非常多,一句话:我不会......

消息重复消费

这个也是生产环境业务中经常出现的场景,我的博客使用了 RabbitMQ ,就很奇怪经常日志上会显示消息被消费了两次。

我们解决消息重复消费有两种角度,第一种就是不让消费端执行两次,第二种是让它重复消费了,但是不会对我的业务数据造成影响就行了。

确保消费端只执行一次

一般来说消息重复消费都是在短暂的一瞬间消费多次,我们可以使用 redis 将消费过的消息唯一标识存储起来,然后在消费端业务执行之前判断 redis 中是否已经存在这个标识。举个例子,订单使用优惠券后,要通知优惠券系统,增加使用流水。这里可以用订单号 + 优惠券 id 做唯一标识。业务开始先判断 redis 是否已经存在这个标识,如果已经存在代表处理过了。不存在就放进 redis 设置过期时间,执行业务。

    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("orderNo+couponId");
    //先检查这条消息是不是已经消费过了
    if (!Boolean.TRUE.equals(flag)) {
        return;
    }
    //执行业务...
    //消费过的标识存储到 Redis,10 秒过期
    stringRedisTemplate.opsForValue().set("orderNo+couponId","1", Duration.ofSeconds(10L));

允许消费端执行多次,保证数据不受影响

  • 数据库唯一键约束

如果消费端业务是新增操作,我们可以利用数据库的唯一键约束,比如优惠券流水表的优惠券编号,如果重复消费将会插入两条相同的优惠券编号记录,数据库会给我们报错,可以保证数据库数据不会插入两条。

  • 数据库乐观锁思想

如果消费端业务是更新操作,可以给业务表加一个 version 字段,每次更新把 version 作为条件,更新之后 version + 1。由于 MySQLinnoDB 是行锁,当其中一个请求成功更新之后,另一个请求才能进来,由于版本号 version 已经变成 2,必定更新的 SQL 语句影响行数为 0,不会影响数据库数据。

2024-07-10 修改(消息幂等)

复习时关注到 @walzzz 的评论发现的问题。 我们要明确消息重复消费和消息幂等绝对不是一个概念。上一段我们说的是针对极短时间内多条重复消息投递消费。可以使用数据库乐观锁、Redis、数据库唯一键约束等方式。但是消息的 幂等性概念是:消息消费一次,消费 N 次,消费者的业务数据都是相同的结果。

第 n 天消费消息处理了业务,第 n+3、n+4、n+99999 天,我再次消费这个消息,对于业务数据不该有任何影响。

这个业务是很常见的,我们生产环境中用户还款成功后发送消息,然后执行一系列逻辑,其中一个逻辑是核销账单。本来我们作为消费者就不能确保同一个消息的发送次数,更何况这个消息还是别的业务组发的。

而且很多时候我们自己修复数据,人工补偿的时候也需要重新投递消息。所以作为消费者我们必须确保自己的幂等。

以此为例,当其他业务需要被补偿的时候,消息第二次、第三次发送,我们核销账单的逻辑如何保证幂等?很简单,保证消息的幂等就是让我们当前的消费者业务,已执行过的不再次执行。 乍一看可能是一句废话,但是这是真理。拿核销账单为例,监听到扣款成功的消息,准备核销账单时,我们先查出来用户的还款计划、账单。判断是否已经被核销了。如果已经被核销就不做处理,注意查询的这里为了防止并发安全,数据库查询需要加排他锁 select for update

对于这个核销账单的业务,未核销的账单 repayStatus = 1,已核销的账单 repayStatus = 2。核销账单之前先查询

//伪代码
List<RepaySchedule> list = repayScheduleMapper.selectByOrderNoForUpdate(orderNo);
int status = list.get(0).getRepayStatus()
if(status == 2){
    //账单已被核销,无需处理
}

消息(堆积)积压

所谓消息积压一般是由于消费端消费的速度远小于生产者发消息的速度,导致大量消息在 RabbitMQ 的队列中无法消费。

其实这玩意我也不知道为什么面试这么喜欢问.....既然消费者速度跟不上生产者,那么提高消费者的速度就行了呀!个人认为有以下几种思路

  • 对生产者发消息接口进行适当限流(不太推荐,影响用户体验)
  • 多部署几台消费者实例(推荐)
  • 适当增加 prefetch 的数量,让消费端一次多接受一些消息(推荐,可以和第二种方案一起用)

结语

如果这篇文章对你有帮助,记得点赞加关注。你的支持就是我继续创作的动力!