美團二面:如何設計一個訂單超時未支付關閉訂單的解決方案?
訂單超時未支付自動取消是一個典型的電商和在線交易業務場景,在該場景下,用戶在購物平臺上下單后,系統通常會為用戶提供一段有限的時間來完成支付。如果用戶在這個指定的時間窗口內沒有成功完成支付,系統將自動執行訂單取消操作。
當然類似的業務場景還有:
- 我們預約釘釘會議后,釘釘會在會議開始前15分鐘、5分鐘提醒。
- 淘寶收到貨物簽收之后,超過7天沒有確認收貨,會自動確認收貨。
- 未使用的優惠券有效期結束后,自動將優惠券狀態更新為已過期。
- 用戶登錄失敗次數過多后,賬號鎖定一段時間,利用延遲隊列在鎖定期滿后自動解鎖賬號。而針對這種業務需求,我們常見的兩中技術方向即:定時輪訓訂單之后判斷是否取消以及延遲隊列實現。而到具體的技術方案主要有以下幾種:
圖片
本文主要介紹以下幾種主流方案。
定時輪訓(SpringBoot的Scheduled實現)
定時輪訓的方式都是基于定時定任務掃描訂單表,按照下單時間以及狀態進行過濾,之后在進行判斷是否在有效期內,如果不在,則取消訂單。
如以下,我們使用SpringBoot中的定時任務實現:
我們先創建定時任務的配置,設置任務每隔5秒執行一次。
@Configuration
@EnableScheduling
public class CustomSchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler threadPoolTaskScheduler = threadPoolTaskScheduler();
taskRegistrar.setTaskScheduler(threadPoolTaskScheduler); // 設置自定義的TaskScheduler
// 根據任務信息創建CronTrigger
CronTrigger cronTrigger = new CronTrigger("0/5 * * * * ?");
// 創建任務執行器(假設TaskExecutor是實現了Runnable接口的對象)
MyTaskExecutor taskExecutor = new MyTaskExecutor();
// 使用自定義的TaskScheduler調度任務
threadPoolTaskScheduler.schedule(taskExecutor, cronTrigger);
}
@Bean(destroyMethod = "shutdown")
public ThreadPoolTaskScheduler threadPoolTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 設置線程池大小
scheduler.setThreadNamePrefix("scheduled-task-"); // 設置線程名稱前綴
scheduler.setAwaitTerminationSeconds(60); // 設置終止等待時間
return scheduler;
}
}
然后在MyTaskExecutor中實現掃描訂單以及判斷訂單是否需要取消:
public class MyTaskExecutor implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 在 "+ LocalDateTime.now() +" 執行MyTaskExecutor。。。。。");
}
}
運行結果如下:
圖片
采用定時任務機制來實現實時監測并取消超時訂單的方法相對直接易行,我們可以運用諸如Quartz、XXL-Job或Elastic-Job等成熟的定時任務框架進行集群部署,從而提升任務執行效能。然而,此類方案存在顯著局限:
首先,定時輪詢訂單表的方式在訂單數量龐大的情況下會對數據庫帶來持續且顯著的壓力,因為頻繁地全表掃描無疑會增加I/O負擔和CPU使用率。
其次,定時任務執行的間隔設定頗為棘手。若設定的間隔時間較長,可能會導致訂單超時后的取消動作出現延遲,影響用戶體驗;相反,若時間間隔設置得過短,則會導致大量訂單被重復掃描和判斷,不僅浪費計算資源,還可能導致不必要的并發問題和事務沖突,尤其是在高并發交易的高峰期。
在實際應用中,針對大流量訂單場景下的超時處理,往往更傾向于采用延遲隊列技術而非簡單的定時任務輪詢,以實現更為精確、高效的超時邏輯處理。
關于SpringBoot的定時任務實現的幾種方式,請參考:玩轉SpringBoot:SpringBoot的幾種定時任務實現方式
JDK的延遲隊列
使用JDK自帶的DelayQueue實現一個延遲隊列并處理超時訂單,首先我們需要定義一個實現了Delayed接口的訂單對象類,然后創建DelayQueue實例并不斷從隊列中取出已超時的訂單進行處理。
我們定義一個包含訂單信息和延遲時間的訂單類:
@Getter
public class DelayedOrder implements Delayed {
private final String orderNo;
private final long expireTimeMillis; // 訂單超時時間戳(毫秒)
public DelayedOrder(String orderNo, long delayInSeconds) {
this.orderNo = orderNo;
// 設置訂單在當前時間多少秒后超時
this.expireTimeMillis = System.currentTimeMillis() + delayInSeconds;
}
@Override
public long getDelay(TimeUnit unit) {
long remainingNanos = expireTimeMillis - System.currentTimeMillis();
return unit.convert(remainingNanos, TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed other) {
if (other == this) {
return 0;
}
DelayedOrder t = (DelayedOrder) other;
long d = (getDelay(TimeUnit.MILLISECONDS) - t.getDelay(TimeUnit.MILLISECONDS));
return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
}
// 其他訂單屬性及方法...
// 處理訂單取消的邏輯
public void cancelOrder() {
// 在這里調用實際的服務接口或方法取消訂單
}
}
然后我們就可以使用DelayQueue處理超時訂單:
@Component
public class OrderDelayQueue {
private final DelayQueue<DelayedOrder> delayQueue = new DelayQueue<>();
public void addOrderToQueue(DelayedOrder order) {
delayQueue.put(order);
System.out.println("訂單 " + order.getOrderNo() + "在 "+LocalDateTime.now()+" 添加到延遲隊列");
}
// 啟動訂單處理線程池
@Autowired
private ExecutorService executorService;
@PostConstruct
public void init() {
executorService.execute(this::processOrders);
}
private void processOrders() {
while (true) {
try {
DelayedOrder order = delayQueue.take(); // 從延遲隊列中取出已經過期的訂單
System.out.println("訂單 " + order.getOrderNo() + "在 "+ LocalDateTime.now() +" 取消");
order.cancelOrder();
// 在這里執行取消訂單的邏輯,比如更新數據庫狀態等
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
我們實現一個創建訂單的接口,模擬訂單創建:
@RestController
@RequestMapping("orderDelay")
public class OrderDelayController {
@Autowired
private OrderDelayQueue orderDelayQueue;
@PostMapping("add")
public void addOrder() {
// 202403221901 2秒后取消
DelayedOrder delayedOrder = new DelayedOrder("202403221901", 2000);
orderDelayQueue.addOrderToQueue(delayedOrder);
// 202403221902 3秒后取消
DelayedOrder delayedOrder1 = new DelayedOrder("202403221902", 3000);
orderDelayQueue.addOrderToQueue(delayedOrder1);
// 202403221903 5秒后取消
delayedOrder = new DelayedOrder("202403221903", 6000);
orderDelayQueue.addOrderToQueue(delayedOrder);
delayedOrder = new DelayedOrder("202403221904", 8000);
orderDelayQueue.addOrderToQueue(delayedOrder);
}
}
請求接口,發現訂單超過各自的時間之后,都超時了。當然真實場景是超時時間一致,只是訂單創建時間不一致。
圖片
基于JDK的DelayQueue實現的延遲隊列解決取消超時訂單的方案,相比較于定時輪訓有如下優點:
- DelayQueue基于優先級隊列實現,內部使用了堆數據結構,插入和刪除操作的時間復雜度為O(log n),對于大量訂單的處理效率較高。
- 相比于定期查詢數據庫的方式,DelayQueue將待處理的訂單信息保留在內存中,減少了對數據庫的訪問頻率,降低了IO壓力。
- DelayQueue是java.util.concurrent包下的工具類,本身就具備良好的線程安全特性,可以在多線程環境下穩定工作。
但是因為DelayQueue是基于內存的,這也導致它在實現上有一定的缺點:
- 所有待處理的訂單信息都需要保留在內存中,對于大量訂單,可能會造成較大的內存消耗。
- 由于所有的超時信息都依賴于內存中的隊列,如果系統崩潰或重啟,未處理的訂單信息可能丟失,除非有額外的持久化措施。
時間輪算法
在介紹時間輪算法實現取消超時訂單功能之前,我們先來看一下什么是時間輪算法?
時間輪算法(Time Wheel Algorithm)是一種高效處理定時任務調度的機制,廣泛應用于各類系統如計時器、調度器等組件。該算法的關鍵理念在于將時間維度映射至物理空間,即構建一個由多個時間槽構成的循環結構,每個槽代表一個固定的時間單位(如毫秒、秒等)。
時間輪實質上是一個具有多個槽位的環形數據結構,隨著時間的推進,時間輪上的指針按照預先設定的速度(例如每秒前進一槽)順時針旋轉。每當指針移動至下一槽位時,系統會檢視該槽位中掛載的所有定時任務,并逐一執行到期的任務。
在時間輪中,每個待執行任務均與其觸發時間點對應的時間槽關聯。添加新任務時,系統會根據任務的期望執行時間計算出相應的槽位編號,并將任務插入該槽。對于未來執行的任務,計算所需等待的槽位數目,確保任務按時被處理。值得注意的是,時間輪設計為循環結構,意味著當指針到達最后一個槽位后會自動返回至第一個槽位,形成連續不斷的循環調度。
借助時間輪算法,定時任務的執行時間以相對固定的時間槽來表示,而非直接依賴于絕對時間。任務執行完畢后,系統會及時將其從時間輪中移除,同時,對于不再需要執行的任務,也可以在任何時候予以移除,確保整個調度系統的高效運作和實時響應。
圖片
如上圖為例,假設一個格子是1秒,則整個wheel能表示的時間段為8s,假設當前指針指向2,此時需要調度一個3s后執行的任務, 顯然應該加入到(2+3=5)的方格中,指針再走3次就可以執行了;如果任務要在10s后執行,應該等指針走完一個round零2格再執行, 因此應放入4,同時將round(1)保存到任務中。檢查到期任務應當只執行round為0的,格子上其他任務的round應減1.
所以,我們可以使用時間輪算法去試一下延遲任務,用于實現取消超時訂單。
我們以Netty4為例,引入依賴:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.68.Final</version>
</dependency>
然后定義訂單處理服務,在創建訂單時定義訂單超時時間,以及超時時取消訂單。
@Service
public class OrderService {
private final Map<String, Timeout> orderTimeouts = new HashMap<>();
private final HashedWheelTimer timer = new HashedWheelTimer();
public void createOrder(String orderId) {
System.out.println("訂單"+orderId+"在"+ LocalDateTime.now() +"創建成功.");
// 創建訂單,設置超時時間為5秒鐘
Timeout timeout = timer.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// 超時處理邏輯,取消訂單
cancelOrder(orderId);
}
}, Duration.ofSeconds(5).toMillis(), TimeUnit.MILLISECONDS);
orderTimeouts.put(orderId, timeout);
}
public void cancelOrder(String orderId) {
// 取消訂單的邏輯
orderTimeouts.remove(orderId);
System.out.println(orderId+"訂單超時,在"+ LocalDateTime.now() +"取消訂單:" + orderId);
}
}
我們定義訂單創建接口,模擬訂單創建:
@RestController
@RequestMapping("orderTimeWheel")
public class OrderTimeWheelController {
@Autowired
private OrderService orderService;
@PostMapping("/create")
public String createOrder(String orderId) {
orderService.createOrder(orderId);
return "訂單創建成功:" + orderId;
}
}
我們分別請求接口,創建訂單:
圖片
可以看見,訂單在5秒鐘之后自動調用取消方法取消訂單。
基于時間輪實現延遲任務來取消超時訂單有如下優點:
- 時間輪算法能夠高效地管理大量的定時任務,其執行時間與任務數量無關,因此非常適合處理大規模的定時任務。
- 時間輪算法能夠提供相對精確的超時控制,可以在指定的時間后執行任務或者取消任務,從而確保超時訂單能夠及時取消。并且時間輪算法允許靈活地管理時間間隔和超時時間,可以根據具體業務需求進行調整和優化。
- 時間輪算法的實現相對簡單,算法本身比較容易理解,且現有的實現庫如Netty的HashedWheelTimer已經提供了成熟的實現,因此可以很方便地集成到現有的系統中。
- 基于內存操作,減少一些IO壓力。
但是相對應的也存在一些缺點:
- 時間輪算法需要維護一個槽的數據結構,因此會占用一定的內存和計算資源,對于一些資源受限的環境可能會存在一定的壓力。同DelayQueue,在大量訂單時會對內存造成較大的內存消耗。同時也會影響延遲精度。
- 同時,如果系統崩潰或者重啟,未處理的訂單信息可能丟失,除非有額外的持久化措施。
Redis實現
對于Redis實現延遲任務,常見的兩種方案是使用有序集合(Sorted Set,通常簡稱為zset)和使用key過期監聽。
定時輪訓有序集合
利用有序集合的特性,即集合中的元素是有序的,每個元素都有一個分數(score)。在延遲任務的場景中,可以將任務的執行時間作為分數,將任務的唯一標識(如任務ID)作為集合中的元素。然后,定時輪詢有序集合,查找分數小于當前時間的元素,這些元素即為已經到期需要執行的任務。執行完任務后,可以從有序集合中刪除對應的元素。因此可以將訂單的過期時間作為score,用于實現取消超時訂單。
引入Redis依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.7.0</version>
</dependency>
配置一下RedisTemplate:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
// 其余配置 如序列化等
return new StringRedisTemplate(factory);
}
}
創建訂單創建以及自動取消服務:
@EnableScheduling
@Service
public class OrderZSetService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// key: orders:timeout, value: order_id:order_expiration_time
private static final String ORDER_TIMEOUT_SET_KEY = "orders:timeout";
public void createOrder(String orderId) {
System.out.println("訂單"+orderId+"在"+ LocalDateTime.now() +"創建成功.");
// 假設訂單超時時間為5秒
long expirationTime = 5 * 1000 + System.currentTimeMillis();
redisTemplate.opsForZSet().add(ORDER_TIMEOUT_SET_KEY, orderId, expirationTime);
}
@Scheduled(fixedRate = 1000) // 每秒檢查一次,實際頻率根據業務需求調整
public void checkAndProcessTimeoutOrders() {
Long now = System.currentTimeMillis();
Set<ZSetOperations.TypedTuple<String>> range = redisTemplate.opsForZSet().rangeByScoreWithScores(ORDER_TIMEOUT_SET_KEY, 0, now);
for (ZSetOperations.TypedTuple<String> tuple : range) {
String orderId = (String) tuple.getValue();
if (tuple.getScore() <= now) {
// 處理超時訂單
cancelOrder(orderId);
// 從有序集合中移除已處理的超時訂單
redisTemplate.opsForZSet().remove(ORDER_TIMEOUT_SET_KEY, orderId);
}
}
}
private void cancelOrder(String orderId) {
// 在這里實現訂單取消的實際邏輯
System.out.println("訂單 " + orderId + " 在" + LocalDateTime.now() +"取消");
// 更新訂單狀態、釋放庫存等操作...
}
}
注意:因本例中基于@Scheduled實現定時輪訓,所以需要使用@EnableScheduling開啟Scheduled功能。具體請參考:玩轉SpringBoot:SpringBoot的幾種定時任務實現方式
我們定義訂單創建接口,模擬訂單創建:
圖片
可以看到訂單5秒鐘后自動取消。
使用Redis有序集合實現取消超時訂單有一些優點:
- 有序集合可以根據分數(過期時間)快速定位到需要處理的超時訂單,避免了對全部訂單的全表掃描,提高了查詢效率。
- 在分布式環境中,Redis作為緩存和中間件,可以很容易地實現在多節點間共享超時訂單信息,有利于分布式系統中統一管理超時訂單。
- 利用Redis內存數據結構,不需要頻繁讀寫數據庫,降低了數據庫的壓力,同時也節約了數據庫資源。
但是也有一些缺點:
- 定時任務的執行頻率決定了處理超時訂單的精確程度,頻率太低可能導致部分訂單未能及時取消,頻率太高則可能浪費系統資源。
- 在涉及事務處理的情況下,可能需要額外的手段來保證與數據庫之間的數據一致性,防止因Redis處理超時訂單后,數據庫層面的更新失敗導致的數據不一致問題。
- 在處理超時訂單過程中,若出現異常,需要配套的重試機制。
使用Redis key過期監聽
利用Redis的key過期監聽功能。當設置一個key的過期時間時,可以設置一個回調函數,當key過期時,Redis會自動調用這個回調函數。即利用Redis的Keyspace Notifications功能,當一個鍵(Key)過期時,Redis會向已訂閱了相關頻道的客戶端發送一個通知。
使用Redis的key的過期監聽功能之前我們需要啟用Redis Keyspace Notifications,在Redis配置文件(redis.conf)中啟用Key Space Notifications,即打開如下配置:
notify-keyspace-events Ex
notify-keyspace-events設置為Ex,表示啟用所有類型的鍵空間通知,包括過期事件。具體配置方法可能因Redis的版本和環境而有所不同,請根據實際情況進行配置。
然后我們就可以使用代碼實現,首先實現MessageListener接口實現一個監聽器來監聽Redis的key過期事件。當訂單的key過期時,將觸發監聽器中的邏輯,執行取消訂單的操作。
@Component
public class OrderExpirationListener implements MessageListener {
@Autowired
private OrderExpirationService orderService;
@Override
public void onMessage(Message message, byte[] pattern) {
String orderId = message.toString();
// 調用服務取消訂單
orderService.cancelOrder(orderId);
}
}
然后配置Redis key過期事件監聽器,并將其注冊到Redis連接工廠中。這樣,監聽器將會在Redis的key過期事件發生時被調用。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
// 其余配置 如序列化等
return new StringRedisTemplate(factory);
}
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
OrderExpirationListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listener, new ChannelTopic("__keyevent@0__:expired")); // 監聽所有數據庫的key過期事件
return container;
}
}
__keyevent@0__:expired是Redis的系統通道,用于監聽所有數據庫中的key過期事件。如果需要監聽特定數據庫的key過期事件,則可以修改對應的數據庫號。例如,__keyevent@1__:expired表示監聽第一個數據庫的key過期事件。
然后我們就可以實現具體的訂單創建服務以及訂單取消的邏輯了。這里我們模擬一下:
@Component
public class OrderExpirationService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void createOrder(String orderId) {
System.out.println("訂單"+orderId+"在"+ LocalDateTime.now() +"創建成功.");
// 假設訂單超時時間為5秒
long expirationTime = 5;
redisTemplate.opsForValue().set(orderId, "orderData", expirationTime, TimeUnit.SECONDS);
}
public void cancelOrder(String orderId) {
// 在這里實現訂單取消的實際邏輯
System.out.println("訂單 " + orderId + " 在" + LocalDateTime.now() +"取消");
// 更新訂單狀態、釋放庫存等操作...
}
}
@RestController
@RequestMapping("orderRedis")
public class RedisOrderController {
@Autowired
private OrderExpirationService orderService;
@PostMapping("/create")
public String createOrder(String orderId) {
orderService.createOrder(orderId);
return "訂單創建成功:" + orderId;
}
}
我們創建4個訂單,模擬5秒鐘后的訂單取消
圖片
使用Redis的key過期監聽事件,實現取消超時訂單有以下優點:
- Redis鍵過期事件能在鍵過期時立即觸發監聽器,因此可以在訂單超時的瞬間準確執行取消操作,大大提高了時效性。
- 相比定期輪詢數據庫查詢超時訂單的方式,Redis鍵過期事件是被動觸發,節省了CPU和網絡資源,減少了無效查詢。
- Redis的鍵過期事件處理機制天然支持高并發場景,只要Redis集群足夠強大,可以輕松處理大量訂單的過期處理。
- 資源占用小,只需要維護Redis中少量的鍵,相對于數據庫存儲所有訂單信息并做定時任務查詢,內存和磁盤資源占用較少。
但是也存在一些缺點:
- 整個方案依賴于Redis服務的穩定性和性能,如果Redis服務出現問題,可能會影響訂單超時處理。
- 在高并發場景下,Redis過期事件產生的速率可能非常高,如果處理不當,監聽器本身的處理能力可能成為瓶頸,導致消息堆積,這時需要考慮消息隊列或者其他緩沖機制。
- Redis的鍵過期并不是嚴格意義上的實時,而是基于定期檢查機制,極端情況下可能存在一定的延遲。盡管在實踐中這種延遲很小,但對于極高精度要求的場景,可能需要額外關注。
MQ消息隊列
使用消息隊列實現取消超時訂單的常見方法是利用延遲隊列以及死信隊列。比如RabbitMq,在介紹實現方式之前,我們先來了解一下RabbitMq的延遲隊列以及死信隊列。
- 延遲隊列:RabbitMQ本身并不直接支持延遲隊列,但可以通過安裝rabbitmq_delayed_message_exchange插件來實現延遲消息的功能。當啟用這個插件后,你可以創建一個類型為x-delayed-message的交換機。在發送消息時,可以設置消息頭中的x-delay字段,表示消息應該在多久之后才開始被路由到綁定的目標隊列。這樣,當一個訂單創建時,可以將包含訂單ID和過期時間的消息發送到延遲交換機,并設置相應的延遲時間。當延遲時間結束時,消息將被發送到處理超時訂單的隊列,隨后由消費者進行訂單狀態檢查和取消操作。
我的RabbitMq是部署在docker中的,所以順帶提議一下關于安裝rabbitmq_delayed_message_exchange插件,我們需要在 Releases · rabbitmq/rabbitmq-delayed-message-exchange (github.com)下載.ez結尾的插件,然后使用docker cp命令將其拷貝到rabbitmq容器內:
docker cp <本地路徑>/rabbitmq_delayed_message_exchange-3.13.0.ez <容器ID>:/plugins
然后我們進入容器后啟動插件:
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
然后驗證一下插件是否開啟成功:
rabbitmq-plugins list | grep delayed
圖片
我的rabbitmq的版本是3.13.0
- 死信隊列死信隊列是指當消息在原始隊列中遇到某種情況(如消息過期、消息被拒絕等)時,會被重新路由到另一個預定義的隊列中。當消息在隊列中停留的時間超過TTL,該消息就會變成死信,并根據隊列配置轉發到死信隊列。
基于RabbitMq的延遲隊列
延遲隊列可以直接處理延遲消息,即消息在指定的延遲時間過后才被投遞給消費者。在超時取消訂單的場景中,訂單創建時將訂單信息封裝成消息,并設置消息的延遲時間,當訂單超時時,消息自動被投遞到處理超時訂單的隊列,消費者接收到消息后執行取消操作。
引入依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
<version>2.7.18</version>
</dependency>
配置rabbitmq的相關參數:
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.password=guest
spring.rabbitmq.username=guest
配置延遲交換機,并且初始化延遲交換機、隊列及綁定關系
@Configuration
@EnableRabbit
public class RabbitConfig {
public static final String ORDER_EXCHANGE = "order.delayed.exchange";
public static final String ORDER_QUEUE = "order.delayed.queue";
public static final String ROUTING_KEY = "delayed-routing-key";
@Bean
public CustomExchange delayedExchange() {
return new CustomExchange(ORDER_EXCHANGE, "x-delayed-message", true, false);
}
@Bean
public Queue delayedQueue() {
return new Queue(ORDER_QUEUE);
}
@Bean
public Binding delayedBinding(CustomExchange delayedExchange, Queue delayedQueue) {
return BindingBuilder.bind(delayedQueue).to(delayedExchange).with(ROUTING_KEY).noargs();
}
}
這里交換機exchange,需要我們事先在rabbitmq中創建好,訪問http://localhost:15672/在Exchanges中,添加Exchange,設置type= x-delayed-message,如圖:
圖片
在定義一個監聽rabbitmq消息的監聽器,當消息延遲時間到了之后,就會被該監聽器見聽到,在這里判斷訂單是否已經被支付,如果沒有支付則取消。
@Component
public class DelayedQueueListener {
@Autowired
private OrderMqService orderMqService;
@RabbitListener(queues = RabbitConfig.ORDER_QUEUE)
public void handleDelayedOrder(String orderId) {
orderMqService.cancelOrder(orderId);
}
}
然后我們在訂單創建時,將訂單信息發送到MQ中,等延遲時間到了之后,如果訂單還沒有支付,則執行取消訂單操作。
@Service
public class OrderMqService {
private final AmqpTemplate rabbitTemplate;
@Autowired
public OrderMqService(AmqpTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void createOrder(String orderId) {
System.out.println("訂單"+orderId+"在"+ LocalDateTime.now() +"創建成功.");
rabbitTemplate.convertAndSend(RabbitConfig.ORDER_EXCHANGE, RabbitConfig.ROUTING_KEY, orderId, message -> {
message.getMessageProperties().setDelay(5 * 1000);
return message;
});
}
public void cancelOrder(String orderId) {
// 在這里實現訂單取消的實際邏輯
System.out.println("訂單" + orderId + " 在" + LocalDateTime.now() +"取消");
// 更新訂單狀態、釋放庫存等操作...
}
}
我們模擬創建訂單請求:
@RestController
@RequestMapping("orderMq")
public class MqOrderController {
@Autowired
private OrderMqService orderMqService;
@PostMapping("/create")
public String createOrder(String orderId) {
orderMqService.createOrder(orderId);
return "訂單創建成功:" + orderId;
}
}
圖片
可以看見訂單過了5秒之后開始執行取消。
使用延遲隊列方案來實現訂單超時取消等場景的優點:
- 延遲隊列能夠在消息到達指定時間后立刻觸發處理,減少不必要的輪詢查詢,提高了處理效率和實時性。
- 訂單超時處理是異步進行的,不會影響主線業務流程,有利于提升整體系統的響應速度和穩定性。
- 延遲隊列方案使得訂單創建、支付和超時取消三個環節相互獨立,有利于系統的模塊化和擴展性
- 當系統規模擴大時,可以通過增加消費者數量來應對更多的超時訂單處理,實現水平擴展。
但是也有一些缺點:
- 高度依賴消息隊列服務的可用性和穩定性,一旦消息隊列出現故障,可能導致超時訂單無法正常處理。
- 延遲隊列方案涉及更多的中間件配置和管理,增加了系統的復雜性。
- 在分布式系統中,如果訂單狀態不僅在消息隊列中維護,還要同步到數據庫,需要額外保證消息隊列處理和數據庫操作的一致性。
- 雖然大部分消息隊列的延遲機制相當可靠,但仍有極小概率出現消息延遲到達或丟失的情況,需要有相應的容錯和補償機制。
對于延遲隊列,并非只有rabbitmq才有,RocketMQ也有延遲隊列。在RocketMQ中,延遲消息的發送是通過設置消息的延遲級別來實現的。每個延遲級別都對應著一個具體的延遲時間,例如 1 表示 1 秒、2 表示 5 秒、3 表示 10 秒,以此類推。用戶可以根據自己的需求選擇合適的延遲級別。但是也可以看出他并沒有支持的那么精確,如果想要精確的就必須使用RocketMQ的企業版,在企業版中可以自定義設置延遲時間。這里就不過多講解,有興趣的可以自己研究一下。
基于RabbitMq的死信隊列實現
訂單創建時,將訂單信息發送到一個具有TTL的隊列,當消息在隊列中停留的時間超過了TTL(也就是訂單的有效支付期限),消息就會變為死信。然后再配置隊列,使得這些過期的死信消息被路由到一個預先設置好的死信隊列。最后創建一個消費者監聽這個死信隊列,一旦有消息進來(即訂單超時),消費者便處理這些死信,檢查訂單狀態并執行取消操作。
使用的rabbitmq依賴以及配置同上使用延遲隊列方案。我們來看一下創建處理訂單即帶有TTL的隊列:
@Configuration
public class RabbitMQConfig {
/**訂單隊列*/
public static final String ORDER_QUEUE = "order.queue";
/**死信隊列交換機*/
public static final String DEAD_LETTER_EXCHANGE = "order.deadLetter.exchange";
/**死信隊列*/
public static final String DEAD_LETTER_QUEUE = "order.deadLetter.queue";
/**死信路由*/
public static final String ROUTING_KEY = "delayed-routing-key";
/**
* 創建訂單隊列
* @return
*/
@Bean
public Queue orderQueue() {
Map<String, Object> args = new HashMap<>();
args.put("x-message-ttl", 5000L); // 設置訂單隊列消息有效期為30秒(可以根據實際情況調整)
args.put("type", "java.lang.Long");
args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
args.put("x-dead-letter-routing-key", ROUTING_KEY);
return new Queue(ORDER_QUEUE, true, false, false, args);
}
}
同理也是需要先創建交換機:
圖片
創建訂單業務類,將訂單發送到訂單消息隊列:
@Service
public class OrderMqService {
private final AmqpTemplate rabbitTemplate;
@Autowired
public OrderMqService(AmqpTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void createOrder(String orderId) {
System.out.println("訂單"+orderId+"在"+ LocalDateTime.now() +"創建成功.");
rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_QUEUE, orderId);
}
public void cancelOrder(String orderId) {
// 在這里實現訂單取消的實際邏輯
System.out.println("訂單" + orderId + " 在" + LocalDateTime.now() +"取消");
// 更新訂單狀態、釋放庫存等操作...
}
}
在創建死信隊列,私信隊列交換機,通過訂單隊列路由將私信隊列綁定到訂單訂單隊列中:
@Configuration
public class RabbitMQConfig {
/**訂單隊列*/
public static final String ORDER_QUEUE = "order.queue";
/**死信隊列交換機*/
public static final String DEAD_LETTER_EXCHANGE = "order.deadLetter.exchange";
/**死信隊列*/
public static final String DEAD_LETTER_QUEUE = "order.deadLetter.queue";
/**死信路由*/
public static final String ROUTING_KEY = "delayed-routing-key";
/**
* 創建死信隊列交換機
* @return
*/
@Bean
public DirectExchange deadLetterExchange() {
return new DirectExchange(DEAD_LETTER_EXCHANGE);
}
/**
* 創建死信隊列
* @return
*/
@Bean
public Queue deadLetterQueue() {
return new Queue(DEAD_LETTER_QUEUE);
}
/**
* 將死信隊列與私信交換機綁定通過路由幫訂單訂單隊列中
* @return
*/
@Bean
public Binding bindingDeadLetterQueue() {
return BindingBuilder.bind(deadLetterQueue()).to(deadLetterExchange()).with(ROUTING_KEY);
}
}
在創建一個死信隊列消息監聽器,用于判斷訂單是否超時:
@Component
public class DelayedQueueListener {
@Autowired
private OrderMqService orderMqService;
@RabbitListener(queues = RabbitMQConfig.DEAD_LETTER_QUEUE)
public void handleDeadLetterOrder(String orderId) {
orderMqService.cancelOrder(orderId);
}
}
然后我們在訂單創建時,將訂單信息發送到訂單MQ中,等消息的TTL到期之后,會自動轉到死信隊列中。
@Service
public class OrderMqService {
private final AmqpTemplate rabbitTemplate;
@Autowired
public OrderMqService(AmqpTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void createOrder(String orderId) {
System.out.println("訂單"+orderId+"在"+ LocalDateTime.now() +"創建成功.");
rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_QUEUE, orderId);
}
public void cancelOrder(String orderId) {
// 在這里實現訂單取消的實際邏輯
System.out.println("訂單" + orderId + " 在" + LocalDateTime.now() +"取消");
// 更新訂單狀態、釋放庫存等操作...
}
}
我們模擬創建訂單接口:
@RestController
@RequestMapping("orderMq")
public class MqOrderController {
@Autowired
private OrderMqService orderMqService;
@PostMapping("/create")
public String createOrder(String orderId) {
orderMqService.createOrder(orderId);
return "訂單創建成功:" + orderId;
}
}
圖片
可以看見訂單過了5秒之后開始執行取消。
使用死信隊列實現取消超時訂單的優點:
- 死信隊列可以捕獲并隔離那些在原始隊列中無法正常處理的消息,比如訂單超時未支付等情況。這樣有助于保障主業務流程不受影響,同時可以對異常情況進行統一管理和處理。
- 通過設置消息TTL(Time-to-Live)或最大重試次數等條件,將無法正常處理的消息轉移到死信隊列,可以避免消息堆積導致的資源浪費,如內存、磁盤空間等。
- 死信隊列可以作為訂單生命周期中特定階段的處理通道,如訂單超時后的處理流程,從而實現業務邏輯的清晰分離和模塊化。
- 所有的死信消息都被記錄在死信隊列中,方便跟蹤和分析訂單處理過程中出現的問題,也有助于完善系統的監控報警和數據分析。
- 死信隊列的處理過程也是異步進行的,不影響主線程的執行效率,增強系統的并發處理能力和響應速度。
當然他也有一些缺點:
- 相較于專門的延遲隊列,死信隊列機制通常不會自動將消息在特定時間后發出,需要通過設置消息TTL(過期時間)并在過期后觸發轉移至死信隊列。這種方式對于精確到秒級別的超時處理不夠友好,可能需要配合定時任務來檢查即將超時的訂單。
- 死信隊列的配置相對復雜,需要設置死信交換機、綁定關系以及消息TTL等,而且在處理死信時也需要額外的邏輯判斷。
- 如果沒有妥善處理死信隊列的消息,比如沒有監聽死信隊列或者處理邏輯存在缺陷,可能會導致部分死信消息未被正確處理。
- 在分布式環境下,如果訂單狀態不僅在消息隊列中維護,還涉及到數據庫的更新,那么需要保證消息隊列與數據庫之間的事務一致性。
總結
訂單超時自動取消是電商平臺中非常重要的功能之一,通過合適的技術方案,可以實現自動化處理訂單超時的邏輯,提升用戶體驗和系統效率。本文討論了多種實現訂單超時自動取消的技術方案,包括定時輪詢、JDK的延遲隊列、時間輪算法、Redis實現以及MQ消息隊列中的延遲隊列和死信隊列。
- 定時輪詢:基于SpringBoot的Scheduled實現,通過定時任務掃描數據庫中的訂單。優點是實現簡單直接,但缺點是會給數據庫帶來持續壓力,處理效率受任務執行間隔影響較大,且在高并發場景下可能引發并發問題和資源浪費。
- JDK的延遲隊列(DelayQueue):基于優先級隊列實現,減少數據庫訪問,提供高效的任務處理。優點是內部數據結構高效,線程安全。缺點是所有待處理訂單需保留在內存中,可能導致內存消耗大,且無持久化機制,系統崩潰時可能丟失數據。
- 時間輪算法:通過時間輪結構實現定時任務調度,能高效處理大量定時任務,提供精確的超時控制。優點是實現簡單,執行效率高,且有成熟實現庫。缺點同樣是內存占用和崩潰時數據丟失的問題。
- Redis實現:
有序集合(Sorted Set):利用有序集合的特性,定時輪詢查找已超時的任務。優點是查詢效率高,適用于分布式環境,減少數據庫壓力。缺點是依賴定時任務執行頻率,處理超時訂單的實時性受限,且在處理事務一致性方面需要額外努力。
Key過期監聽:利用Redis鍵過期事件自動觸發訂單取消邏輯。優點是實時性好,資源消耗少,支持高并發。缺點是對Redis服務的依賴性強,極端情況下處理能力可能成為瓶頸,且鍵過期有一定的不確定性。
- MQ消息隊列:
延遲隊列(如RabbitMQ的rabbitmq_delayed_message_exchange插件):實現消息在指定延遲后送達處理隊列。優點是處理高效,異步執行,易于擴展,模塊化程度高。缺點是高度依賴消息隊列服務,配置復雜度增加,可能涉及消息丟失或延遲風險,以及消息隊列與數據庫操作一致性問題。
死信隊列:通過設置隊列TTL將超時訂單轉為死信,由監聽死信隊列的消費者處理。優點是能捕獲并隔離異常消息,實現業務邏輯分離,資源保護良好,方便追蹤和分析問題。缺點是相比延遲隊列,處理超時不夠精確,配置復雜,且同樣存在消息處理完整性及一致性問題。
不同方案各有優劣,實際應用中應根據系統的具體需求、資源狀況以及技術棧等因素綜合評估,選擇最適合的方案。在許多現代大型系統中,通常會選擇消息隊列的延遲隊列或死信隊列方案,以充分利用其異步處理、資源優化和擴展性方面的優勢。