當使用 12306 搶票成功后,就會進入付款界面,這個時候就會出現一個訂單倒計時,下面我們就對付款倒計時的功能實現,進行深入學習和介紹,界面展示如下:
如何實現付款及時呢,首先用戶下單后,存儲用戶的下單時間。下面介紹四種系統自動取消訂單的方案:
一、DelayQueue 延時無界阻塞隊列
我們的第一反應是用 數據庫輪序+任務調度 來實現此功能。但這種高效率的延遲任務用任務調度(定時器)實現就得不償失。而且對系統也是一種壓力且數據庫消耗極大。因此我們使用 Java 延遲隊列 DelayQueue 來實現,DelayQueue 是一個無界的延時阻塞隊列(BlockingQueue),用於存放實現了 Delayed 接口的對象,隊列中的對象只能在其到期時才能從隊列中取走。這種隊列是有序的,既隊頭對象的延遲到期時間最長。
將未付款的訂單都 add 到延遲隊列中,並通過線程池啟動多個線程不斷獲取延遲隊列的內容,獲取到后進行狀態的修改,進行業務邏輯處理。具體代碼如下:
這種方案的缺點:【1】代碼復雜度較高,大量消息堆積,性能不能保證,且很容易觸發OOM。
【2】需要考慮分布式的實現、存在單點故障。
二、環形隊列
58同城架構沈劍提供一種基於時間輪的環形隊列算法,在他的分享中,一個高效延時消息,包含兩個重要的數據結構:
【1】環形隊列,例如可以創建一個包含3600個 slot 的環形隊列(本質是個數組)
【2】任務集合,環上每一個 slot 是一個 Set<Task>
同時,啟動一個 timer ,這個 timer 每隔一秒,在上述環形隊列中移動一格,有一個 Current Index 指針來標識正在檢測的 slot。環形隊列分為 3600 個長度,每秒移動一格,移動 3600 秒正好一個小時。比如一個任務需要在60秒后執行,那這個任務應該放在那個槽位的集合里呢?假設當前指針移動到 slot 的位置為2,那么60秒后的槽位就是62,所以數據應該放在索引為 62 的那個槽位圈數為0。如果這個任務要70分鍾,70*60+2=4202,4202-3600=602,減了一次3600,所以應該放在第二圈的602槽位,既放在隊列索引為602槽位的集合,且圈數為1,代表運行一圈后才執行這個任務。
這種方案效率高,任務觸發時間延遲時間比 delayQueue 低,代碼復雜度 delayQueue 低,但沒有公開源碼,不過通過次思路可以實現次組件,當然缺點和 delayQueue 相同。
三、使用 Redis 實現
通過 Redis ZSet 類型及操作命令實現一個延遲隊列,用時間戳(當前時間+延遲的分鍾數)作為元素的 score 存入ZSet。只需獲取 zset中的第一條記錄,即最早時間下單數據,如果該記錄未超時支付,剩下的訂單必然未超時。
這種方案的缺點:【1】消息處理失敗,不能恢復處理。
【2】數據量大時,zset 性能有問題,多定義幾個 zset,增加了內存和定時器去讀的復雜度。
四、RabbitMQ 實現
利用 RabbitMQ做延時隊列是比較常見的一種方式,而實際上 RabbitMQ自身並沒有直接支持提供延遲隊列功能,而是通過 RabbitMQ 消息隊列的 TTL和 DLX這兩個屬性間接實現的。
Time To Live(TTL):消息的存活時間,RabbitMQ可以通過 x-message-tt參數來設置指定Queue(隊列)和 Message(消息)上消息的存活時間,它的值是一個非負整數,單位為微秒。
RabbitMQ 可以從兩種維度設置消息過期時間,分別是隊列和消息本身:
【1】設置隊列過期時間,那么隊列中所有消息都具有相同的過期時間。
【2】設置消息過期時間,對隊列中的某一條消息設置過期時間,每條消息TTL都可以不同。
如果同時設置隊列和隊列中消息的TTL,則TTL值以兩者中較小的值為准。而隊列中的消息存在隊列中的時間,一旦超過TTL過期時間則成為Dead Letter(死信)。
隊列出現 Dead Letter的情況有:
【1】消息或者隊列的TTL過期;
【2】隊列達到最大長度;
【3】消息被消費端拒絕(basic.reject or basic.nack);
應用場景:一般應用在當正常業務處理時出現異常時,將消息拒絕則會進入到死信隊列中,有助於統計異常數據並做后續處理;重試隊列在重試16次(默認次數)將消息放入死信隊列。利用 RabbitMQ 的死信隊列(Dead-Letter-Exchage)機制實現,在 queueDeclare 方法中加入 “x-dead-letter-exchage”實現:
RabbitMQ的 Queue可以配置 x-dead-letter-exchange 和 x-dead-letter-routing-key(可選)兩個參數,如果隊列內出現了dead letter,則按照這兩個參數重新路由轉發到指定的隊列。
x-dead-letter-exchage:過期消息路由轉發(轉發器類型)
x-dead-letter-routing-key:當消息達到過期時間由該 exchange 安裝配置的 x-dead-letter-routing-key 轉發到指定隊列,最后被消費者消費
下邊結合一張圖看看如何實現超30分鍾未支付關單功能,我們將訂單消息A0001發送到延遲隊列order.delay.queue,並設置x-message-tt消息存活時間為30分鍾,當到達30分鍾后訂單消息A0001成為了Dead Letter(死信),延遲隊列檢測到有死信,通過配置x-dead-letter-exchange,將死信重新轉發到能正常消費的隊列,直接監聽隊列處理關閉訂單邏輯即可。
我們需要兩個隊列,一個用來做主隊列,真正的投遞消息;另一個用來延遲處理消息。
放入延遲消息(DeliveryMode 等於 2 說明這個消息是 persistent 的):
這種方案的缺點:【1】筆者之前做 MQ 性能測試時,在公司的服務器上單機 TPS 接近 3W,如果是中小型企業級應用基本滿足。但如果大量的消息積壓得不到投遞,性能仍然是個問題。
【2】依賴於 RabbitMQ 的運維,復雜度和成本提高。