RabbitMQ簡介

Hanyta發表於2024-05-31
  1. 同步呼叫

    • 基於OpenFeign的呼叫都屬於是同步呼叫,等待上一個需求結束,開始下一個需求。
    • 有缺點:
      • 擴充性差:每次有新的需求,現有支付邏輯都要跟著變化,程式碼經常變動,不符合開閉原則,擴充性不好。
      • 效能下降:每次遠端呼叫,呼叫者都是阻塞等待狀態。
      • 級聯失敗:當某一個服務出現故障時,整個事務都會回滾,交易失敗。
  2. 非同步呼叫:訊息傳送者,訊息Broker,訊息接收者

    • 非同步呼叫的優勢包括:

      • 耦合度更低
      • 效能更好
      • 業務擴充性強
      • 故障隔離,避免級聯失敗
    • 缺點:

      • 完全依賴於Broker的可靠性、安全性和效能
      • 架構複雜,後期維護和除錯麻煩
  3. 技術選型


    目前國內訊息佇列使用最多的還是RabbitMQ,再加上其各方面都比較均衡,穩定性也好。

  4. RabbitMQ是基於Erlang語言開發的開源訊息通訊中介軟體。

    RabbitMQ對應的架構如圖:

    • publisher:生產者,也就是傳送訊息的一方
    • consumer:消費者,也就是消費訊息的一方
    • queue:佇列,儲存訊息。生產者投遞的訊息會暫存在訊息佇列中,等待消費者處理
    • exchange:交換機,負責訊息路由。生產者傳送的訊息由交換機決定投遞到哪個佇列。
    • virtual host:虛擬主機,起到資料隔離的作用。每個虛擬主機相互獨立,有各自的exchange、queue
  5. SpringAMQP:由於RabbitMQ採用了AMQP協議,因此它具備跨語言的特性。任何語言只要遵循AMQP協議收發訊息,都可以與RabbitMQ互動。並且RabbitMQ官方也提供了各種不同語言的客戶端。但是,RabbitMQ官方提供的Java客戶端編碼相對複雜,一般生產環境下我們更多會結合Spring來使用。而Spring的官方剛好基於RabbitMQ提供了這樣一套訊息收發的模板工具:SpringAMQP。並且還基於SpringBoot對其實現了自動裝配,使用起來非常方便。

    • SpringAMQP提供了三個功能:

      • 自動宣告佇列、交換機及其繫結關係
      • 基於註解的監聽器模式,非同步接收訊息
      • 封裝了RabbitTemplate工具,用於傳送訊息
  6. 快速使用

    • 引入依賴
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    
    • 新增配置
    spring:
      rabbitmq:
        host: 192.168.150.101 # 你的虛擬機器IP
        port: 5672 # 埠
        virtual-host: /hmall # 虛擬主機
        username: hmall # 使用者名稱
        password: 123 # 密碼
    
    • 進行測試,訊息傳送
    @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);
        }
    }
    
    • 訊息接收
    package com.itheima.consumer.listener;
    
    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SpringRabbitListener {
            // 利用RabbitListener來宣告要監聽的佇列資訊
        // 將來一旦監聽的佇列中有了訊息,就會推送給當前服務,呼叫當前方法,處理訊息。
        // 可以看到方法體中接收的就是訊息體的內容
        @RabbitListener(queues = "simple.queue")
        public void listenSimpleQueueMessage(String msg) throws InterruptedException {
            System.out.println("spring 消費者接收到訊息:【" + msg + "】");
        }
    }
    
  7. WorkQueues模型: 讓多個消費者繫結到一個佇列,共同消費佇列中的訊息,能夠提高訊息處理的速度。但是這是訊息是平均分配給每個消費者的,並沒有考慮到消費者的處理能力。

    • 修改consumer服務的application.yml檔案,新增配置
    spring:
      rabbitmq:
        listener:
          simple:
            prefetch: 1 # 每次只能獲取一條訊息,處理完成才能獲取下一個訊息
    

    這樣充分利用了每一個消費者的處理能力,可以有效避免訊息積壓問題。

    Work模型的使用:

    • 多個消費者繫結到一個佇列,同一條訊息只會被一個消費者處理
    • 透過設定prefetch來控制消費者預取的訊息數量
  8. Fanout交換機:廣播,FanoutExchange的會將訊息路由到每個繫結的佇列

    • 可以有多個佇列
    • 每個佇列都要繫結到Exchange(交換機)
    • 生產者傳送的訊息,只能傳送到交換機
    • 交換機把訊息傳送給繫結過的所有佇列
    • 訂閱佇列的消費者都能拿到訊息
  9. Direct交換機:不同的訊息被不同的佇列消費

    • 佇列與交換機的繫結,不能是任意繫結了,而是要指定一個RoutingKey(路由key)
    • 訊息的傳送方在 向 Exchange傳送訊息時,也必須指定訊息的 RoutingKey。
    • Exchange不再把訊息交給每一個繫結的佇列,而是根據訊息的Routing Key進行判斷,只有佇列的Routingkey與訊息的 Routing key完全一致,才會接收到訊息
  10. Topic交換機:可以讓佇列在繫結BindingKey 的時候使用萬用字元

    萬用字元規則:
    '#':匹配一個或多個詞
    *:匹配不多不少恰好1個詞

  11. Java程式碼的形式宣告佇列和交換機

    • fanout示例
    package com.itheima.consumer.config;
    
    import org.springframework.amqp.core.Binding;
    import org.springframework.amqp.core.BindingBuilder;
    import org.springframework.amqp.core.FanoutExchange;
    import org.springframework.amqp.core.Queue;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class FanoutConfig {
        /**
         * 宣告交換機
         * @return Fanout型別交換機
         */
        @Bean
        public FanoutExchange fanoutExchange(){
            return new FanoutExchange("hmall.fanout");
        }
        /**
         * 第1個佇列
         */
        @Bean
        public Queue fanoutQueue1(){
            return new Queue("fanout.queue1");
        }
        /**
         * 繫結佇列和交換機
         */
        @Bean
        public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange){
            return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
        }
        /**
         * 第2個佇列
         */
        @Bean
        public Queue fanoutQueue2(){
            return new Queue("fanout.queue2");
        }
        /**
         * 繫結佇列和交換機
         */
        @Bean
        public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange){
            return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
        }
    }
    
    • direct示例:direct模式由於要繫結多個KEY,會非常麻煩,每一個Key都要編寫一個binding
    package com.itheima.consumer.config;
    
    import org.springframework.amqp.core.*;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class DirectConfig {
    
        /**
         * 宣告交換機
         * @return Direct型別交換機
         */
        @Bean
        public DirectExchange directExchange(){
            return ExchangeBuilder.directExchange("hmall.direct").build();
        }
    
        /**
         * 第1個佇列
         */
        @Bean
        public Queue directQueue1(){
            return new Queue("direct.queue1");
        }
    
        /**
         * 繫結佇列和交換機
         */
        @Bean
        public Binding bindingQueue1WithRed(Queue directQueue1, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue1).to(directExchange).with("red");
        }
        /**
         * 繫結佇列和交換機
         */
        @Bean
        public Binding bindingQueue1WithBlue(Queue directQueue1, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue1).to(directExchange).with("blue");
        }
    
        /**
         * 第2個佇列
         */
        @Bean
        public Queue directQueue2(){
            return new Queue("direct.queue2");
        }
    
        /**
         * 繫結佇列和交換機
         */
        @Bean
        public Binding bindingQueue2WithRed(Queue directQueue2, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue2).to(directExchange).with("red");
        }
        /**
         * 繫結佇列和交換機
         */
        @Bean
        public Binding bindingQueue2WithYellow(Queue directQueue2, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue2).to(directExchange).with("yellow");
        }
    }
    
    • 基於註解宣告
    @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 + "】");
    }
    
    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "direct.queue2"),
        exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
        key = {"red", "yellow"}
    ))
    public void listenDirectQueue2(String msg){
        System.out.println("消費者2接收到direct.queue2的訊息:【" + msg + "】");
    }
    
  12. 訊息轉換器:Spring的訊息傳送程式碼接收的訊息體是一個Object,而在資料傳輸時,它會把你傳送的訊息序列化為位元組傳送給MQ,接收訊息的時候,還會把位元組反序列化為Java物件。

    預設情況下Spring採用的序列化方式是JDK序列化。眾所周知,JDK序列化存在下列問題:

    • 資料體積過大:JDK 序列化會將物件轉換為位元組流進行傳輸,但這種方式可能導致序列化後的資料體積遠大於原始物件的大小

    • 有安全漏洞:JDK 序列化在處理物件時存在一些安全漏洞,主要是由於它對反序列化的輸入信任過度,可能導致惡意程式碼注入、遠端程式碼執行等安全問題。

    • 可讀性差:序列化後的資料通常是以位元組流的形式存在的

    • 配置JSON轉換器

    • 在publisher和consumer兩個服務中都引入依賴,如果專案中引入了spring-boot-starter-web依賴,則無需再次引入Jackson依賴。

    <dependency>
        <groupId>com.fasterxml.jackson.dataformat</groupId>
        <artifactId>jackson-dataformat-xml</artifactId>
        <version>xxx</version>
    </dependency>
    
    • 配置訊息轉換器,在publisher和consumer兩個服務的啟動類中新增一個Bean即可:
    @Bean
    public MessageConverter messageConverter(){
        // 1.定義訊息轉換器
        Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
        // 2.配置自動建立訊息id,用於識別不同訊息,也可以在業務中基於ID判斷是否是重複訊息
        jackson2JsonMessageConverter.setCreateMessageIds(true);
        return jackson2JsonMessageConverter;
    }
    
  13. 業務改造

    • 案例需求:改造餘額支付功能,將支付成功後基於OpenFeign的交易服務的更新訂單狀態介面的同步呼叫,改為基於RabbitMQ的非同步通知。

    • 定義direct型別交換機,命名為pay.direct

    • 定義訊息佇列,命名為trade.pay.success.queue

    • 將trade.pay.success.queue與pay.direct繫結,BindingKey為pay.success

    • 支付成功時不再呼叫交易服務更新訂單狀態的介面,而是傳送一條訊息到pay.direct,傳送訊息的RoutingKey 為pay.success,訊息內容是訂單id

    • 交易服務監聽trade.pay.success.queue佇列,接收到訊息後更新訂單狀態為已支付

    • 配置MQ,新增依賴

      <!--訊息傳送-->
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-amqp</artifactId>
      </dependency>
    
    • 配置MQ地址
    spring:
      rabbitmq:
        host: 192.168.150.101 # 你的虛擬機器IP
        port: 5672 # 埠
        virtual-host: /hmall # 虛擬主機
        username: hmall # 使用者名稱
        password: 123 # 密碼
    
    • 在trade-service服務中定義一個訊息監聽類
    package com.hmall.trade.listener;
    
    import com.hmall.trade.service.IOrderService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.amqp.core.ExchangeTypes;
    import org.springframework.amqp.rabbit.annotation.Exchange;
    import org.springframework.amqp.rabbit.annotation.Queue;
    import org.springframework.amqp.rabbit.annotation.QueueBinding;
    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.stereotype.Component;
    
    @Component
    @RequiredArgsConstructor
    public class PayStatusListener {
    
        private final IOrderService orderService;
    
        @RabbitListener(bindings = @QueueBinding(
                value = @Queue(name = "trade.pay.success.queue", durable = "true"),
                exchange = @Exchange(name = "pay.topic"),
                key = "pay.success"
        ))
        public void listenPaySuccess(Long orderId){
            orderService.markOrderPaySuccess(orderId);
        }
    }
    
    • 修改pay-service服務下的com.hmall.pay.service.impl.PayOrderServiceImpl類中的tryPayOrderByBalance方法
    private final RabbitTemplate rabbitTemplate;
    
    @Override
    @Transactional
    public void tryPayOrderByBalance(PayOrderDTO payOrderDTO) {
        // 1.查詢支付單
        PayOrder po = getById(payOrderDTO.getId());
        // 2.判斷狀態
        if(!PayStatus.WAIT_BUYER_PAY.equalsValue(po.getStatus())){
            // 訂單不是未支付,狀態異常
            throw new BizIllegalException("交易已支付或關閉!");
        }
        // 3.嘗試扣減餘額
        userClient.deductMoney(payOrderDTO.getPw(), po.getAmount());
        // 4.修改支付單狀態
        boolean success = markPayOrderSuccess(payOrderDTO.getId(), LocalDateTime.now());
        if (!success) {
            throw new BizIllegalException("交易已支付或關閉!");
        }
        // 5.修改訂單狀態
        // tradeClient.markOrderPaySuccess(po.getBizOrderNo());
        try {
            rabbitTemplate.convertAndSend("pay.direct", "pay.success", po.getBizOrderNo());
        } catch (Exception e) {
            log.error("支付成功的訊息傳送失敗,支付單id:{}, 交易單id:{}", po.getId(), po.getBizOrderNo(), e);
        }
    }
    

相關文章