延遲佇列在實際專案中有非常多的應用場景,最常見的比如訂單未支付,超時取消訂單,在建立訂單的時候傳送一條延遲訊息,達到延遲時間之後消費者收到訊息,如果訂單沒有支付的話,那麼就取消訂單。
那麼,今天我們需要來談的問題就是RabbitMQ、RocketMQ、Kafka中分別是怎麼實現延時佇列的,以及他們對應的實現原理是什麼?
RabbitMQ
RabbitMQ本身並不存在延遲佇列的概念,在 RabbitMQ 中是透過 DLX 死信交換機和 TTL 訊息過期來實現延遲佇列的。
TTL(Time to Live)過期時間
有兩種方式可以設定 TTL。
- 透過佇列屬性設定,這樣的話佇列中的所有訊息都會擁有相同的過期時間
- 對訊息單獨設定過期時間,這樣每條訊息的過期時間都可以不同
那麼如果同時設定呢?這樣將會以兩個時間中較小的值為準。
針對佇列的方式透過引數x-message-ttl
來設定。
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 6000);
channel.queueDeclare(queueName, durable, exclusive, autoDelete, args);
針對訊息的方式透過setExpiration
來設定。
AMQP.BasicProperties properties = new AMQP.BasicProperties();
Properties.setDeliveryMode(2);
properties.setExpiration("60000");
channel.basicPublish(exchangeName, routingKey, mandatory, properties, "message".getBytes());
DLX(Dead Letter Exchange)死信交換機
一個訊息要成為死信訊息有 3 種情況:
- 訊息被拒絕,比如呼叫
reject
方法,並且需要設定requeue
為false
- 訊息過期
- 佇列達到最大長度
可以透過引數dead-letter-exchange
設定死信交換機,也可以透過引數dead-letter- exchange
指定 RoutingKey(未指定則使用原佇列的 RoutingKey)。
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-dead-letter-exchange", "exchange.dlx");
args.put("x-dead-letter-routing-key", "routingkey");
channel.queueDeclare(queueName, durable, exclusive, autoDelete, args);
原理
當我們對訊息設定了 TTL 和 DLX 之後,當訊息正常傳送,透過 Exchange 到達 Queue 之後,由於設定了 TTL 過期時間,並且訊息沒有被消費(訂閱的是死信佇列),達到過期時間之後,訊息就轉移到與之繫結的 DLX 死信佇列之中。
這樣的話,就相當於透過 DLX 和 TTL 間接實現了延遲訊息的功能,實際使用中我們可以根據不同的延遲級別繫結設定不同延遲時間的佇列來達到實現不同延遲時間的效果。
RocketMQ
RocketMQ 和 RabbitMQ 不同,它本身就有延遲佇列的功能,但是開源版本只能支援固定延遲時間的訊息,不支援任意時間精度的訊息(這個好像只有阿里雲版本的可以)。
他的預設時間間隔分為 18 個級別,基本上也能滿足大部分場景的需要了。
預設延遲級別:1s、 5s、 10s、 30s、 1m、 2m、 3m、 4m、 5m、 6m、 7m、 8m、 9m、 10m、 20m、 30m、 1h、 2h。
使用起來也非常的簡單,直接透過setDelayTimeLevel
設定延遲級別即可。
setDelayTimeLevel(level)
原理
實現原理說起來比較簡單,Broker 會根據不同的延遲級別建立出多個不同級別的佇列,當我們傳送延遲訊息的時候,根據不同的延遲級別傳送到不同的佇列中,同時在 Broker 內部透過一個定時器去輪詢這些佇列(RocketMQ 會為每個延遲級別分別建立一個定時任務),如果訊息達到傳送時間,那麼就直接把訊息傳送到指 topic 佇列中。
RocketMQ 這種實現方式是放在服務端去做的,同時有個好處就是相同延遲時間的訊息是可以保證有序性的。
談到這裡就順便提一下關於訊息消費重試的原理,這個本質上來說其實是一樣的,對於消費失敗需要重試的訊息實際上都會被丟到延遲佇列的 topic 裡,到期後再轉發到真正的 topic 中。
Kafka
對於 Kafka 來說,原生並不支援延遲佇列的功能,需要我們手動去實現,這裡我根據 RocketMQ 的設計提供一個實現思路。
這個設計,我們也不支援任意時間精度的延遲訊息,只支援固定級別的延遲,因為對於大部分延遲訊息的場景來說足夠使用了。
只建立一個 topic,但是針對該 topic 建立 18 個 partition,每個 partition 對應不同的延遲級別,這樣做和 RocketMQ 一樣有個好處就是能達到相同延遲時間的訊息達到有序性。
原理
- 首先建立一個單獨針對延遲佇列的 topic,同時建立 18 個 partition 針對不同的延遲級別
- 傳送訊息的時候根據延遲引數傳送到延遲 topic 對應的 partition,對應的
key
為延遲時間,同時把原 topic 儲存到 header 中
ProducerRecord<Object, Object> producerRecord = new ProducerRecord<>("delay_topic", delayPartition, delayTime, data);
producerRecord.headers().add("origin_topic", topic.getBytes(StandardCharsets.UTF_8));
- 內嵌的
consumer
單獨設定一個ConsumerGroup
去消費延遲 topic 訊息,消費到訊息之後如果沒有達到延遲時間那麼就進行pause
,然後seek
到當前ConsumerRecord
的offset
位置,同時使用定時器去輪詢延遲的TopicPartition
,達到延遲時間之後進行resume
- 如果達到了延遲時間,那麼就獲取到
header
中的真實 topic ,直接轉發
這裡為什麼要進行pause
和resume
呢?因為如果不這樣的話,如果超時未消費達到max.poll.interval.ms
最大時間(預設300s),那麼將會觸發 Rebalance。