關於延時任務,在業務場景中實在是太常見了。比如訂單,下單 xx 分鐘未支付就要將訂單關閉。比如紅包, XX 分鐘未搶,則紅包失效。
那麼說起延時任務的實現方案的話,可能有很多人第一時間會想到輪詢,即設置定時任務,而稍有經驗的開發者就知道。輪詢這機制會給數據庫帶來很大壓力,小業務當然無所謂。如果是大量數據要處理的業務用輪詢肯定是不行的。而且你如果要保證高可用,就又得牽扯出分佈式定時任務。怎麼搞都很麻煩。
很多小機靈鬼知道可以用消息隊列來實現。確實,MQ 的異步性和解耦性在延時任務的這種場景下可以爆發出很強的戰鬥力。而 RabbitMQ 因其被廣泛使用,關於如何實現延時任務自然也有其解決方案。
下面本文基於 SpringBoot 環境演示一下使用 RabbitMQ 實現延時任務的方案
用文字和 UML 活動圖來講一講所謂 RabbitMQ 的 “死信” 機制如何實現延時消息的需求及其功能上的 不足
1、死信是什麼
說起死信,balabala 的什麼死信隊列、死信交換機這種名詞就出來了。
這個詞語有點抽象,但也不是那麼難以理解。死信死信,就當他死了~
比如你生產者發送一條消息到 MQ Broker ,這條消息因爲各種原因沒被消費掉,消息最終掛掉了 / 死了。就可以認爲他是死信
那麼死信隊列呢?死信交換機呢?其實這兩個東西 和普通的隊列、交換機是一樣的,並沒有本質區別
不過可以通過對 RabbitMQ 的配置,將其設置爲 “死信” 的處理者。就是一條消息因爲種種原因沒被消費掉,最終死了,那麼就把這個消息轉發給死信交換機、由他來對這個死亡的消息進行處理
這種設置、處理 在 RabbitMQ 中是點對點的,即一個普通隊列 可以綁定一個死信交換機。
指定隊列的死信交換機需要設置隊列的屬性參數 (arguments)
具體參數名:
綁定死信交換機 : x-dead-letter-exchange
路由死信時使用的 routingkey : x-dead-letter-routing-key
2、什麼情況會產生死信
在 RabbitMQ 中,產生死信有這麼幾種情況
1、隊列長度滿了
2、消費者拒絕消費消息 (丟棄)
3、消息 TTL 過期
這裏說到了 TTL ,那麼就需要解釋一下這是個什麼東西。
TTL 是 time to live 的縮寫,即生存時間。
RabbitMQ 中可以在隊列上、單條消息上設置 TTL。如果是設置在隊列上,則可以認爲該條隊列中所有消息的 TTL 爲設定值。
隊列 TTL 屬性參數: x-message-ttl
單條消息 TTL 參數: expiration
如果設置了 TTL 值,消息待在隊列中的時間超過 TTL 值後還未被消費的話,消息隊列則會將消息丟棄,產生” 死信”。
產生死信後,若隊列配置了死信交換機,則會將消息流轉到綁定的死信交換機中,然後再由死信交換機路由到死信隊列。
死信隊列再推送給這個隊列的消費者
3、基於死信機制的延時任務實現方案
那麼,根據上述 1、2 知識點,對應的延時任務實現方案自然就出來了。
具體方案:
1、創建一個沒有消費者的隊列,設置 TTL 值,並綁定死信交換機
2、所有需要延時的消息全部向這條隊列發送。
3、死信交換機綁定對應的死信隊列,其消費者即爲處理延時消息的服務
根據以上方案邏輯,在發消息到隊列後,必定會等待到消息過期後——即指定的延時時間後,纔會有消費者對消息進行處理。
可以實現延時任務的需求。
活動圖如下所示:
3、Spring 中 RabbitMQ 死信實現方式
既然知道了原理和機制,那麼就先真實上手擼一個出來。
依賴的配置以及具體 application.yml 文件的書寫就不在此進行說明了,想了解詳情可以看我以前文章。
最重要最核心的是 RabbitMQ 的隊列、交換機配置。
據上述知識點可以得出,只要配置好了 TTL、死信交換機,即可實現功能。
那麼這裏我就直接將我寫的配置類貼出:
1. `@Configuration`
3. `public class RabbitBindConfig {`
5. ` public final static String SKYPYB_ORDINARY_EXCHANGE = "skypyb-ordinary-exchange";`
7. ` public final static String SKYPYB_DEAD_EXCHANGE = "skypyb-dead-exchange";`
9. ` public final static String SKYPYB_ORDINARY_QUEUE_1 = "skypyb-ordinary-queue";`
11. ` public final static String SKYPYB_DEAD_QUEUE = "skypyb-dead-queue";`
13. ` public final static String SKYPYB_ORDINARY_KEY = "skypyb.key.ordinary.one";`
15. ` public final static String SKYPYB_DEAD_KEY = "skypyb.key.dead";`
17. ` @Bean`
19. ` public DirectExchange ordinaryExchange() {`
21. ` return new DirectExchange(SKYPYB_ORDINARY_EXCHANGE, false, true);`
23. ` }`
25. ` @Bean`
27. ` public DirectExchange deadExchange() {`
29. ` return new DirectExchange(SKYPYB_DEAD_EXCHANGE, false, true);`
31. ` }`
33. ` @Bean`
35. ` public Queue ordinaryQueue() {`
37. ` Map arguments = new HashMap<>();`
39. ` //TTL 5s`
41. ` arguments.put("x-message-ttl", 1000 * 5);`
43. ` // 綁定死信隊列和死信交換機`
45. ` arguments.put("x-dead-letter-exchange", SKYPYB_DEAD_EXCHANGE);`
47. ` arguments.put("x-dead-letter-routing-key", SKYPYB_DEAD_KEY);`
49. ` return new Queue(SKYPYB_ORDINARY_QUEUE_1, false, false, true, arguments);`
51. ` }`
53. ` @Bean`
55. ` public Queue deadQueue() {`
57. ` return new Queue(SKYPYB_DEAD_QUEUE, false, false, true);`
59. ` }`
61. ` @Bean`
63. ` public Binding bindingOrdinaryExchangeAndQueue() {`
65. ` return BindingBuilder.bind(ordinaryQueue()).to(ordinaryExchange()).with(SKYPYB_ORDINARY_KEY);`
67. ` }`
69. ` @Bean`
71. ` public Binding bindingDeadExchangeAndQueue() {`
73. ` return BindingBuilder.bind(deadQueue()).to(deadExchange()).with(SKYPYB_DEAD_KEY);`
75. ` }`
77. `}`
可以看到我定義了關於 普通隊列相關 以及 死信隊列相關 的幾個常量。
並且基於這些常量實例化出了對應的交換機、隊列,並設置了綁定關係。
在實例化普通隊列時對其進行了特殊處理; 給普通隊列綁定上了死信交換機,並指定好死信 routing key。指定好了其 TTL 值 (5s 過期) 後才進行實例化。
那麼現在以這麼一個配置,就已經實現了延時消息需要的所有條件了。
寫個消費者、發送者來測試一下。
消費者:
1. `@RabbitListener(queues = {RabbitBindConfig.SKYPYB_DEAD_QUEUE})`
3. `@Component`
5. `public class DeadReceiver {`
7. ` private Logger logger = LoggerFactory.getLogger(DeadReceiver.class);`
9. ` @RabbitHandler`
11. ` public void onDeadMessage(@Payload String message,`
13. `@Headers Map headers,`
15. `Channel channel) throws IOException {`
17. ` logger.info("死信隊列消費者接收消息: {}", message);`
19. ` //delivery tag 可以從 headers 中 get 出來`
21. ` Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);`
23. ` try {`
25. ` channel.basicAck(deliveryTag, false);`
27. ` } catch (Exception e) {`
29. ` System.err.println(e.getMessage());`
31. ` boolean redelivered = (boolean) headers.get(AmqpHeaders.REDELIVERED);`
33. ` channel.basicNack(deliveryTag, false, !redelivered);`
35. ` }`
37. ` }`
39. `}`
發送者:
1. `@RunWith(SpringRunner.class)`
3. `@SpringBootTest(classes = Application.class)`
5. `public class RabbitmqTest {`
7. ` @Autowired`
9. ` private RabbitTemplate rabbitTemplate;`
11. ` private Logger logger = LoggerFactory.getLogger(RabbitmqTest.class);`
13. ` @Test`
15. ` public void testDead() {`
17. ` rabbitTemplate.convertAndSend(RabbitBindConfig.SKYPYB_ORDINARY_EXCHANGE,`
19. ` RabbitBindConfig.SKYPYB_ORDINARY_KEY, "消息體");`
21. ` rabbitTemplate.convertAndSend(RabbitBindConfig.SKYPYB_ORDINARY_EXCHANGE,`
23. ` RabbitBindConfig.SKYPYB_ORDINARY_KEY, "消息體");`
25. ` logger.info("----- 消息發送完畢 -----");`
27. ` }`
29. `}`
最終控制檯結果, 確實實現了延時隊列的功能:
2020-01-12 11:14:17.582 INFO 12032 — [main] com.skypyb.test.RabbitmqTest : —–消息發送完畢—–
2020-01-12 11:14:22.599 INFO 10576 — [cTaskExecutor-2] c.s.rabbitmq.controller.DeadReceiver : 死信隊列消費者接收消息: 消息體
2020-01-12 11:14:22.599 INFO 10576 — [cTaskExecutor-1] c.s.rabbitmq.controller.DeadReceiver : 死信隊列消費者接收消息: 消息體
除了隊列 TTL 以外,粒度爲消息級別的 TTL 也是可以設置的。
SpringAMQP 對單條消息的 TTL 設置,需要在 MessageProperties 類中進行,每個消息都會內置一個此類。
爲了方便,SpringAMQP 在消息發送流程中提供了一個鉤子可以讓我們設置 Message 的屬性,那就是 MessagePostProcessor
1. `@FunctionalInterface`
3. `public interface MessagePostProcessor {`
5. `Message postProcessMessage(Message message) throws AmqpException;`
7. `default Message postProcessMessage(Message message, Correlation correlation) {`
9. `return postProcessMessage(message);`
11. `}`
13. `}`
既然他用了 @FunctionalInterface 註解,那爲了方便我就用 lambda 表達式寫一個,設置單個消息的 TTL 爲 3 秒:
1. `@RunWith(SpringRunner.class)`
3. `@SpringBootTest(classes = Application.class)`
5. `public class RabbitmqTest {`
7. ` @Autowired`
9. ` private RabbitTemplate rabbitTemplate;`
11. ` private Logger logger = LoggerFactory.getLogger(RabbitmqTest.class);`
13. ` @Test`
15. ` public void testDead() {`
17. ` rabbitTemplate.convertAndSend(`
19. ` RabbitBindConfig.SKYPYB_ORDINARY_EXCHANGE,`
21. ` RabbitBindConfig.SKYPYB_ORDINARY_KEY,`
23. ` "消息體",`
25. ` (msg) -> {`
27. ` msg.getMessageProperties().setExpiration("3000");`
29. ` return msg;`
31. ` });`
33. ` rabbitTemplate.convertAndSend(RabbitBindConfig.SKYPYB_ORDINARY_EXCHANGE,`
35. ` RabbitBindConfig.SKYPYB_ORDINARY_KEY, "消息體");`
37. ` logger.info("----- 消息發送完畢 -----");`
39. ` }`
41. `}`
將代碼修改後再次發送,控制檯輸出:
2020-01-12 11:51:22.788 INFO 26232 — [main] com.skypyb.test.RabbitmqTest : —–消息發送完畢—–
2020-01-12 11:51:25.787 INFO 10576 — [cTaskExecutor-4] c.s.rabbitmq.controller.DeadReceiver : 死信隊列消費者接收消息: 消息體
2020-01-12 11:51:27.784 INFO 10576 — [cTaskExecutor-5] c.s.rabbitmq.controller.DeadReceiver : 死信隊列消費者接收消息: 消息體
可以看到,嘿 果不其然,消息接收的有時間差別了,正好符合設置的消息 TTL 3s 和隊列 TTL 5s 。
但是,這個功能是有缺陷的
這是使用 RabbitMQ 死信機制來作爲延時任務必定會出現的不足之處
下面解釋一下
4、RabbitMQ 死信實現方式缺陷
將上邊的發送消息代碼,順序調轉一下,如下所示:
1. `@RunWith(SpringRunner.class)`
3. `@SpringBootTest(classes = Application.class)`
5. `public class RabbitmqTest {`
7. ` @Autowired`
9. ` private RabbitTemplate rabbitTemplate;`
11. ` private Logger logger = LoggerFactory.getLogger(RabbitmqTest.class);`
13. ` @Test`
15. ` public void testDead() {`
17. ` rabbitTemplate.convertAndSend(RabbitBindConfig.SKYPYB_ORDINARY_EXCHANGE,`
19. ` RabbitBindConfig.SKYPYB_ORDINARY_KEY, "消息體");`
21. ` rabbitTemplate.convertAndSend(`
23. ` RabbitBindConfig.SKYPYB_ORDINARY_EXCHANGE,`
25. ` RabbitBindConfig.SKYPYB_ORDINARY_KEY,`
27. ` "消息體",`
29. ` (msg) -> {`
31. ` msg.getMessageProperties().setExpiration("3000");`
33. ` return msg;`
35. ` });`
37. ` logger.info("----- 消息發送完畢 -----");`
39. ` }`
41. `}`
運行代碼,結果,執行偏離了想象… 控制檯打印:
2020-01-12 15:00:19.371 INFO 9680 — [main] com.skypyb.test.RabbitmqTest : —–消息發送完畢—–
2020-01-12 15:00:24.380 INFO 10576 — [cTaskExecutor-1] c.s.rabbitmq.controller.DeadReceiver : 死信隊列消費者接收消息: 消息體
2020-01-12 15:00:24.380 INFO 10576 — [cTaskExecutor-3] c.s.rabbitmq.controller.DeadReceiver : 死信隊列消費者接收消息: 消息體
可以看到,消費者消費消息時,都等了整整 5s !
◾ 這是爲什麼?
這是因爲 RabbitMQ 的特性導致的。
RabbitMQ 的隊列是一個 FIFO 的有序隊列,投入的消息都順序的壓進 MQ 中。
而 RabbitMQ 也只會對隊尾的消息進行超時判定,所以就出現了上述的情況。
即哪怕第二條在第 3 秒時就過期了,但由於第一條消息 5 秒過期,RabbitMQ 會等待到第一條被丟棄後,纔對第二條進行判斷。最終出現了第一條過期後第二條纔跟着過期的結果。
結語
其實就平時可能遇見的場景而言,使用 RabbitMQ 的死信機制就已經足夠了。
畢竟大部分延時任務都是固定時間的,比如下單後半小時未支付則關閉訂單這種場景。
只要場景是有着固定時間的延時任務的話, RabbitMQ 無疑可以很好的承擔起這個需求。
針對標題的疑問作出回答的話,可以說出:
RabbitMQ 死信機制能作爲延時任務這個場景的解決方案
但是,由於 RabbitMQ 消息死亡並非異步化,而是阻塞的。所以無法作爲複雜延時場景——需要每條消息的死亡相互獨立這種場景 下的解決方案。