SpringCloud —— MQ的可靠性保障和延迟消息

一、前言

前面一节主要是讲述的如何使用RabbitMQ,涵盖了大多数情况下MQ的使用,这里针对MQ剩下较少数的使用场景,对可能出现的部分问题给出对应的解决办法。

第一是调试问题,MQ的可靠性主要是通过生产者确认机制和消费者确认机制来保障,当我们使用确认机制,就可以看出来是哪个部分发生问题,从而排查问题。

第二是少数使用场景出现的问题,比如对于状态不一致的问题,以及订单超时取消问题,这里也会通过延迟消息来进行解决。

二、MQ的可靠性保障

1.生产者可靠性保障

对于一个消息,它的传递步骤应该是:

生产者 -> 交换机 -> 队列 -> 消费者

(1)生产者重试机制

对于生产者来说,如果MQ出现了故障导致消息传递失败,为了保证消息的传递,应该继续尝试重连,直到MQ恢复功能。

然而,MQ的故障不一定能及时解决,而尝试重连会占用线程 ,如果长期尝试就会导致长时间占用服务器线程,这样就会导致其他功能受影响,所以尝试应当是有限度的,也就是应该设置最大重试次数

对于重试机制,我们直接可以在配置文件中配置,不需要其他额外代码,所以还是很容易实现的:

bash 复制代码
spring:
  rabbitmq:
    host: 192.168.111.111 # 你的虚拟机IP
    port: 5672 # 端口
    virtual-host: /hmall # 虚拟主机
    username: hmall # 用户名
    password: 12345 # 密码
    connection-timeout: 1s
    template:
      retry:
        enabled: true
        multiplier: 2
 

(2)生产者确认机制

对于生产者确认机制,简单一点来说,就是生产者会将消息传递给交换机,如果交换机确认收到消息,就会返回ack(acknowledge),告诉生产者消息确实传到了交换机,这时就可以排除生产者到交换机步骤的问题了(同理,失败将返回nack,自然就找到问题所在了)。

首先我们要在配置中开启生产者确认机制:

bash 复制代码
spring:
  rabbitmq:
    host: 192.168.111.111 # 你的虚拟机IP
    port: 5672 # 端口
    virtual-host: /hmall # 虚拟主机
    username: hmall # 用户名
    password: 12345 # 密码
    connection-timeout: 1s
    template:
      retry:
        enabled: true
        multiplier: 2
    publisher-confirm-type: correlated #开启发送者确认
    publisher-returns: true

然后我们可以通过配置一个配置类来对确认机制初始化,这个类将在生产者发送消息后自动初始化,输出对应参数的日志:

java 复制代码
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MqConfig {

    private final RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init() {
        rabbitTemplate.setReturnsCallback(returnedMessage -> {
            log.error("监听到了消息return callback:{}",returnedMessage.getMessage());
            log.debug("交换机:{}", returnedMessage.getExchange());
            log.debug("routingKey:{}", returnedMessage.getRoutingKey());
            log.debug("message:{}", returnedMessage.getMessage());
            log.debug("replyCode:{}", returnedMessage.getReplyCode());
            log.debug("replyText:{}", returnedMessage.getReplyText());
        });
    }
}

然后我们创建一个测试方法,来尝试接收返回的消息,这里使用了一个匿名内部类来对不同的返回结果进行处理。

java 复制代码
//发送者确认
    @Test
    public void testConfirmCallback() throws InterruptedException {
        //0.创建correlationData
        CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
        cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                log.error("SpringAMQP 处理确认结果异常",ex);
            }

            @Override
            public void onSuccess(CorrelationData.Confirm result) {
                //1.判断是否成功
                if(result.isAck()){
                    log.debug("收到ConfirmCallback ack,消息发送成功");
                }else {
                    log.debug("收到ConfirmCallback nack,消息发送失败!:{}",result.getReason());
                }
            }
        });

        //1.交换机名
        String exchange = "hmall.direct";

        //2.消息
        String message = "蓝色";

        //3.发送消息
        rabbitTemplate.convertAndSend(exchange, "blue", message, cd);
    }

如果正常发送:

如果填错了路由:

2.数据持久化

对于MQ来说,如果希望保证可靠性,那就不能把消息存到内存中,而是应该存到硬盘(持久化),如果存在内存中,一旦MQ故障,那么内存中的消息将全部消失,而如果存在硬盘中,MQ即使故障,消息也将全部保留,当MQ故障修好,消息将继续传递。

所以很容易可以看出,持久化对于MQ的可靠性的影响相当大,所以事实上,存到内存的这种消息存储方式已经被RabbitMQ弃用了,新版的RabbitMQ都是直接存储到硬盘中去的。

对于老版RabbitMQ来说,要想直接存入硬盘,需要借助LazyQueue,但是刚刚也说了,现在新版默认都是LazyQueue了,所以也就不用再特殊设置了。

3.消费者可靠性保障

(1)消费者确认机制

还是先看看流程图:

生产者 -> 交换机 -> 队列 -> 消费者

队列是对消费者负责的,所以消费者的确认回执应该返回给队列,有三种回执:

  • ack:成功处理消息,RabbitMQ从队列中删除该消息

  • nack:消息处理失败,RabbitMQ需要再次投递消息

  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息

不像生产者那样复杂,消费者的确认机制可以直接配置,Spring底层将使用AOP的环绕增强自动执行确认机制。

java 复制代码
spring:
  rabbitmq:
    host: 192.168.111.111 # 你的虚拟机IP
    port: 5672 # 端口
    virtual-host: /hmall # 虚拟主机
    username: hmall # 用户名
    password: 12345 # 密码
    listener:
      simple:
        prefetch: 1
        acknowledge-mode: auto
        retry:
          enabled: true

(2)消费者失败重试机制

首先要明确消费者失败是指什么,其实这里就是指消费者处理消息的时候失败,也就是消费者抛异常了,当配置了确认机制时,这个消息将不会直接消失,而是会重新入队(requeue),然后重新发给消费者,直到不抛异常。

但是刚刚也说了,这个过程是会占用线程的,所以,重试都是需要设置一个上限的。

如此配置即可:

java 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          initial-interval: 1000ms # 初识的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false

(3)消费者处理失败策略

刚刚说了,重试是有限制的,那问题来了,当重试次数用尽了,这个消息该何去何从呢?

这个时候就要引入一个策略了,首先我们知道,如果直接把这个消息扔掉是绝对不妥的,因为这样的话对于我们开发人员来说是不易找出错误的,我们需要错误信息才能排除问题。所以我们选择将这个错误放到另一个消息队列中,这样就只需要去另一个消息队列中查看就能看到错误信息了。

我们声明这个错误消息队列:

java 复制代码
@Configuration
@RequiredArgsConstructor
public class ErrorMessageConfiguration {

    private final RabbitTemplate rabbitTemplate;

    @Bean
    public DirectExchange directErrorExchange(){
        return new DirectExchange("error.direct");
    }

    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue");
    }

    @Bean
    public Binding errorQueueBinding(){
        return BindingBuilder.bind(errorQueue()).to(directErrorExchange()).with("error");
    }

    //消费者失败重试策略 重试三次后失败了就重发,重发给指定的交换机(将错误信息一并发出),然后人工处理错误。
    @Bean
    public MessageRecoverer messageRecoverer(){
        return new RepublishMessageRecoverer(rabbitTemplate,"error.direct","error");
    }
}

这里我们做一个测试,在处理消息时故意抛出异常:

java 复制代码
    //消费者确认机制
    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueue2(String message) {
        log.info("监听到simple.queue的消息:{}", message);
        throw new RuntimeException("故意抛异常");
    }

然后测试:

java 复制代码
@Test
    public void testSimpleQueue() {
        //1.队列名
        String queueName = "simple.queue";
        //2.消息
        String message = "hello spring amqp!!!";
        //3.发送消息
        rabbitTemplate.convertAndSend(queueName, message);

    }

发现在尝试了三次后,消息被转发到error.direct交换机去了:

4.业务的幂等性

业务的幂等性,简单一点来说就是业务无论执行多少次都不会影响业务状态的一致性。

业务状态 是指一个业务对象在其生命周期中所处的特定阶段或条件,它定义了该对象在特定时间点可以做什么不可以做什么。业务状态是领域驱动设计(DDD)中的核心概念,反映了真实世界的业务规则和流程。

比如查询操作,无论查多少次,业务本身都是不会改变的,当然具有幂等性;

而根据id的删除操作,无论操作多少次,删除的都是那个id,第一次删除后,后续无论再执行多少次,业务都是不会改变的了,所以也是具有幂等性的

新增一般来讲也是具有幂等性的,因为账户一般是唯一的,所以不会允许重复新建,自然也不会影响业务的状态。

但是更新操作就不具有了,比如重复退款,订单取消,这些都有可能引起业务状态的不一致。

举例:

  1. 假如用户刚刚支付完成,并且投递消息到交易服务,交易服务更改订单为已支付状态。

  2. 由于某种原因,例如网络故障导致生产者没有得到确认,隔了一段时间后重新投递给交易服务。

  3. 但是,在新投递的消息被消费之前,用户选择了退款,将订单状态改为了已退款状态。

  4. 退款完成后,新投递的消息才被消费,那么订单状态会被再次改为已支付。业务异常。

为了解决这个问题,我们将使用两种方法来解决:

(1)唯一ID

对每个消息都添加上唯一的ID,当消息传给消费者后,我们就将这个ID存入数据库,然后在每次传递消息的时候去判断ID是否重复。

刚刚造成这个问题的原因是:

发生第一次投递 -> 生产者的网络故障(期间消费者已经接收到第一次的消息而改变状态了) -> 发生第二次投递

那解决的办法无非就是让第二次投递失效呗,这里如果使用了唯一ID,那就能检测到第二次投递是重复的,从而直接失效。

这里通过消息转换器来为消息添加ID:

java 复制代码
@Bean
public MessageConverter messageConverter(){
    // 1.定义消息转换器
    Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
    jjmc.setCreateMessageIds(true);
    return jjmc;
}

但是这个办法会用到数据库,消息增加后就会使数据库不断增加,每次对比ID的时间就会增加,从而会增大服务器负荷,所以在高并发时不太推荐。

(2)业务判断

第二个方式是直接在业务处理的时候判断:比如这里,在接收到消息后,订单的状态必须是未支付 才会更新为**已支付,**如果在网络故障期间状态变成了已退款或者其他的状态,那就直接忽略这次操作,从而避免了重复操作。

java 复制代码
@RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "trade.pay.success.queue", durable = "true"),
            exchange = @Exchange(name = "pay.direct"),
            key = "pay.success"
    ))
    public void listenPaySuccess(Long orderId) {
        //1.查询订单
        Order order = orderService.getById(orderId);
        //2.判断订单状态,是否为未支付
        //(业务非幂等,所以需要判断,防止用户在消息还未传来时退款,当消息传来,如果不做判断,会导致退款的订单的状态被更新为已支付)
        if (order == null || order.getStatus() != 1) {
            //不做处理
            return;
        }
        //3.标记订单状态为已支付
        orderService.markOrderPaySuccess(orderId);
    }

三、延迟消息

为啥要使用延迟消息,给出一个场景,当商品只剩最后一个了,一个用户成功下单,于是商品库存减为了0,但是过了15分钟了,这个用户还没有完成支付,这个时候就应该将这个商品让给其他人来抢了,所以这个时候我们就直接将这个订单取消掉,然后恢复库存即可。

现在问题来了,怎么检测用户15分钟还没有下单?

先前我们是使用的SpringTask定时器,每隔一分钟就检查一下时间,但是既然我们都使用了消息队列了,有没有更好的方法呢?答案是有的,我们只需要在第十五分钟检查订单的支付状态就行了,如果依旧是未支付状态,就取消订单,如果已支付,就不做处理了。

延迟消息有两种实现方法:

1.死信交换机

死信就是dead letter,这个交换机是专门用于处理过期消息的,当一个定时消息过期,就会变成死信,我们就可以将这个死信投递给死信交换机,从而在死信队列中获取这个消息。

首先先创建死信交换机和死信队列:

java 复制代码
//死信交换机
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue("dlx.queue"),
            exchange = @Exchange(name = "dlx.direct", type = ExchangeTypes.DIRECT),
            key = {"hi"}
    ))
    public void listenDlxQueue(String message) {
        log.info("消费者监听到dlx.queue的消息:{}", message);
    }

然后我们创建另一个没有消费者接收的交换机和队列,并且绑定死信交换机

java 复制代码
//死信交换机的模拟交换机及队列
@Configuration
public class NormalConfiguration {

    @Bean
    public DirectExchange normalExchange() {
        return ExchangeBuilder.directExchange("normal.direct").build();
    }

    @Bean
    public Queue normalQueue() {
        return QueueBuilder.durable("normal.queue").deadLetterExchange("dlx.direct").build();
    }

    @Bean
    public Binding normalExchangeBinding(Queue normalQueue, DirectExchange normalExchange) {
        return BindingBuilder.bind(normalQueue).to(normalExchange).with("hi");
    }

}

测试方法中投递一个有时限的消息,由于Normal没有消费者,所以这个消息将一直留在normal.queque中,直到消息过期,就会被投递到死信交换机中。

java 复制代码
 @Test
    public void testSendDelayMessage(){
        rabbitTemplate.convertAndSend("normal.direct", "hi", "hello", message -> {
            //设置消息的过期时间
            message.getMessageProperties().setExpiration("10000");
            return message;
        });
    }

也就是说,如果我们想通过死信交换机来实现延迟消息,那么我们就要创建两个交换机。

对于刚刚的业务,就相当于顾客点击下单,这时就将一个订单号消息定时。然后将判断逻辑和死信队列连接,一旦消息过期,就会传给死信交换机,然后传给死信队列,最终判断逻辑接收到消息,然后通过查询对应的订单号来得到订单状态,从而进行判断。

2.延迟消息插件

我们先安装插件,将插件挂载到指定文件夹中:

然后输入命令让RabbitMQ安装插件:

然后我们就可以直接创建一个延迟消息交换机 了,这个交换机的功能就是让消息在这个交换机中延迟**(不是在队列中延迟)** ,过期后将消息直接传给消费者。相较于死信交换机,使用插件可以少创建一个交换机,所以更推荐使用延迟消息插件!

java 复制代码
//延迟消息
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue("delay.queue"),
            exchange = @Exchange(name = "delay.direct", type = ExchangeTypes.DIRECT, delayed = "true"),
            key = {"hi"}
    ))
    public void listenDelayQueue(String message) {
        log.info("delay.queue的消息:{}", message);
    }
java 复制代码
//延迟插件
    @Test
    public void testSendDelayMessageByPlugin(){
        rabbitTemplate.convertAndSend("delay.direct", "hi", "hello", message -> {
            //设置消息的过期时间
            message.getMessageProperties().setDelay(10000);
            return message;
        });
    }

四、项目中实现

实现对超时订单的取消。

1.用一个常量类来统一管理延迟交换机和队列的名字,以及路由名。

java 复制代码
public interface MQConstants {

    String DELAY_EXCHANGE_NAME = "reade.delay.direct";
    String DELAY_ORDER_QUEUE_NAME = "reade.delay.order.queue";
    String DELAY_ORDER_KEY = "delay.order.query";

}

2.创建延迟交换机,并对订单的支付状态做出判断。

java 复制代码
@Component
@RequiredArgsConstructor
public class OrderDelayMessageListener {

    private final IOrderService orderService;
    private final PayClient payClient;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(MQConstants.DELAY_ORDER_QUEUE_NAME),
            exchange = @Exchange(name = MQConstants.DELAY_EXCHANGE_NAME, delayed = "true"),
            key = MQConstants.DELAY_ORDER_KEY
    ))
    public void listenOrderDelayMessage(Long orderId) {
        //1.查询本地订单状态
        Order order = orderService.getById(orderId);
        //2.检测订单状态,判断是否已支付
        if (order == null || order.getStatus() != 1) {
            //如果订单不存在或者已经支付
            return;
        }
        //3.未支付,需要查询支付流水状态
        PayOrderDTO payOrder = payClient.queryPayOrderByBizOrderNo(orderId);
        //4.判断是否支付
        if (payOrder != null && payOrder.getStatus() == 3) {
            //4.1 已支付,标记订单为已支付
            orderService.markOrderPaySuccess(orderId);

        } else {
            //4.2 未支付,取消订单,恢复库存
            orderService.cancelOrder(orderId);
        }
    }
}

3.在支付微服务中添加这个功能,用于在交易微服务中远程调用,从而查询到支付流水。

java 复制代码
@ApiOperation("根据id查询支付单")
    @GetMapping("/biz/{id}")
    public PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id) {
        PayOrder payOrder = payOrderService.lambdaQuery().eq(PayOrder::getBizOrderNo, id).one();
        return BeanUtils.copyBean(payOrder, PayOrderDTO.class);
    }
java 复制代码
public PayOrder queryByBizOrderNo(Long bizOrderNo) {
        return lambdaQuery()
                .eq(PayOrder::getBizOrderNo, bizOrderNo)
                .one();
    }

4.交易微服务作为生产者将消息投递给延迟交换机(详见代码的第五步)

java 复制代码
@Override
    @GlobalTransactional
    public Long createOrder(OrderFormDTO orderFormDTO) {
        // 1.订单数据
        Order order = new Order();
        // 1.1.查询商品
        List<OrderDetailDTO> detailDTOS = orderFormDTO.getDetails();
        // 1.2.获取商品id和数量的Map
        Map<Long, Integer> itemNumMap = detailDTOS.stream()
                .collect(Collectors.toMap(OrderDetailDTO::getItemId, OrderDetailDTO::getNum));
        Set<Long> itemIds = itemNumMap.keySet();
        // 1.3.查询商品
        List<ItemDTO> items = itemClient.queryItemByIds(itemIds);
        if (items == null || items.size() < itemIds.size()) {
            throw new BadRequestException("商品不存在");
        }
        // 1.4.基于商品价格、购买数量计算商品总价:totalFee
        int total = 0;
        for (ItemDTO item : items) {
            total += item.getPrice() * itemNumMap.get(item.getId());
        }
        order.setTotalFee(total);
        // 1.5.其它属性
        order.setPaymentType(orderFormDTO.getPaymentType());
        order.setUserId(UserContext.getUser());
        order.setStatus(1);
        // 1.6.将Order写入数据库order表中
        save(order);

        // 2.保存订单详情
        List<OrderDetail> details = buildDetails(order.getId(), items, itemNumMap);
        iOrderDetailService.saveBatch(details);

        //3.清理购物车商品
        //cartClient.removeByItemIds(itemIds);

        try {
            rabbitTemplate.convertAndSend("trade.topic", "order.create", itemIds, new MessagePostProcessor() {
                @Override
                public Message postProcessMessage(Message message) throws AmqpException {
                    message.getMessageProperties().setHeader("userId",UserContext.getUser());
                    return message;
                }
            });
        } catch (Exception e){
            log.info("发送购物车清除通知失败,订单id:{}",itemIds);
        }

        // 4.扣减库存
        try {
            itemClient.deductStock(detailDTOS);
        } catch (Exception e) {
            throw new RuntimeException("库存不足!");
        }

        //5.发送延迟消息,检测订单支付状态
        rabbitTemplate.convertAndSend(
                MQConstants.DELAY_EXCHANGE_NAME,
                MQConstants.DELAY_ORDER_KEY,
                order.getId(), message -> {
                    message.getMessageProperties().setDelay(10000);
                    return message;
                }
        );

        return order.getId();
    }

至此就写完了,由于我们是双重检查,即使订单的支付状态还是未支付,只要支付单中发现扣款了,那也算支付成功,所以甚至可以不在这里修改订单状态,直接判断支付单。

当然,我这里还是不删,保持这个方法的逻辑完整性。

java 复制代码
@Override
    @GlobalTransactional
    public void tryPayOrderByBalance(PayOrderFormDTO payOrderFormDTO) {
        // 1.查询支付单
        PayOrder po = getById(payOrderFormDTO.getId());
        // 2.判断状态
        if (!PayStatus.WAIT_BUYER_PAY.equalsValue(po.getStatus())) {
            // 订单不是未支付,状态异常
            throw new BizIllegalException("交易已支付或关闭!");
        }
        // 3.尝试扣减余额
        userClient.deductMoney(payOrderFormDTO.getPw(), po.getAmount());
        // 4.修改支付单状态
        boolean success = markPayOrderSuccess(payOrderFormDTO.getId(), LocalDateTime.now());
        if (!success) {
            throw new BizIllegalException("交易已支付或关闭!");
        }
        //5.修改订单状态
        try {
            rabbitTemplate.convertAndSend("pay.direct","pay.success",po.getBizOrderNo());
        } catch (Exception e){
            log.info("发送支付状态通知失败,订单id:{}",po.getBizOrderNo());
        }

    }
相关推荐
tkevinjd13 分钟前
5-Web基础
java·spring boot·后端·spring
树码小子14 分钟前
SpringMVC(4):获取参数,上传文件
spring·mvc
源代码•宸25 分钟前
Golang面试题库(Context、Channel)
后端·面试·golang·context·channel·sudog·cancelctx
橘子师兄30 分钟前
C++AI大模型接入SDK—Ollama本地接入Deepseek
c++·人工智能·后端
好好沉淀35 分钟前
Spring Boot Admin:微服务的“健康体检中心
spring boot·spring·架构·springbootadmin
sheji341636 分钟前
【开题答辩全过程】以 基于Spring Boot的旅游推荐系统的设计与实现为例,包含答辩的问题和答案
spring boot·后端·旅游
只是懒得想了43 分钟前
Go语言ORM深度解析:GORM、XORM与entgo实战对比及最佳实践
开发语言·数据库·后端·golang
爱吃山竹的大肚肚1 小时前
异步导出方案
java·spring boot·后端·spring·中间件
草履虫建模1 小时前
Java 基础到进阶|专栏导航:路线图 + 目录(持续更新)
java·开发语言·spring boot·spring cloud·maven·基础·进阶
三水不滴1 小时前
从原理、场景、解决方案深度分析Redis分布式Session
数据库·经验分享·redis·笔记·分布式·后端·性能优化