別再找了,關于延時關閉訂單,這里有10種方案
大家好,我是飄渺。今天繼續更新DDD&微服務專欄,本篇主要與大家探討一下在Dailymart中如何定時關閉未支付的訂單。
概述
之前的文章提及過,在DailyMart項目中,我們采用了預扣模式進行庫存扣減。預扣模式的核心思想是在用戶下單時提前扣減庫存,在規定時間內完成支付,否則系統將釋放預扣的庫存。
這種模式的應用需要確保及時關閉未支付訂單并釋放庫存,以避免商家出現庫存不足導致少賣的問題。在系統開發中,類似的場景也有很多,例如到期自動收貨、超時自動退款、下單后自動發送短信等。
本文旨在從這類業務問題出發,深入探討可行的技術方案、實現細節,以及相關方案的優缺點。最后,將回顧DailyMart是如何解決這一問題的。由于篇幅有限,本文將主要聚焦于方案的闡述,而不涉及具體的代碼實現。
一、定時任務
定時任務關閉訂單是一個較為直觀的方案,很多小型項目均是基于此方案實現。
具體實現是通過調度平臺執行定時任務,掃描所有即將到期的訂單并執行關單動作。這種方案的優勢在于簡單易實現,可基于Timer、ScheduledThreadPoolExecutor,或像xxl-job這類調度框架來實現。然而,此方案會存在以下幾個問題:
1、時間不精準 :
定時任務基于固定的頻率或時間執行,可能導致一些訂單已經超時,但定時任務尚未觸發,使得實際關閉時間延遲。
2、無法處理大訂單量:
定時任務將關閉操作集中在一段時間內,當訂單量較大時,任務執行時間可能較長,延遲訂單掃描和關閉時間。
3、對數據庫造成壓力:
定時任務集中掃描數據庫表,可能在短時間內占用大量數據庫IO,若未進行良好隔離,可能影響線上正常業務。
4、分庫分表問題:
在訂單系統中,一旦訂單量大就可能會考慮分庫分表,而在分庫分表中執行全表掃描,這是一個極不推薦的做法。
綜上,定時任務的方案,適合于對時間精確度要求不高、并且業務量不是很大的場景中。如果對時間精度要求比較高,并且業務量很大的話,這種方案不適用。
二、JDK自帶的延遲隊列
利用JDK自帶的DelayQueue可直接實現延時隊列。
DelayQueue是一個無界的BlockingQueue,用于存放實現了Delayed接口的對象,這些對象只能在到期時才能被取出。通過延遲隊列,可以實現訂單的延遲關閉。
具體實現步驟為:用戶創建訂單時,將訂單加入DelayQueue;隨后,需要一個常駐任務從隊列中取出超時的訂單并執行關單操作,然后將其從隊列中移除。
這一方案需要一個線程來循環從隊列中取出訂單,通常使用while(true)確保任務持續執行并及時處理超時訂單。該方案的優勢在于簡單易實現,無需依賴第三方框架和類庫,原生支持于JDK。不過,這一方案的缺點也很明顯:
首先,基于DelayQueue需要將訂單存放其中,當訂單量過大時,可能引發OOM問題。
此外,DelayQueue基于JVM內存,機器重啟會導致數據丟失,這就使得需要搭配數據庫持久化來配合解決,并且現在很多應用都會基于集群部署,多個實例上的多個DelayQueue又無法很好的協作配合。
因此,基于JDK的DelayQueue方案僅適用于單機場景和數據量不大的情況,不建議在涉及分布式場景中使用。
三、Netty的時間輪
一種類似于前文提到的JDK自帶DelayQueue的解決方案是基于時間輪的實現。
盡管DelayQueue的插入和刪除操作的平均時間復雜度已經相當不錯(O(nlog(n)),但時間輪方案可以將這兩個操作的時間復雜度降低至O(1)。時間輪可被視為一種環形結構,分割為多個時間槽,每個槽表示一個時間段,其中可以存放多個任務。采用鏈表結構保存每個時間槽中所有到期的任務。隨著時間的推移,時間輪的時針逐漸移動,執行每個槽中所有到期的任務。
圖片
利用Netty的HashedWheelTimer,我們能夠快速實現一個時間輪,這種方式和DelayQueue類似,也存在一些缺點,例如基于內存、集群擴展不夠靈活、內存限制等。然而,相對于DelayQueue,時間輪方案在效率上更具優勢,任務觸發的延遲更低,并且代碼實現更為精簡。
因此,基于Netty的時間輪方案相對于基于JDK的DelayQueue更為高效、實現更為簡單。然而,同樣地,它只適用于單機場景和數據量不大的情況,不建議在涉及分布式場景中使用。
四、Kafka的時間輪
既然基于Netty的時間輪存在一些問題,那么有沒有其他的時間輪的實現呢?還真有的,那就是Kafka的時間輪,Kafka內部有很多延時性的操作,如延時生產,延時拉取,延時數據刪除等,這些延時功能由內部的延時操作管理器來做專門的處理,其底層是采用時間輪實現的。圖片 而且,為了解決有一些時間跨度大的延時任務,Kafka 還引入了層級時間輪,能更好控制時間粒度,可以應對更加復雜的定時任務處理場景;Kafka 中的時間輪的實現是 TimingWheel 類,位于 kafka.utils.timer 包中。基于Kafka的時間輪同樣可以得到O(1)時間復雜度,性能上還是不錯的。基于Kafka的時間輪的實現方式,在實現方式上有點復雜,需要依賴kafka,但是他的穩定性和性能都要更高一些,而且適合用在分布式場景中。
既然在基于Netty的時間輪中存在一些問題,是否有其他可行的時間輪實現呢?確實存在一種備選方案,即Kafka的時間輪。在Kafka內部,許多操作需要延時處理,例如延時生產、延時拉取和延時數據刪除等。這些延時功能由內部的延時操作管理器專門處理,其底層采用了時間輪的實現。
特別值得一提的是,為了有效處理一些時間跨度較大的延時任務,Kafka引入了層級時間輪,該設計更靈活地控制時間粒度,以適應更為復雜的定時任務處理場景。Kafka中的時間輪實現由TimingWheel類完成,位于kafka.utils.timer包中。基于Kafka的時間輪同樣能夠實現O(1)時間復雜度,性能表現相當不錯。
基于Kafka時間輪的實現方式,需要依賴于Kafka實現較為復雜,但其穩定性和性能表現更為卓越,尤其適用于分布式場景。
五、RocketMQ延遲消息
與Kafka相比,RocketMQ提供了一個強大的功能,即支持延遲消息。
延遲消息指的是在消息寫入Broker后,并非立即被消費者處理,而是需要等待指定時長后才能被消費。這種機制使得我們能夠在訂單創建后發送一個延遲消息,例如,設置一個延遲30分鐘的消息來取消訂單。在過去的30分鐘內,消息會保持未被消費狀態,然后在時間到達后被消費者處理。消費者在接收到消息后,即可執行關單操作。
需要注意的是,RocketMQ的延遲消息并不支持任意時長的延遲,而是限定在一些預定義的時長內,如1秒、5秒、10秒、30秒、1分鐘、2分鐘等(RocketMQ商業版支持任意時長,這也是他們的一貫作風)。盡管RocketMQ的延遲消息簡化了處理流程,實現了系統之間的完全解耦,但由于時長受到了限制,靈活性相對較低。
在業務中,如果訂單關閉的時長恰好匹配RocketMQ延遲消息支持的時長,那么基于RocketMQ延遲消息是一種可行的實現方式。然而,如果時長不匹配,可能需要考慮其他更靈活的解決方案。
六、RabbitMQ
延遲消息不僅在RocketMQ中得到支持,在RabbitMQ中同樣可以實現,可以選擇使用死信隊列或者基于rabbitmq_delayed_message_exchange插件進行實現。
死信隊列
在RabbitMQ中,一旦一條正常消息因為TTL過期、隊列長度超限或被消費者拒絕等原因無法被及時消費,它將成為Dead Message,即死信,會被重新發送到死信隊列(。通過這一機制,我們可以實現延遲消息的效果。具體實現方式是給消息設定TTL,但不立即消費,等待其過期后進入死信隊列,然后監聽死信隊列以完成消息的消費。
RabbitMQ中的TTL可以設置任意時長,解決了RocketMQ在這方面的不靈活之處。然而,死信隊列實現方式可能導致隊頭阻塞問題,因為隊列采用先進先出的原則,每次只判斷隊頭消息是否過期。若隊頭消息的過期時間較長且一直不過期,整個隊列都會受阻。即使隊頭后面的消息過期,也會受到阻塞影響。
基于RabbitMQ的死信隊列,可以實現高度靈活的延遲消息機制,滿足定時關單等需求。利用RabbitMQ的集群擴展性,實現高可用和大并發處理。然而,這種方案存在消息阻塞問題,且相對較為復雜,需要依賴RabbitMQ并聲明多個隊列(交換機),增加系統復雜度,在實際使用中不是很推薦。
關于RabbitMQ死信隊列我之前寫過一篇文章,感興趣的可以翻閱實戰!SpringBoot + RabbitMQ死信隊列實現超時關單~
rabbitmq_delayed_message_exchange 插件
在RabbitMQ中,也可以利用rabbitmq_delayed_message_exchange插件實現延時消息,該方案解決了通過死信隊列引起的消息阻塞問題。請注意,該插件從RabbitMQ版本3.6.12開始提供支持,因此對版本有一定的要求。
圖片
這官方插件經過認證,使用安全可靠。安裝并啟用該插件后,即可創建x-delayed-message類型的隊列。相較于先將消息投遞到正常隊列,再在TTL過期后進入死信隊列的方式,插件的實現方式略有不同。具體而言,消息并不會立即進入隊列,而是先存儲在基于Erlang開發的Mnesia數據庫中。然后,通過定時器查詢需要投遞的消息,并將它們投遞到x-delayed-message隊列中。
七、Redis
基于Redis也可以實現延時消息的功能,有以下三種方案:
Redis過期監聽
通過在配置文件redis.conf中增加配置notify-keyspace-events Ex 即可實現消息的過期監聽,然后可以在業務代碼實現KeyExpirationEventMessageListener監聽器來接收過期消息,這樣就可以實現延時關閉訂單的操作。然而,Redis并不保證鍵在過期時立即刪除,也不保證消息能夠及時發出。并且在集群模式下,某個節點的key事件被觸發了并不會擴散到所有節點。
圖片
Redis的zset
雖然Redis過期監聽方案并不完美,但我們還可以借助有序集合(zset)來實現此功能。在zset中,我們將訂單超時時間的時間戳(下單時間+超時時長)與訂單號分別設置為score和member,Redis會根據score延時時間來排序zset。最后通過Redis掃描任務,獲取 “當前時間>score” 的延時任務,執行關閉訂單業務邏輯。使用zset實現訂單關閉的優點在于可借助Redis的持久化和高可用機制,避免數據丟失。 然而,在高并發場景下可能存在多個消費者同時獲取相同訂單號的問題,這就要求消費者一定要做好冪等處理。
關于冪等方案可以參考我DDD系列的這篇文章SpringBoot項目如何保證接口冪等
Redisson
雖然zset方案看起來不錯,但需要手動編寫基于zset這種數據結構實現。一種更友好的方式是基于Redisson框架。
圖片
Redisson是在Redis基礎上實現的框架,提供了分布式的Java常用對象和許多分布式服務。其中,Redisson定義了分布式延遲隊列RDelayedQueue,這是基于zset實現的延遲隊列,它允許將元素以指定的延遲時長放入目標隊列中。當我們要添加一個數據到延遲隊列的時候,redission會把數據+超時時間放到zset中,并且起一個延時任務,當任務到期的時候,再去zset中把數據取出來,返回給客戶端使用。通過Redisson,我們可以更輕松地實現延遲隊列,不僅解決了zset方案中的并發重復問題,并且性能穩定高,推薦大家使用!
關于Redission實現延時消息的原理可以查看這篇文章:分布式延時消息的另外一種選擇 Redisson
小結
本文介紹了七大類關于訂單延時關閉的方案,其中在RabbitMQ和Redis的方案中又各自包含幾個小方案,每種方案都有其獨特的優缺點,適用于不同的業務場景。在實際開發中,應根據業務需求和系統架構選擇最合適的方案。比如你是一個簡單的單體應用并且業務量不大,選擇Netty的時間輪或定時任務即可;如果你的項目恰好引入了RabbitMQ或者RocketMQ,那就可以;如果只是引入了Redis也可直接使用Redisson的RDelayedQueue方案。
例如,對于簡單的單體應用且業務量不大的情況,可考慮使用Netty的時間輪或定時任務。如果項目已經引入了RabbitMQ或RocketMQ,直接使用RabbitMQ的插件方案或RocketMQ的延時消息是一個不錯的方案。而對于僅引入了Redis的情況,可以直接利用Redisson的RDelayedQueue方案。
值得注意的是,使用Redis過期監聽或RabbitMQ死信隊列作為延時任務的方式可能會存在一些設計者未預料到的問題。這種出其不意的使用方式通常會帶來一致性和可靠性的隱患,可能導致低吞吐量、資源泄漏等問題。因此,個人不推薦采用這些方式,而是建議選擇更為穩定和可控的方案。
DailyMart中的實現方式
在Dailymart中我們已經引入了RocketMQ,所以可以直接采取Redis的延遲消息來實現訂單的延時關閉。
首先,在領域服務保存訂單數據時通過ApplicationEventPublisher發布訂單創建事件。
@Transactional
public void save(TradeOrder tradeOrder) {
orderRepository.save(tradeOrder);
eventPublisher.publishEvent(new OrderCreatedEvent(tradeOrder));
}
然后,在應用層的事件監聽器中,通過RocketMQ發布延時消息
@Order(3)
@Component
@Slf4j
public class DelayCloseListener implements ApplicationListener<OrderCreatedEvent> {
@Resource
private RocketMQEnhanceTemplate rocketMQEnhanceTemplate;
/**
* 收到消息以后需要發送延時消息,用以確保訂單及時支付
* @param event 訂單創建事件
*/
@Override
public void onApplicationEvent(@NotNull OrderCreatedEvent event) {
TradeOrder tradeOrder = (TradeOrder) event.getSource();
DelayCloseOrderEvent orderMessage = new DelayCloseOrderEvent(tradeOrder.getCustomerId(), tradeOrder.getOrderSn());
rocketMQEnhanceTemplate.sendDelay("TRADE-ORDER", "DELAY-CLOSE", orderMessage, DelayMessageConstant.THIRTY_MINUTE);
}
}
最后,在訂單服務基礎設施層監聽延時消息,當收到延時消息后執行關單操作
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "dailymart_order_group", topic = "TRADE-ORDER", selectorExpression = "DELAY-CLOSE")
public class OrderDelayClosedConsumer extends EnhanceMessageHandler<DelayCloseOrderEvent> implements RocketMQListener<DelayCloseOrderEvent> {
@Override
protected void handleMessage(DelayCloseOrderEvent delayCloseOrderEvent) {
// 1. 查看訂單支付狀態
// 1.1 如果已支付,則無需處理消息
// 1.2 如果未支付,則需要關閉訂單, 釋放庫存
String orderSn = delayCloseOrderEvent.getOrderSn();
TradeOrder tradeOrder = orderRepository.findOrderByTransaction(orderSn);
if (Objects.equals(tradeOrder.getStatus(), OrderStatusEnum.WAITING_PAYMENT.getStatus())) {
tradeOrderService.changeOrderStatus(orderSn, OrderStatusEnum.CLOSED);
inventoryRemoteFacade.unWithhold(orderSn);
}
}
}
在文中涉及的相關代碼,你可以在我星球 DDD&微服務 系列專欄中找到相應的實現,感興趣可以參考文末說明
DailyMart是一個基于 DDD 和Spring Cloud Alibaba的微服務商城系統,采用SpringBoot3.x以及JDK17。旨在為開發者提供集成式的學習體驗,并將其無縫地應用于實際項目中。該專欄包含領域驅動設計(DDD)、Spring Cloud Alibaba企業級開發實踐、設計模式實際應用場景解析、分庫分表戰術及實用技巧等內容。如果你對這個系列感興趣,可在本公眾號回復關鍵詞 DDD 獲取完整文檔以及相關源碼。