延時消息在項目中的應用(二:消息實現與複雜度優化方案)

延時消息在項目中的應用(二:消息實現)

參考:延時消息在項目中的應用(一:方案選擇)https://www.cnblogs.com/yizhiamumu/p/16736527.html

基本概念:延遲消息是指生產者發送消息發送消息後,不能立刻被消費者消費,需要等待指定的時間後纔可以被消費。

場景案例:

邀請用戶參加民主測評,需要在指定時間內(例如2h)完成測評,在特定情況未完成前發送消息通知。或用戶下了一個訂單之後,需要在指定時間內(例如30分鐘)進行支付,在到期之前可以發送一個消息提醒用戶進行支付。

一些消息中間件的Broker端內置了延遲消息支持的能力,如:

  • NSQ:這是一個go語言的消息中間件,其通過內存中的優先級隊列來保存延遲消息,支持秒級精度,最多2個小時延遲。Java中也有對應的實現,如ScheduledThreadPoolExecutor內部實際上也是使用了優先級隊列。
  • QMQ:採用雙重時間輪實現。
  • RabbitMQ:需要安裝一個rabbitmq_delayed_message_exchange插件。
  • RocketMQ:RocketMQ 開源版本延遲消息臨時存儲在一個內部主題中,不支持任意時間精度,支持特定的 level,預設值的延遲時間間隔爲:1s、 5s、 10s、 30s、 1m、 2m、 3m、 4m、 5m、 6m、 7m、 8m、 9m、 10m、 20m、 30m、 1h、 2h等。

Broker端內置延遲消息處理能力,核心實現思路是:將延遲消息通過一個臨時存儲進行暫存,到期後才投遞到目標Topic中。如下圖所示:
image

步驟說明如下:

  1. producer將一個延遲消息發送到某個Topic中
  2. Broker判斷這是一個延遲消息後,將其通過臨時存儲進行暫存。
  3. Broker內部通過一個延遲服務(delay service)檢查消息是否到期,將到期的消息投遞到目標Topic中。
  4. 消費者消費目標topic中的延遲投遞的消息

臨時存儲模塊和延遲服務模塊,是延遲消息實現的關鍵。

上圖中,臨時存儲和延遲服務都是在Broker內部實現,對業務透明。

 

有一些消息中間件原生並不支持延遲消息,如Kafka,可以選擇對Kafka進行改造,但是成本較大。

另外一種方式是使用第三方臨時存儲,並加一層代理。

第三方存儲選型要求:
對於第三方臨時存儲,其需要滿足以下幾個特點:

  • 高性能:寫入延遲要低,MQ的一個重要作用是削峯填谷,在選擇臨時存儲時,寫入性能必須要高,關係型數據庫(如Mysql)通常不滿足需求。
  • 高可靠:延遲消息寫入後,不能丟失,需要進行持久化,並進行備份
  • 支持排序:支持按照某個字段對消息進行排序,對於延遲消息需要按照時間進行排序。普通消息通常先發送的會被先消費,延遲消息與普通消息不同,需要進行排序。例如先發一條延遲10s的消息,再發一條延遲5s的消息,那麼後發送的消息需要被先消費。
  • 支持長時間保存:一些業務的延遲消息,需要延遲幾個月,所以延遲消息必須能長時間保留。不過通常不建議延遲太長時間,存儲成本比較大,且業務邏輯可能已經發生變化,已經不需要消費這些消息。

例如,滴滴開源的消息中間件DDMQ,底層消息中間件的基礎上加了一層代理,獨立部署延遲服務模塊,使用rocksdb進行臨時存儲。rocksdb是一個高性能的KV存儲,並支持排序。

此時對於延遲消息的流轉如下圖所示:
image

說明如下:

  1. 生產者將發送給producer proxy,proxy判斷是延遲消息,將其投遞到一個緩衝Topic中;
  2. delay service啓動消費者,用於從緩衝topic中消費延遲消息,以時間爲key,存儲到rocksdb中;
  3. delay service判斷消息到期後,將其投遞到目標Topic中。
  4. 消費者消費目標topic中的數據

這種方式的好處是,因爲delay service的延遲投遞能力是獨立於broker實現的,不需要對broker做任何改造,對於任意MQ類型都可以提供支持延遲消息的能力。例如DDMQ對RocketMQ、Kafka都提供了秒級精度的延遲消息投遞能力,但是Kafka本身並不支持延遲消息,而開源版本的 RocketMQ 只支持幾個指定的延遲級別,並不支持秒級精度的定時消息。

事實上,DDMQ還提供了很多其他功能,僅僅從延遲消息的角度,完全沒有必要使用這個proxy,直接將消息投遞到緩衝Topic中,之後通過delay service完成延遲投遞邏輯即可。

具體到delay service模塊的實現上,也有一些重要的細節:

  1. 爲了保證服務的高可用,delay service也是需要部署多個節點。
  2. 爲了保證數據不丟失,每個delay service節點都需要消費緩衝Topic中的全量數據,保存到各自的持久化存儲中,這樣就有了多個備份,並需要以時間爲key。不過因爲是各自拉取,並不能保證強一致。如果一定要強一致,那麼delay service就不需要內置存儲實現,可以藉助於其他支持強一致的存儲。
  3. 爲了避免重複投遞,delay service需要進行選主,可以藉助於zookeeper、etcd等實現。只有master可以通過生產者投遞到目標Topic中,其他節點處於備用狀態。否則,如果每個節點進行都投遞,那麼延遲消息就會被投遞多次,造成消費重複。
  4. master要記錄自己當前投遞到的時間到一個共享存儲中,如果master掛了,從slave節點中選出一個新的master節點,從之前記錄時間繼續開始投遞。
  5. 延遲消息的取消:一些延遲消息在未到期之前,可能希望進行取消。通常取消邏輯實現較爲複雜,且不夠精確。對於那些已經快要到期的消息,可能還未取消之前,已經發送出去了,因此需要在消費者端做檢查,才能萬無一失。
延時隊列出現
  • 能夠在指定時間間隔後觸發某個業務操作

  • 能夠應對業務數據量特別大的特殊場景

 

五:RabbitMQ 實現延時隊列

RabbitMQ具有以下兩個特性,可以實現延遲隊列

  • RabbitMQ可以針對Queue和Message設置 x-message-tt,來控制消息的生存時間,如果超時,則消息變爲dead letter
  • lRabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可選)兩個參數,用來控制隊列內出現了deadletter,則按照這兩個參數重新路由。結合以上兩個特性,就可以模擬出延遲消息的功能。

實際上RabbitMQ自身並沒有直接支持提供延遲隊列功能,而是通過 RabbitMQ 消息隊列的 TTL和 DXL這兩個屬性間接實現的。

先來認識一下 TTL和 DXL兩個概念:

Time To Live(TTL):指的是消息的存活時間,RabbitMQ可以通過x-message-tt參數來設置指定Queue(隊列)和 Message(消息)上消息的存活時間,它的值是一個非負整數,單位爲微秒。

RabbitMQ 可以從兩種維度設置消息過期時間,分別是隊列和消息本身:

  • 設置隊列過期時間,那麼隊列中所有消息都具有相同的過期時間。
  • 設置消息過期時間,對隊列中的某一條消息設置過期時間,每條消息TTL都可以不同。


如果同時設置隊列和隊列中消息的TTL,則TTL值以兩者中較小的值爲準。而隊列中的消息存在隊列中的時間,一旦超過TTL過期時間則成爲Dead Letter(死信)。

Dead Letter Exchanges(DLX):即死信交換機,綁定在死信交換機上的即死信隊列。RabbitMQ的Queue(隊列)可以配置兩個參數x-dead-letter-exchange和x-dead-letter-routing-key(可選),一旦隊列內出現了Dead Letter(死信),則按照這兩個參數可以將消息重新路由到另一個Exchange(交換機),讓消息重新被消費。

x-dead-letter-exchange:隊列中出現Dead Letter後將Dead Letter重新路由轉發到指定 exchange(交換機)。

x-dead-letter-routing-key:指定routing-key發送,一般爲要指定轉發的隊列。

隊列出現Dead Letter的情況有:

  • 消息或者隊列的TTL過期
  • 隊列達到最大長度
  • 消息被消費端拒絕(basic.reject or basic.nack)


下邊結合一張圖看看,我們將訂單消息A0001發送到延遲隊列order.delay.queue,並設置x-message-tt消息存活時間爲30分鐘,當到達30分鐘後訂單消息A0001成爲了Dead Letter(死信),延遲隊列檢測到有死信,通過配置x-dead-letter-exchange,將死信重新轉發到能正常消費的關單隊列,直接監聽關單隊列處理關單邏輯即可。

3.png


發送消息時指定消息延遲的時間。

public void send(String delayTimes) {
    amqpTemplate.convertAndSend("order.pay.exchange", "order.pay.queue","======延遲數據======", message -> {
        // 設置延遲毫秒值
        message.getMessageProperties().setExpiration(String.valueOf(delayTimes));
        return message;
    });
}
}


設置延遲隊列出現死信後的轉發規則。

/**
 * 延時隊列
 */
@Bean(name = "order.delay.queue")
public Queue getMessageQueue() {
    return QueueBuilder
            .durable(RabbitConstant.DEAD_LETTER_QUEUE)
            // 配置到期後轉發的交換
            .withArgument("x-dead-letter-exchange", "order.close.exchange")
            // 配置到期後轉發的路由鍵
            .withArgument("x-dead-letter-routing-key", "order.close.queue")
            .build();
} 

 

六:RocketMQ中的延遲消息

開源RocketMQ支持延遲消息,默認支持18個level的延遲消息,這是通過broker端的messageDelayLevel配置項確定的,但是不支持秒級精度。如下:

messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

Broker在啓動時,內部會創建一個內部主題:SCHEDULE_TOPIC_XXXX,根據延遲level的個數,創建對應數量的隊列,也就是說18個level對應了18個隊列。

注意,這並不是說這個內部主題只會有18個隊列,因爲Broker通常是集羣模式部署的,因此每個節點都有18個隊列。

延遲級別的值可以進行修改,以滿足自己的業務需求,可以修改/添加新的level。

例如:你想支持7天的延遲,修改最後一個level的值爲7d,這個時候依然是18個level;也可以增加一個7d,這個時候總共就有19個level。

生產者發送延遲消息

生產者在發送延遲消息只需要設置一個延遲級別即可,注意不是具體的延遲時間,如:

Message msg=new Message();
msg.setTopic("TopicA");
msg.setTags("Tag");
msg.setBody("this is a delay message".getBytes());
//設置延遲level爲5,對應延遲1分鐘
msg.setDelayTimeLevel(5);
producer.send(msg);

如果設置的延遲level超過最大值,那麼將會重置最最大值。

Broker端存儲延遲消息

延遲消息在RocketMQ Broker端的流轉如下圖所示:
image

可以看到,總共有6個步驟,下面會對這6個步驟進行詳細的講解:

  1. 修改消息Topic名稱和隊列信息
  2. 轉發消息到延遲主題的CosumeQueue中
  3. 延遲服務消費SCHEDULE_TOPIC_XXXX消息
  4. 將信息重新存儲到CommitLog中
  5. 將消息投遞到目標Topic中
  6. 消費者消費目標topic中的數據

1:修改消息Topic名稱和隊列信息

RocketMQ Broker端在存儲生產者寫入的消息時,首先都會將其寫入到CommitLog中。之後根據消息中的Topic信息和隊列信息,將其轉發到目標Topic的指定隊列(ConsumeQueue)中。

由於消息一旦存儲到ConsumeQueue中,消費者就能消費到,而延遲消息不能被立即消費,所以這裏將Topic的名稱修改爲SCHEDULE_TOPIC_XXXX,並根據延遲級別確定要投遞到哪個隊列下。

同時,還會將消息原來要發送到的目標Topic和隊列信息存儲到消息的屬性中。相關源碼如下所示:
org.apache.rocketmq.store.CommitLog#asyncPutMessage

public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {

//如果是延遲消息
            if (msg.getDelayTimeLevel() > 0) {
                //如果設置的級別超過了最大級別 重置延遲級別
                if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
                    msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
                }

                //修改TOPIC的投遞目標爲內部主題SCHEDULE_TOPIC_XXX
                topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
                //根據delayLevel 確定消息投遞到SCHEDULE_TOPIC_XXX內部的哪個隊列中
                queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

                // Backup real topic, queueId
                //記錄原始topic、queueId
                MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
                MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
                msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

                //更新消息的topic、queueId
                msg.setTopic(topic);
                msg.setQueueId(queueId);
            }

}

 

2:轉發消息到延遲主題的CosumeQueue中

CommitLog中的消息轉發到CosumeQueue中是異步進行的。在轉發過程中,會對延遲消息進行特殊處理,主要是計算這條延遲消息需要在什麼時候進行投遞。

投遞時間=消息存儲時間(storeTimestamp) + 延遲級別對應的時間

需要注意的是,會將計算出的投遞時間當做消息Tag的哈希值存儲到CosumeQueue中,CosumeQueue單個存儲單元組成結構如下圖所示:
image

其中:

  • Commit Log Offset:記錄在CommitLog中的位置。
  • Size:記錄消息的大小
  • Message Tag HashCode:記錄消息Tag的哈希值,用於消息過濾。特別的,對於延遲消息,這個字段記錄的是消息的投遞時間戳。這也是爲什麼java中hashCode方法返回一個int型,只佔用4個字節,而這裏Message Tag HashCode字段卻設計成8個字節的原因。

相關源碼參見:CommitLog#checkMessageAndReturnSize

public DispatchRequest checkMessageAndReturnSize(java.nio.ByteBuffer byteBuffer, final boolean checkCRC,
        final boolean readBody) {

		// Timing message processing
                {
                    //如果消息需要投遞到延遲主題SCHEDULE_TOPIC_XXX中
                    String t = propertiesMap.get(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
                    if (TopicValidator.RMQ_SYS_SCHEDULE_TOPIC.equals(topic) && t != null) {
                        int delayLevel = Integer.parseInt(t);

                        if (delayLevel > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
                            delayLevel = this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel();
                        }

                        //如果延遲級別大於0 計算目標投遞時間 並將其當作tag哈希值
                        if (delayLevel > 0) {
                            tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
                                storeTimestamp);
                        }
                    }
                }

}

 

3:延遲服務消費SCHEDULE_TOPIC_XXXX消息

Broker內部有一個ScheduleMessageService類,其充當延遲服務,消費SCHEDULE_TOPIC_XXXX中的消息,並投遞到目標Topic中。

ScheduleMessageService在啓動時,其會創建一個定時器Timer,並根據延遲級別的個數,啓動對應數量的TimerTask,每個TimerTask負責一個延遲級別的消費與投遞。

相關源碼如下所示:ScheduleMessageService#start

public void start() {
        if (started.compareAndSet(false, true)) {
            super.load();
            //1.創建定時器Timer
            this.timer = new Timer("ScheduleMessageTimerThread", true);
            //2.針對每個延遲級別 創建一個 TimerTask
            //2.1: 迭代每個延遲級別,delayLevelTable是一個Map 記錄了每個延遲級別對應的延遲時間
            for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
                Integer level = entry.getKey();
                Long timeDelay = entry.getValue();
                Long offset = this.offsetTable.get(level);
                if (null == offset) {
                    offset = 0L;
                }
                //2.2 針對每個延遲級別 創建一個 TimerTask
                if (timeDelay != null) {
                    this.timer.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME);
                }
            }
}

 

需要注意的是,每個TimeTask在檢查消息是否到期時,首先檢查對應隊列中尚未投遞第一條消息,如果這條消息沒到期,那麼之後的消息都不會檢查。如果到期了,則進行投遞,並檢查之後的消息是否到期。

4:將信息重新存儲到CommitLog中

在將消息到期後,需要投遞到目標Topic。由於在第一步已經記錄了原來的Topic和隊列信息,因此這裏重新設置,再存儲到CommitLog即可。此外,由於之前Message Tag HashCode字段存儲的是消息的投遞時間,這裏需要重新計算tag的哈希值後再存儲。

源碼參見:DeliverDelayedMessageTimerTask的messageTimeup方法。

5:將消息投遞到目標Topic中

這一步與第二步類似,不過由於消息的Topic名稱已經改爲了目標Topic。因此消息會直接投遞到目標Topic的ConsumeQueue中,之後消費者即消費到這條消息。

 

延遲消息與消費重試的關係

RocketMQ提供了消息重試的能力,在併發模式消費消費失敗的情況下,可以返回一個枚舉值RECONSUME_LATER,那麼消息之後將會進行重試。如:

consumer.registerMessageListener(new MessageListenerConcurrently() {
       @Override
       public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
                                       ConsumeConcurrentlyContext context) {
           //處理消息,失敗,返回RECONSUME_LATER,進行重試
           return ConsumeConcurrentlyStatus.RECONSUME_LATER;
       }
   });

 

重試默認會進行重試16次。使用過RocketMQ消息重試功能的用戶,可能看到過以下這張圖:

第幾次重試 與上次重試的間隔時間 第幾次重試 與上次重試的間隔時間
1 10 秒 9 7 分鐘
2 30 秒 10 8 分鐘
3 1 分鐘 11 9 分鐘
4 2 分鐘 12 10 分鐘
5 3 分鐘 13 20 分鐘
6 4 分鐘 14 30 分鐘
7 5 分鐘 15 1 小時
8 6 分鐘 16 2 小時

這裏消息重試的16個級別,實際上是把延遲消息18個級別的前兩個level去掉了。

事實上,RocketMQ的消息重試也是基於延遲消息來完成的。在消息消費失敗的情況下,將其重新當做延遲消息投遞迴Broker。

在投遞回去時,會跳過前兩個level,因此只重試16次。當然,消息重試還有一些其他的設計邏輯。

自定義延遲時間

開源版本延遲消息缺點:固定了Level,不夠靈活,最多隻能支持18個Level

Java中的延遲任務

public static void main(String[] args) {
        Timer timer = new Timer();
        //在3秒後執行run方法,之後每隔1秒執行一次run方法
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("======執行任務");
            }
        }, 3000);
    }

ScheduledThreadPoolExecutor

public static void main(String[] args) {
        ScheduledExecutorService scheduledExecutorService
                = Executors.newSingleThreadScheduledExecutor();
        scheduledExecutorService.schedule(
                () -> System.out.println("======執行任務"), 3000,
                TimeUnit.MILLISECONDS);
    }

其原理都是基於最小堆實現的延遲隊列DelayQueue

插入任務的時間複雜度爲Olog(n),消息TPS較高時性能仍不夠快,有沒O(1)複雜度的方案呢?

 

七:時間輪算法


Netty、Kafka中使用TimeWheel來優化I/O超時的操作,先來看一張時間輪的原理圖,解讀一下時間輪的幾個基本概念

4.png


wheel:時間輪,圖中的圓盤可以看作是鐘錶的刻度。比如一圈round長度爲24秒,刻度數爲8,那麼每一個刻度表示3秒。那麼時間精度就是3秒。時間長度/刻度數值越大,精度越大。

當添加一個定時、延時任務A,假如會延遲25秒後纔會執行,可時間輪一圈round的長度才24秒,那麼此時會根據時間輪長度和刻度得到一個圈數round和對應的指針位置index,也是就任務A會繞一圈指向0格子上,此時時間輪會記錄該任務的round和index信息。當round=0,index=0,指針指向0格子,任務A並不會執行,因爲round=0不滿足要求。

所以每一個格子代表的是一些時間,比如1秒和25秒都會指向0格子上,而任務則放在每個格子對應的鏈表中,這點和HashMap的數據有些類似。

Netty構建延時隊列主要用HashedWheelTimer,HashedWheelTimer底層數據結構依然是使用DelayedQueue,只是採用時間輪的算法來實現。

 

ticksPerWheel:槽位數
tick:每個槽位的時間間隔

假設這個延遲時間爲X秒,那麼X%(ticksPerWheel * tick)可以計算出X所屬的TimeWheel中位置

TimeWheel的size爲8,那麼延遲1秒和9秒的消息都處在一個鏈表中。如果用戶先發了延遲9秒的消息再發了延遲1秒的消息,他們在一個鏈表中所以延遲1秒的消息會需要等待延遲9秒的消息先投遞。

那麼如何解決這個問題?

  • 排序
    顯然,如果對TimeWheel一個tick中的元素進行排序顯然就解決了上面的問題。但是顯而易見的是排序是不可能的。

  • 擴大時間輪
    最直觀的方式,我們能不能通過擴大時間輪的方式避免延遲9和延遲1落到一個tick位置上?
    假設支持30天,精度爲1秒,那麼ticksPerWheel=30 * 24 * 60 * 60,這樣每一個tick上的延遲都是一致的,不存在上述的問題(類似於將RocketMQ的Level提升到了30 * 24 * 60 * 60個)。但是TimeWheel需要被加載到內存操作,這顯然是無法接受的。

  • 多級時間輪
    單個TimeWheel無法支持,那麼能否顯示中的時針、分針的形式,構建多級時間輪來解決呢?
    image

多級時間輪解決了上述的問題,但是又引入了新的問題:

在整點(tick指向0的位置)需要加載大量的數據會導致延遲,比如第二個時間輪到整點需要加載未來一天的數據時間輪需要載入到內存,這個開銷是不可接受的

  • 延遲加載
    多級定時輪的問題在於需要加載大量數據到內存,那麼能否優化一下將這裏的數據延遲加載到內存來解決內存開銷的問題呢?
    在多級定時輪的方案中,顯然對於未來一小時或者未來一天的數據可以不加載到內存,而可以只加載延遲時間臨近的消息。
    進一步優化,可以將數據按照固定延遲間隔劃分,那麼每次加載的數據量是大致相同的,不會出tick約大的定時輪需要加載越多的數據,那麼方案如下:
    image
    基於上述的方案,那麼TimeWheel中存儲未來30分鐘需要投遞的消息的索引,索引爲一個long型,那麼數據量爲:30 * 60 * 8 * TPS,相對來說內存開銷是可以接受的,比如TPS爲1w那麼大概開銷爲200M+。
    之後的數據按照每30分鐘一個塊的形式寫入文件,那麼每個整點時的操作就是計算一下將30分鐘的消息Hash到對應的TimeWheel上,那麼排序問題就解決了。
    到此爲止就只剩下一個問題,如何保存30天的數據?

CommitLog保存超長延遲的數據

CommitLog是有時效性的,過期數據將被刪除。對於可能需要30天之後投遞的延時消息,顯然是不能被刪除的。

那麼我們怎麼保存延遲消息呢?

直觀的方法就是將延遲消息從CommitLog中剝離出來,獨立存儲以保存更長的時間。
image
通過DispatchService將WAL中的延遲消息寫入到獨立的文件中。這些文件按照延遲時間組成一個鏈表。

鏈表長度爲最大延遲時間/每個文件保存的時間長度。
那麼WAL可以按照正常的策略進行過期刪除,Delay Msg File則在一個文件投遞完之後進行刪除。

唯一的問題是這裏會有Delay Msg File帶來的隨機寫問題,但是這個對系統整體性能不會有很大影響,在可接受範圍內。

BOUNS

結合TimeWheel和CommitLog保存超長延遲數據的方案,加上一些優化手段,基本就完成了支持任意延遲時間的
image

方案:

  1. 消息寫入WAL
  2. Dispatcher處理延遲消息
  3. 延遲消息一定時間的直接寫入TimeWheel
  4. 延遲超過一定時間寫入DelayMessageStorage
  5. DelayMessageStorage對DelayMsgFile構建一層索引,這樣在映射到TimeWheel時只需要做一次Hash操作
  6. 通過TimeWheel將消息投遞到ConsumeQueue中完成對Consumer的可見

 


下面我們用Netty 簡單實現延時隊列,HashedWheelTimer構造函數比較多,解釋一下各參數的含義。

  • ThreadFactory :表示用於生成工作線程,一般採用線程池;
  • tickDuration和unit:每格的時間間隔,默認100ms;
  • ticksPerWheel:一圈下來有幾格,默認512,而如果傳入數值的不是2的N次方,則會調整爲大於等於該參數的一個2的N次方數值,有利於優化hash值的計算。
public HashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit, int ticksPerWheel) {
    this(threadFactory, tickDuration, unit, ticksPerWheel, true);
} 

 

  • TimerTask:一個定時任務的實現接口,其中run方法包裝了定時任務的邏輯。
  • Timeout:一個定時任務提交到Timer之後返回的句柄,通過這個句柄外部可以取消這個定時任務,並對定時任務的狀態進行一些基本的判斷。
  • Timer:是HashedWheelTimer實現的父接口,僅定義瞭如何提交定時任務和如何停止整個定時機制。
public class NettyDelayQueue {

public static void main(String[] args) {

    final Timer timer = new HashedWheelTimer(Executors.defaultThreadFactory(), 5, TimeUnit.SECONDS, 2);

    //定時任務
    TimerTask task1 = new TimerTask() {
        public void run(Timeout timeout) throws Exception {
            System.out.println("order1  5s 後執行 ");
            timer.newTimeout(this, 5, TimeUnit.SECONDS);//結束時候再次註冊
        }
    };
    timer.newTimeout(task1, 5, TimeUnit.SECONDS);
    TimerTask task2 = new TimerTask() {
        public void run(Timeout timeout) throws Exception {
            System.out.println("order2  10s 後執行");
            timer.newTimeout(this, 10, TimeUnit.SECONDS);//結束時候再註冊
        }
    };

    timer.newTimeout(task2, 10, TimeUnit.SECONDS);

    //延遲任務
    timer.newTimeout(new TimerTask() {
        public void run(Timeout timeout) throws Exception {
            System.out.println("order3  15s 後執行一次");
        }
    }, 15, TimeUnit.SECONDS);

}
} 


從執行的結果看,order3、order3延時任務只執行了一次,而order2、order1爲定時任務,按照不同的週期重複執行。

order1  5s 後執行 
order2  10s 後執行
order3  15s 後執行一次
order1  5s 後執行 
order2  10s 後執行

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章