要做重試機制,就只能選擇 DelayQueue ?其實 RabbitMQ 它上它也行!

不送花的程式猿發表於2020-08-02

原文連結:要做重試機制,就只能選擇 DelayQueue ?其實 RabbitMQ 它上它也行!

一、場景

最近研發一個新功能,後臺天氣預警:後臺啟動一條執行緒,定時呼叫天氣預警 API,查詢現有城市的相關天氣預警資訊,如果發現有預警或取消預警的資訊,給指定預警部門配置的相關人員傳送簡訊;而如果第一次傳送失敗,我們需要隔幾分鐘再重新傳送,最多可以重發5次。

二、技術選型

1、JDK 原生 DelayQueue:

重試機制最簡單的就是直接利用 JDK 提供的 DelayQyeye,而 DelayQueue 裡面存放的任務需要是實現 Delay 介面的實現類,需要重寫 getDelay 方法和 compareTo 方法。getDelay 方法主要用做判斷任務是否到期要出佇列,而 compareTo 方法主要用做入隊時任務的判斷,過期時間短的任務應放在佇列的前面,通過這個方法,我們可以知道,DelayQueue 的底層是利用 PriorityQueue 實現的。

下面上一個 DelayQueue 的簡單的使用例子:

/**
 * @author Howinfun
 * @desc
 * @date 2020/8/1
 */
public class TestDelayQueue {

    public static void main(String[] args) throws InterruptedException {
        DelayQueue<UserMsg> delayQueue = new DelayQueue();
        UserMsg userMsg1 = new UserMsg(1,"15627272727","你好,下單成功1",5, TimeUnit.SECONDS);
        UserMsg userMsg2 = new UserMsg(2,"15627272727","你好,下單成功2",3, TimeUnit.SECONDS);
        UserMsg userMsg3 = new UserMsg(3,"15627272727","你好,下單成功3",4, TimeUnit.SECONDS);
        UserMsg userMsg4 = new UserMsg(4,"15627272727","你好,下單成功4",6, TimeUnit.SECONDS);
        UserMsg userMsg5 = new UserMsg(5,"15627272727","你好,下單成功5",2, TimeUnit.SECONDS);
        delayQueue.add(userMsg1);
        delayQueue.put(userMsg2);
        delayQueue.put(userMsg3);
        delayQueue.put(userMsg4);
        delayQueue.put(userMsg5);

        for (int i=0;i<5;i++){
            // take方法會一直阻塞,直到有任務
            UserMsg userMsg = delayQueue.take();
            System.out.println(userMsg.toString());
        }
    }
}
@Data
@ToString
public class UserMsg implements Delayed {

    private int id;
    private String phone;
    private String msg;
    private int failCount;
    // 過期時間
    private long time;

    public UserMsg(int id,String phone,String msg,long time,TimeUnit unit){
        this.id = id;
        this.phone = phone;
        this.msg = msg;
        this.time = System.currentTimeMillis() + (time > 0 ? unit.toMillis(time) : 0);
        this.failCount = 0;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        // 和當前時間比較,判斷是否到期
        return this.time - System.currentTimeMillis();
    }

    @Override
    public int compareTo(Delayed o) {
        // 入隊時需要判斷任務放到佇列的哪個位置,過期時間短的放在前面
        UserMsg item = (UserMsg) o;
        long diff = this.time - item.time;
        if (diff <= 0) {
            return -1;
        }else {
            return 1;
        }
    }
}

擴充套件點:PriorityQueue的優化點

講到 PriorityQueue,有一個優化點我是覺得挺有意思的:它的 take() 方法,是會一直阻塞直到有任務過期出佇列。它裡面主要是利用 for 死迴圈去讀取佇列的頭節點,判斷頭節點是否為空,如果為空,則直接呼叫 Condition#await() 進入阻塞狀態;而如果佇列的頭節點不為空,但是任務還未過期,則會判斷之前是否有執行緒(leader)嘗試獲取過期任務了,如果有的話就呼叫 Condition#await() 方法,否則就繼續在死迴圈裡面繼續嘗試獲取過期任務。這樣的話,避免所有嘗試獲取過期任務的執行緒一直在死迴圈,這樣能讓多餘的執行緒進入阻塞狀態,從而釋放系統資源。當然了,只要 leader 拿到過期任務了,那麼就會判斷佇列是否還有任務,如果有則呼叫 Condition#signal() 喚醒等待狀態的執行緒們。我們可以看看原始碼:

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            E first = q.peek();
            if (first == null)
                available.await();
            else {
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0)
                    return q.poll();
                first = null; // don't retain ref while waiting
                if (leader != null)
                    available.await();
                else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay);
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        lock.unlock();
    }
}

2、訊息中介軟體 RabbitMQ :

可能很多同學看到這個標題後會有點疑惑,訊息中介軟體還能做重發機制?其實一開始我都沒想到,關於 RabbitMQ 我也只是簡單地學了他的六大使用模式,其他的業務場景還沒深究。然後有一天和我的一個好同事聊了一下這個重試機制,他就說了 RabbitMQ 的死信佇列可以做到,然後我自己就去研究一下。

他的主要原理是利用訊息的 ttl + 死信佇列。當簡訊傳送失敗時,封裝一個訊息往指定的業務 queue 傳送,並且指定訊息的 ttl,當然了,還需要為業務 queue 指定死信佇列。當訊息過期後,會從業務 queue 轉到死信佇列中,所以說,我們只需要監聽死信佇列,拉取其中的訊息進行消費,這樣就能做到重試了。

這兩個怎麼選?

使用原生的 DelayQueue 會更加方便點,因為只需要自定義類實現 Delay 介面,啟動執行緒阻塞獲取過期任務即可。可是對於想監控這個 DelayQueue 裡面任務的情況,可能就要自己寫介面來獲取了,並且在微服務中,通常一個服務模組有多個例項,這樣子的話,統一管理還有監控就更麻煩了。

所以我們可以考慮使用 RabbitMQ 來完成。不但可以統一處理重試機制,並且 RabbitMQ 還提供了自己的後臺管理系統,這樣監控起來也很方便。

三、RabbitMQ 如何利用訊息 ttl 和死信佇列做重試機制

下面是基於 spring-boot-starter-amqp 做的。

1、宣告業務/死信佇列相關元件: Exchange、Routing Key、Queue

第一步還是比較簡單的,主要是建立對應的交換器、佇列和路由鍵,特別要注意的是,在建立業務佇列時,需要為他設定死信佇列的相關資訊,程式碼如下:

@Configuration
public class RabbitMQConfig {

    /**
     * 業務 queue
     */
    public static final String BUSINESS_QUEUE_NAME = "business.queue";
    /**
     * 業務 exchange
     */
    public static final String BUSINESS_EXCHANGE_NAME = "business.exchange";
    /**
     * 業務 routing key
     */
    public static final String BUSINESS_QUEUE_ROUTING_KEY = "business.routing.key";
    /**
     * 死信佇列 exchange
     */
    public static final String DEAD_LETTER_EXCHANGE_NAME = "dead.letter.exchange";
    /**
     * 死信佇列 queue
     */
    public static final String DEAD_LETTER_QUEUE_NAME = "dead.letter.queue";
    /**
     * 死信佇列 routing key
     */
    public static final String DEAD_LETTER_QUEUE_ROUTING_KEY = "dead.letter.routing.key";

    /**
     * 宣告業務交換器
     * @return
     */
    @Bean("businessExchange")
    public DirectExchange businessExchange(){
        return new DirectExchange(BUSINESS_EXCHANGE_NAME);
    }

    /**
     * 宣告業務佇列
     * @return
     */
    @Bean("businessQueue")
    public Queue businessQueue(){
        Map<String, Object> args = new HashMap<>(3);
        // 這裡宣告當前佇列繫結的死信交換機
        args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE_NAME);
        // 這裡宣告當前佇列的死信路由key
        args.put("x-dead-letter-routing-key", DEAD_LETTER_QUEUE_ROUTING_KEY);
        return QueueBuilder.durable(BUSINESS_QUEUE_NAME).withArguments(args).build();
    }

    /**
     * 宣告業務佇列繫結業務交換器,繫結路由鍵
     * @param queue
     * @param exchange
     * @return
     */
    @Bean
    public Binding businessBinding(@Qualifier("businessQueue") Queue queue,
                                   @Qualifier("businessExchange") DirectExchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(BUSINESS_QUEUE_ROUTING_KEY);
    }

    /**
     * 宣告死信交換器
     * @return
     */
    @Bean("deadLetterExchange")
    public DirectExchange deadLetterExchange(){
        return new DirectExchange(DEAD_LETTER_EXCHANGE_NAME);
    }

    /**
     * 宣告死信佇列
     */
    @Bean("deadLetterQueue")
    public Queue deadLetterQueue(){
        return QueueBuilder.durable(DEAD_LETTER_QUEUE_NAME).build();
    }

    /**
     * 宣告死信佇列繫結死信交換器,繫結路由鍵
     * @param queue
     * @param exchange
     * @return
     */
    @Bean
    public Binding deadLetterBinding(@Qualifier("deadLetterQueue") Queue queue,
                                   @Qualifier("deadLetterExchange") DirectExchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(DEAD_LETTER_QUEUE_ROUTING_KEY);
    }
}

2、模擬業務處理失敗,傳送需要重試的簡訊:

程式碼如下:

UserMsg userMsg1 = new UserMsg(1,"15627236666","你好,麻煩充值",1);
UserMsg userMsg2 = new UserMsg(2,"15627236667","你好,麻煩支付",1);
UserMsg userMsg3 = new UserMsg(3,"15627236668","你好,麻煩下單",1);
String msgJson1 = JSON.toJSONString(userMsg1);
String msgJson2 = JSON.toJSONString(userMsg2);
String msgJson3 = JSON.toJSONString(userMsg3);

userMsgMapper.insert(userMsg1);
userMsgMapper.insert(userMsg2);
userMsgMapper.insert(userMsg3);

MessagePostProcessor messagePostProcessor = message -> {
    // 如果配置了 params.put("x-message-ttl", 5 * 1000); 那麼這一句也可以省略,具體根據業務需要是宣告 Queue 的時候就指定好延遲時間還是在傳送自己控制時間
    message.getMessageProperties().setExpiration(1 * 1000 * 60 + "");
    return message;
};
// 往業務 Queue 傳送需要重試的簡訊
rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson1,messagePostProcessor);
rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson2,messagePostProcessor);
rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson3,messagePostProcessor);

我們可以看到,在傳送訊息時,會利用 MessagePostProcessor 來完成給訊息新增 ttl。

3、監聽死信佇列,消費訊息:

這是最重要的一步,我們需要監聽死信佇列,一旦有訊息,證明有任務需要重試了,我們只需要拉取下來然後消費即可。

這裡需要注意的有一個點:為了避免出現訊息丟失的情況,我們需要開啟手動 ack,然後配合 fetch = 1,保證客戶端每次只能拉取一個訊息,當客戶端消費完此訊息後,需要手動呼叫 channel#basicAck() 方法去確認此訊息已經被消費了。

下面是相關的配置:

# 開啟手動 ack
spring.rabbitmq.listener.simple.acknowledge-mode=manual
# 設定 false,訊息才能進入死信佇列
spring.rabbitmq.listener.simple.default-requeue-rejected=false
# 消費者每次只讀取一個訊息
spring.rabbitmq.listener.simple.prefetch=1

接著我們看看如何監聽死信佇列,先上程式碼:

//@RabbitListener(queues = {RabbitMQConfig.DEAD_LETTER_QUEUE_NAME})
@Component
public class DeadLetterQueueListener {

    @Resource
    private RabbitTemplate rabbitTemplate;
    @Resource
    private UserMsgMapper userMsgMapper;

    @RabbitListener(queues = {RabbitMQConfig.DEAD_LETTER_QUEUE_NAME})
    @RabbitHandler
    public void processHandler(String msg, Channel channel, Message message) throws IOException {

        try {
            UserMsg userMsg = JSON.parseObject(new String(message.getBody()), UserMsg.class);
            // 模擬傳送簡訊
            int num = new Random().nextInt(10);
            if (num >5){
                // 傳送成功
                // 更新資料庫記錄
                System.out.println("訊息【" + userMsg.getId() + "】傳送成功,失敗次數:" + userMsg.getFailCount());
                userMsgMapper.update(userMsg);
            }else {
                // 重新發到業務佇列中
                int failCount = userMsg.getFailCount()+1;
                if (failCount > 5){
                    System.out.println("訊息【"+ userMsg.getId() +"】傳送次數已到上線");
                    userMsgMapper.update(userMsg);
                }else {
                    userMsg.setFailCount(failCount);
                    String msgJson = JSON.toJSONString(userMsg);
                    System.out.println("訊息【"+ userMsg.getId() +"】傳送失敗,失敗次數為:"+ userMsg.getFailCount());
                    userMsgMapper.update(userMsg);
                    MessagePostProcessor messagePostProcessor = message2 -> {
                        // 如果配置了 params.put("x-message-ttl", 5 * 1000); 那麼這一句也可以省略,具體根據業務需要是宣告 Queue 的時候就指定好延遲時間還是在傳送自己控制時間
                        message2.getMessageProperties().setExpiration(1 * 1000 * 60 + "");
                        return message2;
                    };
                    rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson,messagePostProcessor);
                }
            }
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            System.err.println("訊息即將再次返回佇列處理...");
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
        }
    }
}

其實非常簡單,首先在 @RabbitListener 註解中加上自己需要監聽的死信佇列,我們可以發現這個註解可載入類上,也可以載入處理訊息的方法上;當然了,還需要在消費訊息的方法上加上註解 @RabbitHandler。

在消費訊息的邏輯中,如果是業務處理成功了,也就是重試成功了,此時不需做其他操作;而如果重試失敗了,需要重新傳送一個訊息到業務 Queue,表示又要重試一次。最後,我們需要呼叫 channel#basicAck() 表示訊息消費成功~

在給業務 Queue 傳送訊息之前,我們記得給訊息設定一下過期時間,還是利用 MessagePostProcessor 來完成。

四、最後

到此基本就結束了。

但是我們還有一個點要注意:就是如果我們使用 RabbitMQ 來做重試機制,我們一定要保證 RabbitMQ 的高可用,這時候我們一般推薦使用映象叢集模式,而不是普通叢集模式。因為普通叢集模式中,每個例項都只是儲存其他例項中 queue 的後設資料,只要一個例項當機的,它所負責的 queue 都不能再被使用了。而映象叢集模式中,每個例項都會儲存所有 queue ,這樣能保證資料 100% 的不丟失!當然了,如果不追求高併發,使用主備模式也還是可以滴~

大家如果對上面的例子還感興趣,可到我的 github 看看:死信佇列完成重試機制

相關文章