如何用RabbitMQ實現延遲佇列

雙子孤狼發表於2021-02-03

前言

jdkjuc 工具包中,提供了一種延遲佇列 DelayQueue。延遲佇列用處非常廣泛,比如我們最常見的場景就是在網購或者外賣平臺中發起一個訂單,如果不付款,一般 15 分鐘後就會被關閉,這個直接用定時任務是不好實現的,因為每個使用者下單的時間並不確定,所以這時候就需要用到延遲佇列。

什麼是延遲佇列

延遲佇列本身也是佇列,只不過這個佇列是延遲的,意思就是說當我們把一條訊息放入延遲佇列,訊息並不會立刻出隊,而是會在到達指定時間之後(或者說過了指定時間)才會出隊,從而被消費者消費。

利用死信佇列實現延遲佇列

RabbitMQ 中的死信佇列就是用來儲存特定條件下的訊息,那麼假如我們把這個條件設定為指定時間過期(設定帶TTL 的訊息或者佇列),就可以用來實現延遲佇列的功能。

  1. 新建一個 TtlDelayRabbitConfig 配置類(省略了包名和匯入),訊息最開始傳送至 ttl 訊息佇列,這個佇列中所有的訊息在 5 秒後過期,後期後會進入死信佇列:
@Configuration
public class TtlDelayRabbitConfig {

    //路由ttl訊息交換機
    @Bean("ttlDelayFanoutExchange")
    public FanoutExchange fanoutExchange(){
        return new FanoutExchange("TTL_DELAY_FANOUT_EXCHANGE");
    }

    //ttl訊息佇列
    @Bean("ttlDelayQueue")
    public Queue ttlQueue(){
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("x-message-ttl", 5000);//佇列中所有訊息5秒後過期
        map.put("x-dead-letter-exchange", "TTL_DELAY_DEAD_LETTER_FANOUT_EXCHANGE");//過期後進入死信佇列
        return new Queue("TTL_QUEUE",false,false,false,map);
    }

    //Fanout交換機和productQueue繫結
    @Bean
    public Binding bindTtlFanoutExchange(@Qualifier("ttlDelayQueue") Queue queue, @Qualifier("ttlDelayFanoutExchange") FanoutExchange fanoutExchange){
        return BindingBuilder.bind(queue).to(fanoutExchange);
    }

    //fanout死信交換機
    @Bean("ttlDelayDeadLetterExchange")
    public FanoutExchange deadLetterExchange(){
        return new FanoutExchange("TTL_DELAY_DEAD_LETTER_FANOUT_EXCHANGE");
    }

    //死信佇列
    @Bean("ttlDelayDeadLetterQueue")
    public Queue ttlDelayDeadLetterQueue(){
        return new Queue("TTL_DELAY_DEAD_LETTER_FANOUT_QUEUE");
    }

    //死信佇列和死信交換機繫結
    @Bean
    public Binding deadLetterQueueBindExchange(@Qualifier("ttlDelayDeadLetterQueue") Queue queue, @Qualifier("ttlDelayDeadLetterExchange") FanoutExchange fanoutExchange){
        return BindingBuilder.bind(queue).to(fanoutExchange);
    }
}

  1. 新建一個消費者 TtlDelayConsumer 類,監聽死信佇列,這裡收到的訊息都是生產者生產訊息之後的 5 秒,也就是延遲了 5 秒的訊息:
@Component
public class TtlDelayConsumer {

    @RabbitHandler
    @RabbitListener(queues = "TTL_DELAY_DEAD_LETTER_FANOUT_QUEUE")
    public void fanoutConsumer(String msg){
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("【延遲佇列】【" + sdf.format(new Date()) + "】收到死信佇列訊息:" + msg);
    }
}
  1. 新建一個 DelayQueueController 類做生產者來傳送訊息:
@RestController
@RequestMapping("/delay")
public class DelayQueueController {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @GetMapping(value="/ttl/send")
    public String clearVipInfo(@RequestParam(value = "msg",defaultValue = "no message") String msg){
        rabbitTemplate.convertAndSend("TTL_DELAY_FANOUT_EXCHANGE","",msg);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("訊息傳送成功【" + sdf.format(new Date()) + "】");
        return "succ";
    }
}
  1. 最後我們在瀏覽器輸入地址 http://localhost:8080/delay/ttl/send?msg=測試ttl延遲佇列 進行測試,可以看到每條訊息都是在傳送 5 秒之後才能收到訊息:

TTL 延遲佇列的問題

假如我們實際中,有的訊息是 10 分鐘過期,有的是 20 分鐘過期,這時候我們就需要建立多個佇列,一旦時間維度非常龐大,那麼就需要維護非常多的佇列。說到這裡,可能很多人會有疑問,我們可以針對單條資訊設定過期時間,大可不必去定義多個佇列?

然而事實真的是如此嗎?接下來我們通過一個例子來驗證下。

  1. 把上面示例中 TtlDelayRabbitConfig 類中的佇列定義函式 x-message-ttl 屬性去掉,不過需要注意的是我們需要先把這個佇列後臺刪除掉,否則同名佇列重複建立無效:
@Bean("ttlDelayQueue")
public Queue ttlQueue(){
    Map<String, Object> map = new HashMap<String, Object>();
    //        map.put("x-message-ttl", 5000);//註釋掉這個屬性,佇列不設定過期時間
    map.put("x-dead-letter-exchange", "TTL_DELAY_DEAD_LETTER_FANOUT_EXCHANGE");//過期後進入死信佇列
    return new Queue("TTL_QUEUE",false,false,false,map);
}
  1. 然後將 DelayQueueController 類中的傳送訊息方法修改一下,對每條資訊設定過期時間:
@GetMapping(value="/ttl/send")
    public String ttlMsgSend(@RequestParam(value = "msg",defaultValue = "no message") String msg,
                             @RequestParam(value = "time") String millTimes){
        MessageProperties messageProperties = new MessageProperties();
        messageProperties.setExpiration(millTimes);//單條訊息設定過期時間,單位:毫秒
        Message message = new Message(msg.getBytes(), messageProperties);
        rabbitTemplate.convertAndSend("TTL_DELAY_FANOUT_EXCHANGE","",message);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("訊息傳送成功【" + sdf.format(new Date()) + "】");
        return "succ";
    }
  1. 然後執行 2 條訊息傳送,一條 10 秒過期,一條 5 秒過期,先傳送 10 秒的:
http://localhost:8080/delay/ttl/send?msg=10秒過期訊息&time=10000
http://localhost:8080/delay/ttl/send?msg=5秒過期訊息&time=5000
  1. 執行之後得到如下結果:

我們看到,兩條訊息都是 10 秒後過期,這是巧合嗎?並不是,這是因為 RabbitMQ 中的機制就是如果前一條訊息沒有出隊,那麼即使後一條訊息已經失效,也必須要等前一條訊息出隊之後才能出隊,所以這就是為什麼一般都儘量避免同一個佇列單條訊息設定不同過期時間的做法。

死信佇列實現的延遲佇列缺點

通過以上兩個例子,使用死信佇列來實現延遲佇列,我們可以得到幾個很明顯的缺點:

  • 如果有非常多的時間點(比如有的 10 分鐘過期,有的 20 分鐘過期等),則需要建立不同的交換機和佇列來實現訊息的路由。
  • 單獨設定訊息的 TTL 時可能會造成訊息的阻塞。因為當前一條訊息沒有出隊,後一條訊息即使到期了也不能出隊。
  • 訊息可能會有一定的延遲(上面的示例中就可以看到有一點延遲)。

為了避免 TTL 和死信佇列可能造成的問題,所以就非常有必要用一種新的更好的方案來替代實現延遲佇列,這就是延時佇列外掛。

利用外掛實現延遲佇列

RabbitMQ3.5.7 版本之後,提供了一個外掛(rabbitmq-delayed-message-exchange)來實現延遲佇列 ,同時需保證 Erlang/OPT 版本為 18.0 之後。

安裝延遲佇列外掛

  1. RabbitMQ版本在 3.5.7-3.7.x 的可以執行以下命令進行下載(也可以直接通過瀏覽器下載):
wget https://bintray.com/rabbitmq/community-plugins/download_file?file_path=rabbitmq_delayed_message_exchange-0.0.1.ez

如果 RabbitMQ3.8 之後的版本,可以點選這裡,找到延遲佇列對應版本的外掛,然後下載。

  1. 下載好之後,將外掛上傳到 plugins 目錄下,執行 rabbitmq-plugins enable rabbitmq_delayed_message_exchange 命令啟動外掛。如果要禁止該外掛,則可以執行命令 rabbitmq-plugins disable rabbitmq_delayed_message_exchange(啟用外掛後需要重啟 RabbitMQ 才會生效)。

延遲佇列外掛示例

  1. 新建一個 PluginDelayRabbitConfig 配置類:
@Configuration
public class PluginDelayRabbitConfig {
    @Bean("pluginDelayExchange")
    public CustomExchange pluginDelayExchange() {
        Map<String, Object> argMap = new HashMap<>();
        argMap.put("x-delayed-type", "direct");//必須要配置這個型別,可以是direct,topic和fanout
        //第二個引數必須為x-delayed-message
        return new CustomExchange("PLUGIN_DELAY_EXCHANGE","x-delayed-message",false, false, argMap);
    }

    @Bean("pluginDelayQueue")
    public Queue pluginDelayQueue(){
        return new Queue("PLUGIN_DELAY_QUEUE");
    }

    @Bean
    public Binding pluginDelayBinding(@Qualifier("pluginDelayQueue") Queue queue,@Qualifier("pluginDelayExchange") CustomExchange customExchange){
        return BindingBuilder.bind(queue).to(customExchange).with("delay").noargs();
    }
}
  1. 新建一個消費者類 PluginDelayConsumer
@Component
public class PluginDelayConsumer {

    @RabbitHandler
    @RabbitListener(queues = "PLUGIN_DELAY_QUEUE")//監聽延時佇列
    public void fanoutConsumer(String msg){
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println("【外掛延遲佇列】【" + sdf.format(new Date()) + "】收到訊息:" + msg);
    }
}
  1. 在上面示例中的 DelayQueueController 類,新增一個方法:
@GetMapping(value="/plugin/send")
public String pluginMsgSend(@RequestParam(value = "msg",defaultValue = "no message") String msg){
    MessageProperties messageProperties = new MessageProperties();
    messageProperties.setHeader("x-delay",5000);//延遲5秒被刪除
    Message message = new Message(msg.getBytes(), messageProperties);
    amqpTemplate.convertAndSend("PLUGIN_DELAY_EXCHANGE","delay",message);//交換機和路由鍵必須和配置檔案類中保持一致
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    System.out.println("訊息傳送成功【" + sdf.format(new Date()) + "】");
    return "succ";
}
  1. 接下來就可以訪問地址 http://localhost:8080/delay/plugin/send?msg=外掛延遲佇列訊息 進行測試,可以看到,訊息在延時 5 秒之後被消費:

總結

延遲佇列的使用非常廣泛,如果是單機部署,可以考慮使用 jdk 自帶的 DelayQueue,分散式部署可以採用 RabbitMQRedis 等中介軟體來實現延遲佇列。本文主要介紹瞭如何利用 RabbitMQ 實現兩種延遲佇列的兩種方案,當然本文的例子只是引導,並沒有開啟回撥等訊息確認模式,如果想了解 RabbitMQ 訊息的可靠性傳輸的,可以點選這裡

相關文章