RabbitMQ 學習筆記 -- 12 死信佇列 DLX + TTL 方式實現延遲佇列

yorsola發表於2020-09-27

死信佇列 DLX + TTL 方式實現延遲佇列

延遲佇列與死信佇列時息息相關的,它具有特點:

  1. 佇列,意味著內部的元素是有序的,元素的出隊和入隊是有方向性的,元素從一端進入,從另一端取出

  2. 延時,這是最重要的特性,普通佇列中的元素總是等著希望被早點取出處理,而延時佇列中的元素則是希望等待特定時間後,消費者才能拿到這個訊息進行消費。

TTL

TTL 是 RabbitMQ 中一個訊息或者佇列的屬性,表明一條訊息或者該佇列中的所有訊息的最大存活時間,單位是毫秒。換句話說,如果一條訊息設定了 TTL 屬性或者進入了設定 TTL 屬性的佇列,那麼這條訊息如果在 TTL 設定的時間內沒有被消費,則會成為“死信”。如果同時配置了佇列的 TTL 和訊息的 TTL,那麼較小的那個值將會被使用。

也就是說我們可以利用這個機制,讓訊息過期後,變成死信,就又交給我們的死信交換機來處理了~

延時佇列可以解決很多特定場景下,帶時間屬性的任務需求,如:訂單建立半小時內未支付進行取消訂單。。。

有兩種方式設定 TTL 值,

  1. 第一種是在建立佇列的時候設定佇列的 “x-message-ttl” 屬性

    @Bean
    public Queue delayQueue() {
        String queueName = "delay_queue";
        Map<String, Object> args = new HashMap<>(1);
        args.put("x-message-ttl", "6000");
        return new Queue(queueName, true, false, false, args);
    }
    
  2. 另一種方式是針對每條訊息設定 TTL

    rabbitTemplate.convertAndSend(exchange, routingKey, (message) -> {
     message.getMessageProperties().setExpiration("6000");
     return message;
    });
    

區別:

  • 設定了佇列的 TTL 屬性,那麼一旦訊息過期,就會被佇列丟棄

  • 給訊息設定 TTL 屬性,訊息過期也不一定會馬上丟棄,因為訊息是否過期是在即將投遞到消費者之前判定的,如果佇列存在訊息積壓問題,那麼已過期的訊息可能還會存活較長些時間

死信佇列 + 訊息TTL = 延遲佇列

一.設定佇列 TTL 屬性來實現延遲佇列

訊息大致流向

delayMQ

delayMQ

延遲佇列 (delay_queu_A) 設定 TTL 能讓資訊在延遲多久後成為死信,成為死信後的訊息都會被投遞到死信佇列中,這樣只需要消費者一直消費死信佇列(dlx_queue_A) 裡就好了,因為裡面的訊息都是希望被處理的延遲後的訊息。

宣告交換機、佇列以及他們的關係:

// 配置延遲佇列
@Bean
public TopicExchange delayExchange() {
    String exchangeName = "delay_exchange";
    return new TopicExchange(exchangeName);
}
@Bean
public Queue delayQueueA() {
    String queueName = "delay_queue_A";
    // 設定死信傳送至 dlx_exchange 交換機,設定路由鍵為 bind.dlx.A
    String dlxExchangeName = "dlx_exchange";
    String bindDlxRoutingKeyA = "bind.dlx.A";

    Map<String, Object> args = new HashMap<>(3);
    // 設定佇列的延遲屬性,6秒
    args.put("x-message-ttl", 6000);
    args.put("x-dead-letter-exchange", dlxExchangeName);
    args.put("x-dead-letter-routing-key", bindDlxRoutingKeyA);
    return new Queue(queueName, true, false, false, args);
}
@Bean
public Binding bindingDelayExchange() {
    String routingKey = "bind.delay.A";
    return BindingBuilder.bind(delayQueueA()).to(delayExchange()).with(routingKey);
}

// 配置死信佇列
@Bean
public TopicExchange dlxExchange() {
    String exchangeName = "dlx_exchange";
    return new TopicExchange(exchangeName);
}
@Bean
public Queue dlxQueueA() {
    String queueName = "dlx_queue_A";
    return new Queue(queueName);
}
@Bean
public Binding bindingDlxExchange() {
    String routingKey = "#.A";
    return BindingBuilder.bind(dlxQueueA()).to(dlxExchange()).with(routingKey);
}

yml

spring:
  rabbitmq:
    host: 192.168.159.129
    port: 5672
    username: admin
    password: admin
    # 虛擬host 可以不設定,使用 server 預設 host
    virtual-host:
    listener:
      simple:
        default-requeue-rejected:
        acknowledge-mode: manual

消費者

@RabbitListener(queues = "dlx_queue_A")
public void receiver2(@Payload String msg, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {
 System.out.println("當前時間:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + "死信佇列 - dlx_queue_A 收到訊息:" + msg);
 channel.basicAck(deliveryTag, false);
}

生產者

@Test
public void demo_10_Producer() {
    String exchange = "delay_exchange_A";
    String routingKey = "delay.routing.key.A";
    String msg = "傳送給延遲佇列 delay_queue_A 的訊息";
    System.out.println("當前時間:" + simpleDateFormat.format(new Date()) + "開始傳送訊息:" + msg);
    rabbitTemplate.convertAndSend(exchange, routingKey, msg);
}

輸出

當前時間:2020-09-27 21:10:20 開始傳送訊息:傳送給延遲佇列 delay_queue_A 的訊息

當前時間:2020-09-27 21:10:26 死信佇列 - dlx_queue_A 收到訊息:傳送給延遲佇列 delay_queue_A 的訊息

缺陷

如果這樣使用的話,每增加一個新的時間需求,就要新增一個佇列。如需要一個小時後處理,那麼就需要增加 TTL 為一個小時的佇列,如果此時訊息的過期時間不確定或者訊息過期時間維度過多,在消費端我們就要去監聽多個訊息佇列,豈不是要增加無數個佇列才能滿足需求??

二.設定訊息的 TTL 屬性來實現延遲佇列

設計成一個通用的延時佇列,我們可以給不同的訊息設定不同的 TTL 過期時間,以達到動態設定延遲時間。

宣告交換機、佇列以及它們的關係:

// 配置延遲佇列
@Bean
public TopicExchange delayExchange() {
    String exchangeName = "delay_exchange";
    return new TopicExchange(exchangeName);
}
@Bean
public Queue delayQueueB() {
    String queueName = "delay_queue_B";
    // 設定死信傳送至 dlx_exchange 交換機,設定路由鍵為 bind_dlx_B
    String dlxExchangeName = "dlx_exchange";
    String bindDlxRoutingKeyB = "bind.dlx.B";

    Map<String, Object> args = new HashMap<>(2);
    args.put("x-dead-letter-exchange", dlxExchangeName);
    args.put("x-dead-letter-routing-key", bindDlxRoutingKeyB);
    return new Queue(queueName, true, false, false, args);
}
@Bean
public Binding bindingDelayExchange() {
    String routingKey = "bind.delay.B";
    return BindingBuilder.bind(delayQueueB()).to(delayExchange()).with(routingKey);
}

// 配置死信佇列
@Bean
public TopicExchange dlxExchange() {
    String exchangeName = "dlx_exchange";
    return new TopicExchange(exchangeName);
}
@Bean
public Queue dlxQueueB() {
    String queueName = "dlx_queue_B";
    return new Queue(queueName);
}
@Bean
public Binding bindingDlxExchange() {
    String routingKey = "#.B";
    return BindingBuilder.bind(dlxQueueB()).to(dlxExchange()).with(routingKey);
}

消費者

@RabbitListener(queues = "dlx_queue_B")
public void receiverB(@Payload String msg, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {
    System.out.println("當前時間:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " 死信佇列 - dlx_queue_B 收到訊息:" + msg);
    channel.basicAck(deliveryTag, false);
}

生產者

@Test
public void producer_B() throws Exception {
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    String exchange = "delay_exchange";
    String routingKey = "bind.delay.B";
    String msg = "我是第一條訊息";
    // 延遲時間
    String delayTime = "6000";

    System.out.println("當前時間:" + simpleDateFormat.format(new Date()) + "開始傳送訊息:" + msg + "  延遲的時間為:" + delayTime);
    rabbitTemplate.convertAndSend(exchange, routingKey, msg, new MyMessagePostProcessor(delayTime));

    Thread.sleep(30000L);
}

MyMessagePostProcessor

/**
 * 因為要給訊息設定 TTL,這裡建立了一個 MessagePostProcessor 的例項來設定過期時間
 */
class MyMessagePostProcessor implements MessagePostProcessor {
    // 延遲時間 毫秒
    private String delayTime;

    MyMessagePostProcessor(String delayTime) {
        this.delayTime = delayTime;
    }

    @Override
    public Message postProcessMessage(Message message) throws AmqpException {
        // 設定延遲時間
        message.getMessageProperties().setExpiration(delayTime);
        return message;
    }
}

這裡的 convertAndSend 使用的第四引數為 MessagePostProcessor,我這裡採用建構函式的方式來動態設定訊息的過期時間。

效果

當前時間:2020-09-27 21:20:20 開始傳送訊息:我是第一條訊息  延遲的時間為:6000

當前時間:2020-09-27 21:20:26 死信佇列 - dlx_queue_B 收到訊息:我是第一條訊息

缺陷

但是上面我們也提到了訊息過期也不一定會馬上丟棄。訊息到了過期時間可能並不會按時“死亡“,因為 RabbitMQ 只會檢查第一個訊息是否過期,如果過期則丟到死信佇列,索引如果第一個訊息的延時時長很長,而第二個訊息的延時時長很短,則第二個訊息並不會優先得到執行。

例子:

生產者

@Test
public void producer_B() throws Exception {
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    String exchange = "delay_exchange";
    String routingKey = "bind.delay.B";
    String msg = "我是第一條訊息";
    // 延遲時間
    String delayTime = "6000";

    System.out.println("當前時間:" + simpleDateFormat.format(new Date()) + "開始傳送訊息:" + msg + "  延遲的時間為:" + delayTime);
    rabbitTemplate.convertAndSend(exchange, routingKey, msg, new MyMessagePostProcessor(delayTime));

    msg = "我是第二條訊息";
    // 修改延遲時間
    delayTime = "3000";

    System.out.println("當前時間:" + simpleDateFormat.format(new Date()) + "開始傳送訊息:" + msg + "  延遲的時間為:" + delayTime);
    rabbitTemplate.convertAndSend(exchange, routingKey, msg, new MyMessagePostProcessor(delayTime));

    Thread.sleep(30000L);
}

效果

當前時間:2020-09-27 21:23:20 開始傳送訊息:我是第一條訊息  延遲的時間為:6000
當前時間:2020-09-27 21:23:20 開始傳送訊息:我是第二條訊息  延遲的時間為:3000
    
當前時間:2020-09-27 21:23:26 死信佇列 - dlx_queue_B 收到訊息:我是第一條訊息
當前時間:2020-09-27 21:23:26 死信佇列 - dlx_queue_B 收到訊息:我是第二條訊息

可以看到但延遲最久的第一條資訊消費後,緊跟其後的已經過期了的第二條訊息也接著消費了

總結

延時佇列在需要延時處理的場景下非常有用,使用 RabbitMQ 來實現延時佇列可以很好的利用 RabbitMQ 的特性,如:訊息可靠傳送、訊息可靠投遞、死信佇列來保障訊息至少被消費一次以及未被正確處理的訊息不會被丟棄。另外,通過RabbitMQ叢集的特性,可以很好的解決單點故障問題,不會因為單個節點掛掉導致延時佇列不可用或者訊息丟失。

死信佇列 DLX + TTL 的方式來實現延遲佇列,這也是一種通用的做法。

不管哪種方式都有各自的優缺點,根據業務情況來考慮。如果要實現在訊息粒度上新增TTL,並使其在設定的TTL時間及時死亡,可以使用 RabbitMQ 的 rabbitmq_delayed_message_exchange外掛的方式實現。

相關文章