目录

RabbitMQ学习笔记

RabbitMQ学习笔记

RabbitMQ学习笔记

1. 认识MQ

1.1 同步调用

在微服务架构中,同步调用(Synchronous Invocation)通常 指一个服务调用另一个服务时,调用方必须等待被调用方处理完请求并返回响应后,才能继续执行后续逻辑

https://i-blog.csdnimg.cn/direct/7c06c0fcb94649f19cfcfea98f6326ce.png#pic_center

特点

  • 阻塞执行:调用方在等待返回结果时无法执行其他任务。
  • 执行顺序严格:必须按顺序等待上一个任务完成后才能继续下一个任务。
  • 适用于短时间执行的任务:如果被调用的方法执行时间较长,会影响系统的响应速度和并发能力。

存在的问题

  • 拓展性差:每次有新的需求,现有支付逻辑都要跟着变化,代码经常变动,不符合开闭原则,拓展性不好。
  • 性能下降:每次远程调用,调用者都是阻塞等待状态。最终整个业务的响应时长就是每次远程调用的执行时长之和
  • 级联失败(雪崩问题):当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。

1.2 异步调用

在微服务架构中,异步调用(Asynchronous Invocation)指的是 调用方在调用某个服务时,不需要等待其完成,而是立即返回并继续执行其他任务被调用方在完成处理后 ,可以通过回调、轮询、 消息队列 等方式通知调用方。

异步调用方式其实就是基于消息通知的方式,一般包含三个角色:

  • 消息发送者:投递消息的人,就是原来的调用方
  • 消息Broker:管理、暂存、转发消息,你可以把它理解成微信服务器
  • 消息接收者:接收和处理消息的人,就是原来的服务提供方

https://i-blog.csdnimg.cn/direct/69759dad84a4486ebe5453ddd53dabe7.png#pic_center

在异步调用中,发送者不再直接同步调用接收者的业务接口,而是发送一条消息投递给消息Broker。然后接收者根据自己的需求从消息Broker那里订阅消息。每当发送方发送消息后,接受者都能获取消息并处理。

https://i-blog.csdnimg.cn/direct/cba174b6e403476e98e91e7ee5061164.png#pic_center

异步调用的优势包括:

  • 耦合度更低
  • 性能更好
  • 业务拓展性强
  • 故障隔离,避免级联失败

异步通信存在下列缺点:

  • 完全依赖于Broker的可靠性、安全性和性能
  • 架构复杂,后期维护和调试麻烦

1.3 常见MQ技术对比

常见MQRabbitMQActiveMQRocketMQKafka
公司/社区RabbitApache阿里Apache
开发语言ErlangJavaJavaScala&Java
协议支持AMQP, XMPP, SMTP, STOMPOpenWire, STOMP, REST, XMPP, AMQP自定义协议自定义协议
可用性一般
单机吞吐量一般非常高
消息延迟微秒级毫秒级毫秒级毫秒以内
消息可靠性一般一般

2. RabbitMQ

2.1 RabbitMQ架构

  • publisher:生产者,也就是发送消息的一方
  • consumer:消费者,也就是消费消息的一方
  • queue:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理
  • exchange:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。
  • virtual host:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue

https://i-blog.csdnimg.cn/direct/18a3f2f6516048b7a5620d535fb1a779.png#pic_center

生产者发送到交换机的消息,只会路由到与其绑定的队列,因此仅仅创建队列是不够的,我们还需要将其与交换机绑定

2.2 数据隔离

  • 给每个项目创建不同的virtual host,将每个项目的数据隔离。
  • 给每个项目创建独立的运维账号,将管理权限分离。

3. SpringAMQP

3.1 入门案例

3.1.1 引入依赖
       <!--AMQP依赖,包含RabbitMQ-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
3.1.2 添加配置

在publisher服务的application.yml中添加配置:

spring:
  rabbitmq:
    host: 192.168.150.101 # 你的虚拟机IP
    port: 5672 # 端口
    virtual-host: /hmall # 虚拟主机
    username: hmall # 用户名
    password: 123 # 密码
3.1.3 发送消息
@SpringBootTest
public class SpringAmqpTest {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSimpleQueue() {
        // 队列名称
        String queueName = "simple.queue";
        // 消息
        String message = "hello, spring amqp!";
        // 发送消息
        rabbitTemplate.convertAndSend(queueName, message);
    }
}
3.1.4 接收消息
@Component
public class SpringRabbitListener {
        // 利用RabbitListener来声明要监听的队列信息
    // 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
    // 可以看到方法体中接收的就是消息体的内容
    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueueMessage(String msg) throws InterruptedException {
        System.out.println("spring 消费者接收到消息:【" + msg + "】");
    }
}

3.2 WorkQueues模型

让多个消费者绑定到一个队列,共同消费队列中的消息。可以使用WorkQueues模型,多个消费者共同处理消息处理,消息处理的速度就能大大提高了。

https://i-blog.csdnimg.cn/direct/85a243b3084742f0acfc9f3b64386b65.png#pic_center

3.2.1 消息发送
/**
     * workQueue
     * 向队列中不停发送消息,模拟消息堆积。
     */
@Test
public void testWorkQueue() throws InterruptedException {
    // 队列名称
    String queueName = "simple.queue";
    // 消息
    String message = "hello, message_";
    for (int i = 0; i < 50; i++) {
        // 发送消息,每20毫秒发送一次,相当于每秒发送50条消息
        rabbitTemplate.convertAndSend(queueName, message + i);
        Thread.sleep(20);
    }
}
3.2.2 消息接收
@RabbitListener(queues = "work.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
    System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
    Thread.sleep(20);
}

@RabbitListener(queues = "work.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
    System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
    Thread.sleep(200);
}
  • 消费者1 sleep了20毫秒,相当于每秒钟处理50个消息
  • 消费者2 sleep了200毫秒,相当于每秒处理5个消息
3.2.3 测试结果
消费者1接收到消息:【hello, message_021:06:00.869555300
消费者2........接收到消息:【hello, message_121:06:00.884518
消费者1接收到消息:【hello, message_221:06:00.907454400
消费者1接收到消息:【hello, message_421:06:00.953332100
消费者1接收到消息:【hello, message_621:06:00.997867300
消费者1接收到消息:【hello, message_821:06:01.042178700
消费者2........接收到消息:【hello, message_321:06:01.086478800
消费者1接收到消息:【hello, message_1021:06:01.087476600
消费者1接收到消息:【hello, message_1221:06:01.132578300
消费者1接收到消息:【hello, message_1421:06:01.175851200
消费者1接收到消息:【hello, message_1621:06:01.218533400
消费者1接收到消息:【hello, message_1821:06:01.261322900
消费者2........接收到消息:【hello, message_521:06:01.287003700
消费者1接收到消息:【hello, message_2021:06:01.304412400
消费者1接收到消息:【hello, message_2221:06:01.349950100
消费者1接收到消息:【hello, message_2421:06:01.394533900
消费者1接收到消息:【hello, message_2621:06:01.439876500
消费者1接收到消息:【hello, message_2821:06:01.482937800
消费者2........接收到消息:【hello, message_721:06:01.488977100
消费者1接收到消息:【hello, message_3021:06:01.526409300
消费者1接收到消息:【hello, message_3221:06:01.572148
消费者1接收到消息:【hello, message_3421:06:01.618264800
消费者1接收到消息:【hello, message_3621:06:01.660780600
消费者2........接收到消息:【hello, message_921:06:01.689189300
消费者1接收到消息:【hello, message_3821:06:01.705261
消费者1接收到消息:【hello, message_4021:06:01.746927300
消费者1接收到消息:【hello, message_4221:06:01.789835
消费者1接收到消息:【hello, message_4421:06:01.834393100
消费者1接收到消息:【hello, message_4621:06:01.875312100
消费者2........接收到消息:【hello, message_1121:06:01.889969500
消费者1接收到消息:【hello, message_4821:06:01.920702500
消费者2........接收到消息:【hello, message_1321:06:02.090725900
消费者2........接收到消息:【hello, message_1521:06:02.293060600
消费者2........接收到消息:【hello, message_1721:06:02.493748
消费者2........接收到消息:【hello, message_1921:06:02.696635100
消费者2........接收到消息:【hello, message_2121:06:02.896809700
消费者2........接收到消息:【hello, message_2321:06:03.099533400
消费者2........接收到消息:【hello, message_2521:06:03.301446400
消费者2........接收到消息:【hello, message_2721:06:03.504999100
消费者2........接收到消息:【hello, message_2921:06:03.705702500
消费者2........接收到消息:【hello, message_3121:06:03.906601200
消费者2........接收到消息:【hello, message_3321:06:04.108118500
消费者2........接收到消息:【hello, message_3521:06:04.308945400
消费者2........接收到消息:【hello, message_3721:06:04.511547700
消费者2........接收到消息:【hello, message_3921:06:04.714038400
消费者2........接收到消息:【hello, message_4121:06:04.916192700
消费者2........接收到消息:【hello, message_4321:06:05.116286400
消费者2........接收到消息:【hello, message_4521:06:05.318055100
消费者2........接收到消息:【hello, message_4721:06:05.520656400
消费者2........接收到消息:【hello, message_4921:06:05.723106700

可以看到消费者1和消费者2竟然每人消费了25条消息:

  • 消费者1很快完成了自己的25条消息

  • 消费者2却在缓慢的处理自己的25条消息。

    也就是说 消息是平均分配给每个消费者 ,并没有考虑到消费者的处理能力。导致1个消费者空闲,另一个消费者忙的不可开交。

3.2.4 能者多劳配置

修改consumer服务的application.yml文件,添加配置:

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

再次测试发现,由于消费者1处理速度较快,所以处理了更多的消息;消费者2处理速度较慢,只处理了6条消息。而最终总的执行耗时也在1秒左右,大大提升。

正所谓能者多劳,这样充分利用了每一个消费者的处理能力,可以有效避免消息积压问题。

3.2.5 Work模型总结
  • 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理
  • 通过设置prefetch来控制消费者预取的消息数量

3.3 交换机

交换机的类型:

  • Fanout:广播,将消息交给所有绑定到交换机的队列。我们最早在控制台使用的正是Fanout交换机
  • Direct:订阅,基于RoutingKey(路由key)发送给订阅了消息的队列
  • Topic:通配符订阅,与Direct类似,只不过RoutingKey可以使用通配符
3.3.1 Fanout交换机

广播 交换机,交换机把消息发送给绑定过的所有队列,每个队列的消费者都能收到消息

https://i-blog.csdnimg.cn/direct/cd0749ce4a964ac290a2151f99a34053.png#pic_center

  1. 消息发送
@Test
public void testFanoutExchange() {
    // 交换机名称
    String exchangeName = "hmall.fanout";
    // 消息
    String message = "hello, everyone!";
    rabbitTemplate.convertAndSend(exchangeName, "", message);
}
  1. 消息接收
@Test
public void testFanoutExchange() {
    // 交换机名称
    String exchangeName = "hmall.fanout";
    // 消息
    String message = "hello, everyone!";
    rabbitTemplate.convertAndSend(exchangeName, "", message);
}
  • FanoutExchange的会将消息路由到每个绑定的队列
3.3.2 Direct交换机

Direct模型下:

  • 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey(路由key)
  • 消息的发送方在 向 Exchange发送消息时,也必须指定消息的 RoutingKey。
  • Exchange不再把消息交给每一个绑定的队列,而是根据消息的Routing Key进行判断, 只有队列的Routingkey与消息的 Routing key完全一致 ,才会接收到消息

https://i-blog.csdnimg.cn/direct/bd9cf41c62c04129af4a817bf5fdc67b.png#pic_center

3.3.2.1 消息接收
@RabbitListener(queues = "direct.queue1")
public void listenDirectQueue1(String msg) {
    System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}

@RabbitListener(queues = "direct.queue2")
public void listenDirectQueue2(String msg) {
    System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}
3.3.2.2 消息发送
@Test
public void testSendDirectExchange() {
    // 交换机名称
    String exchangeName = "hmall.direct";
    // 消息
    String message = "最新报道,哥斯拉是居民自治巨型气球,虚惊一场!";
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "blue", message);
}

此时只有 blue 的消费者才会收到消息,也就是消费者1

3.3.2.3 总结

Direct交换机与Fanout交换机的差异?

  • Fanout交换机将消息路由给每一个与之绑定的队列
  • Direct交换机根据RoutingKey判断路由给哪个队列
  • 如果多个队列具有相同的RoutingKey,则与Fanout功能类似
3.3.3 Topic交换机

Topic类型的Exchange与Direct相比,都是可以根据RoutingKey把消息路由到不同的队列。

只不过Topic类型Exchange可以让队列在绑定BindingKey 的时候使用通配符!

通配符规则:

  • #:匹配一个或多个词
  • *:匹配不多不少恰好1个词

举例:

  • item.#:能够匹配item.spu.insert 或者 item.spu
  • item.*:只能匹配item.spu

https://i-blog.csdnimg.cn/direct/42df1f8a47cd448fa543baade7b3c425.png#pic_center

  1. 消息发送
/**
 * topicExchange
 */
@Test
public void testSendTopicExchange() {
    // 交换机名称
    String exchangeName = "hmall.topic";
    // 消息
    String message = "喜报!孙悟空大战哥斯拉,胜!";
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}
  1. 消息接收
@RabbitListener(queues = "topic.queue1")
public void listenTopicQueue1(String msg){
    System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
}

@RabbitListener(queues = "topic.queue2")
public void listenTopicQueue2(String msg){
    System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
}
  1. Direct交换机与Topic交换机的差异
  • Topic交换机接收的消息RoutingKey必须是多个单词,以 . 分割
  • Topic交换机与队列绑定时的bindingKey可以指定通配符
  • #:代表0个或多个词
  • *:代表1个词

3.4 声明队列和交换机

Spring提供了基于注解方式来声明

@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "direct.queue1"),
    exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
    key = {"red", "blue"}
))
public void listenDirectQueue1(String msg){
    System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}

3.5 消息转换器(JSON转换器)

默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:

  • 数据体积过大
  • 有安全漏洞
  • 可读性差

因此可以使用JSON方式来做序列化和反序列化。

3.5.1 引入依赖

在publisher和consumer两个服务中都引入依赖:

<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.10</version>
</dependency>
3.5.2 配置消息转换器

在publisher和consumer两个服务的启动类中添加一个Bean即可:

@Bean
public MessageConverter messageConverter(){
    // 1.定义消息转换器
    Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
    jackson2JsonMessageConverter.setCreateMessageIds(true);
    return jackson2JsonMessageConverter;
}
3.5.3 接收消息
@RabbitListener(queues = "object.queue")
public void listenSimpleQueueMessage(Map<String, Object> msg) throws InterruptedException {
    System.out.println("消费者接收到object.queue消息:【" + msg + "】");
}

4. MQ消息的可靠性

在微服务架构中,消息队列(MQ)的可靠性指的是 消息在生产、传输和消费的过程中不会丢失、不会重复、不被篡改,并且能够按预期被正确消费 。确保 MQ 可靠性对于保证业务数据一致性、提高系统稳定性至关重要。

消息从生产者到消费者的每一步都可能导致消息丢失:

  • 发送消息时丢失:
    • 生产者发送消息时连接MQ失败
    • 生产者发送消息到达MQ后未找到Exchange
    • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue
    • 消息到达MQ后,处理消息的进程发生异常
  • MQ导致消息丢失:
    • 消息到达MQ,保存到队列后,尚未消费就突然宕机
  • 消费者处理消息时:
    • 消息接收后尚未处理突然宕机
    • 消息接收后处理过程中抛出异常

4.1 发送者的可靠性

4.1.1 生产者重试机制

生产者发送消息时,出现了网络故障,导致与MQ的连接中断。

为了解决这个问题,SpringAMQP提供的消息发送时的 重试机制。即:当RabbitTemplate与MQ连接超时后,多次重试

修改publisher模块的application.yaml文件,添加下面的内容:

spring:
  rabbitmq:
    connection-timeout: 1s # 设置MQ的连接超时时间
    template:
      retry:
        enabled: true # 开启超时重试机制
        initial-interval: 1000ms # 失败后的初始等待时间
        multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
        max-attempts: 3 # 最大重试次数

注意:当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的 重试机制是阻塞式的重试 ,也就是说多次重试等待的过程中,当前线程是被阻塞的。

如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。

4.1.2 生产者确认机制(一般情况下不建议开启)

RabbitMQ提供了生产者消息确认机制,包括Publisher Confirm和Publisher Return两种。在开启确认机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回不同的回执。

https://i-blog.csdnimg.cn/direct/69fe04e392a64f86a3f21471df5e53ca.png#pic_center

总结:

  • 当消息投递到MQ,但是路由失败时,通过Publisher Return返回异常信息,同时返回ack的确认信息,代表投递成功
  • 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功
  • 其它情况都会返回NACK,告知投递失败
4.1.2.1 开启生产者确认

在publisher模块的application.yaml中添加配置:

spring:
  rabbitmq:
    publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
    publisher-returns: true # 开启publisher return机制

这里publisher-confirm-type有三种模式可选:

  • none:关闭confirm机制
  • simple:同步阻塞等待MQ的回执
  • correlated:MQ异步回调返回回执(推荐)
4.1.2.2 定义ReturnCallback
@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
    private final RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init(){
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returned) {
                log.error("触发return callback,");
                log.debug("exchange: {}", returned.getExchange());
                log.debug("routingKey: {}", returned.getRoutingKey());
                log.debug("message: {}", returned.getMessage());
                log.debug("replyCode: {}", returned.getReplyCode());
                log.debug("replyText: {}", returned.getReplyText());
            }
        });
    }
}
4.1.2.3 定义ConfirmCallback

CorrelationData中包含两个核心的东西:

  • id:消息的唯一标示,MQ对不同的消息的回执以此做判断,避免混淆
  • SettableListenableFuture:回执结果的Future对象
@Test
void testPublisherConfirm() {
    // 1.创建CorrelationData
    CorrelationData cd = new CorrelationData();
    // 2.给Future添加ConfirmCallback
    cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
        @Override
        public void onFailure(Throwable ex) {
            // 2.1.Future发生异常时的处理逻辑,基本不会触发
            log.error("send message fail", ex);
        }
        @Override
        public void onSuccess(CorrelationData.Confirm result) {
            // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
            if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
                log.debug("发送消息成功,收到 ack!");
            }else{ // result.getReason(),String类型,返回nack时的异常描述
                log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
            }
        }
    });
    // 3.发送消息
    rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}

4.2 MQ的可靠性

消息到达MQ以后,因为 MQ是基于内存存储的,如果内存空间被消息占满,如果MQ不能及时保存,也会导致消息丢失

有两种解决方法:

  • 数据持久化
  • LazyQueue(推荐)
4.2.1 数据持久化

为了提升性能,默认情况下MQ的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:

  • 交换机持久化
  • 队列持久化
  • 消息持久化

在控制台配置相关的持久化模式,即可开启数据持久化

4.2.2 LazyQueue

从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 消费者要 消费消息时才会从磁盘中读取并加载到内存 (也就是懒加载)
  • 支持数百万条的消息存储

而在3.12版本之后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本或者所有队列都设置为LazyQueue模式。

4.3 消费者的可靠性

消息投递给消费者并不代表就一定被正确消费了,可能出现的故障有很多,比如:

  • 消息投递的过程中出现了网络故障
  • 消费者接收到消息后突然宕机
  • 消费者接收到消息后,因处理不当导致异常
4.3.1 消费者确认机制

消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:

  • ack:成功处理消息,RabbitMQ从队列中删除该消息
  • nack:消息处理失败,RabbitMQ需要再次投递消息
  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息

https://i-blog.csdnimg.cn/direct/11f67706df9e495b8dd42283b1fb745b.png#pic_center

SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
  • manual:手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活
  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:
    • 如果是业务异常,会自动返回nack;
    • 如果是消息处理或校验异常,自动返回reject;

修改SpringAMQP的ACK处理方式:

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: none # 不做处理
4.3.2 失败重试机制

当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。

极端情况就是消费者一直无法执行成功,那么 消息requeue就会无限循环 ,导致mq的消息处理飙升,带来不必要的压力。

修改consumer服务的application.yml文件:

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          initial-interval: 1000ms # 初识的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
  • 重试达到最大次数后,Spring会返回reject,消息会被丢弃
4.3.3 失败处理策略

Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,它有3个不同实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式

  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队

  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机 (推荐)

  • RepublishMessageRecoverer: 失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。

在consumer服务中定义处理失败消息的交换机和队列,定义一个RepublishMessageRecoverer,关联队列和交换机

@Configuration
@ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")
public class ErrorMessageConfig {
    @Bean
    public DirectExchange errorMessageExchange(){
        return new DirectExchange("error.direct");
    }
    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue", true);
    }
    @Bean
    public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
        return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
    }

    @Bean
    public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
    }
}

4.4 业务幂等性

在程序开发中,则是指 同一个业务,执行一次或多次对业务状态的影响是一致的 。例如:

  • 根据id删除数据

  • 查询数据

  • 新增数据

    数据的更新往往不是幂等的,如果 重复执行可能造成不一样的后果 。比如:

  • 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况

  • 退款业务。重复退款对商家而言会有经济损失。

所以,我们要尽可能避免业务被重复执行。

4.4.1 唯一消息ID
  1. 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
  2. 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
  3. 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。

SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可:

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

业务判断就是基于业务本身的逻辑或状态来判断是否是重复的请求或消息。

当前案例中,处理消息的业务逻辑是把订单状态从未支付修改为已支付。因此我们就可以 在执行业务时判断订单状态是否是未支付 ,如果不是则证明订单已经被处理过,无需重复处理。

https://i-blog.csdnimg.cn/direct/1c1ac6b8313846fa893db19c24335abb.png#pic_center

以支付修改订单的业务为例:

    @Override
    public void markOrderPaySuccess(Long orderId) {
        // 1.查询订单
        Order old = getById(orderId);
        // 2.判断订单状态
        if (old == null || old.getStatus() != 1) {
            // 订单不存在或者订单状态不是1,放弃处理
            return;
        }
        // 3.尝试更新订单
        Order order = new Order();
        order.setId(orderId);
        order.setStatus(2);
        order.setPayTime(LocalDateTime.now());
        updateById(order);
    }

4.5 兜底方案

虽然我们利用各种机制尽可能增加了消息的可靠性,但也不好说能保证消息100%的可靠。万一真的MQ通知失败该怎么办呢?有没有其它兜底方案,能够确保订单的支付状态一致呢?

既然MQ通知不一定发送到交易服务,那么 交易服务就必须自己主动去查询支付状态 。这样即便支付服务的MQ通知失败,我们依然能 通过主动查询来保证订单状态的一致

https://i-blog.csdnimg.cn/direct/c3fbfa0008a24ed585ffc2d9b853a902.png#pic_center

通常我们采取的措施就是利用 定时任务定期查询 ,例如每隔20秒就查询一次, 并判断支付状态 。如果发现订单已经支付,则立刻更新订单状态为已支付即可。

4.6 支付服务与交易服务之间的订单状态一致性是如何保证的?

  • 首先,支付服务会正在用户支付成功以后利用MQ消息通知交易服务,完成订单状态同步。
  • 其次,为了保证MQ消息的可靠性,我们采用了生产者确认机制、消费者确认、消费者失败重试等策略,确保消息投递的可靠性
  • 最后,我们还在交易服务设置了定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。

5. 延迟消息

对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存。像这种 在一段时间以后才执行的任务,我们称之为延迟任务 ,而要实现延迟任务,最简单的方案就是利用MQ的延迟消息了。

在RabbitMQ中实现延迟消息也有两种方案:

  • 死信交换机+TTL
  • 延迟消息插件(推荐)

5.1 死信交换机

当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):

  • 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
  • 消息是一个过期消息,超时无人消费
  • 要投递的队列消息满了,无法投递

https://i-blog.csdnimg.cn/direct/73dca266b57948058508f1e99a3accd7.png#pic_center

  • 利用 TTL 让消息在普通队列中延迟一段时间。
  • 超时后,消息进入死信交换机,再转发到真正的目标队列。
  • 消费者监听目标队列,延迟时间一到,才会收到消息。

5.2 DelayExchange插件(推荐)

RabbitMQ社区提供了一个延迟消息插件来实现相同的效果

声明延迟交换机

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "delay.queue", durable = "true"),
        exchange = @Exchange(name = "delay.direct", delayed = "true"),
        key = "delay"
))
public void listenDelayMessage(String msg){
    log.info("接收到delay.queue的延迟消息:{}", msg);
}

发送延迟消息:

@Test
void testPublisherDelayMessage() {
    // 1.创建消息
    String message = "hello, delayed message";
    // 2.发送消息,利用消息后置处理器添加消息头
    rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() {
        @Override
        public Message postProcessMessage(Message message) throws AmqpException {
            // 添加延迟消息属性
            message.getMessageProperties().setDelay(5000);
            return message;
        }
    });
}

注意

  • 如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,不建议设置延迟时间过长的延迟消息。

5.3 超时订单问题

用户下单完成后,发送15分钟延迟消息,在15分钟后接收消息,检查支付状态:

  • 已支付:更新订单状态为已支付
  • 未支付:更新订单状态为关闭订单,恢复商品库存

https://i-blog.csdnimg.cn/direct/36d46b8c1da1498cb26e880d42fb81b6.png#pic_center

参考文献:

END