訂單 30 分鐘未支付就自動取消?這五個狠招幫你搞定!
一、先搞明白需求本質
咱先不著急上代碼,先把需求掰扯清楚。訂單自動取消功能,核心就是在訂單創建后的 30 分鐘內,如果用戶沒完成支付,系統就自動把這個訂單關掉。這里面有幾個關鍵點得注意:
- 時間準確性:必須嚴格在 30 分鐘后執行取消操作,不能早也不能晚,不然用戶體驗可就不好了。比如用戶剛付完錢,訂單就被取消了,那不得罵娘。
- 可靠性:不管系統是高峰期還是低谷期,都得保證該取消的訂單一定能取消,不能漏掉任何一個。要是有訂單沒取消,可能會導致庫存錯誤、資金結算異常等問題。
- 性能影響:不能因為這個功能把系統搞得卡頓,尤其是在訂單量大的時候,得考慮如何高效地處理這些定時任務。
二、五大狠招逐個解析
狠招一:數據庫輪詢 —— 簡單直接但有點笨的辦法
這是最容易想到的辦法,就像班主任盯著全班學生抄作業一樣,定時去數據庫里查一遍所有未支付的訂單,看看有沒有超過 30 分鐘的,有的話就取消。
實現步驟
- 建一張訂單表,里面得有訂單狀態(比如未支付、已支付、已取消)、創建時間等字段。
- 寫一個定時任務,比如用 Spring 的 @Scheduled 注解,每隔一段時間(比如 1 分鐘)就去數據庫查詢一次狀態為未支付且創建時間超過 30 分鐘的訂單。
- 對查詢出來的訂單執行取消操作,更新訂單狀態,可能還需要釋放庫存、發送通知等。
代碼示例
@Service
public class OrderCancelService {
@Autowired
private OrderRepository orderRepository;
@Scheduled(fixedRate = 60 * 1000) // 每分鐘執行一次
public void cancelUnpaidOrders() {
Date thirtyMinutesAgo = new Date(System.currentTimeMillis() - 30 * 60 * 1000);
List<Order> unpaidOrders = orderRepository.findByStatusAndCreateTimeBefore(OrderStatus.UNPAID, thirtyMinutesAgo);
for (Order order : unpaidOrders) {
// 執行取消邏輯
order.setStatus(OrderStatus.CANCELED);
// 釋放庫存等操作
releaseStock(order);
// 發送通知
sendCancelNotification(order);
orderRepository.save(order);
}
}
private void releaseStock(Order order) {
// 具體庫存釋放邏輯
}
private void sendCancelNotification(Order order) {
// 發送短信、APP通知等邏輯
}
}
優缺點分析
- 優點:簡單易懂,不需要引入額外的中間件,對于小型系統來說,快速就能實現。
- 缺點:太笨了!定時任務的間隔不好把握,間隔短了會頻繁查詢數據庫,影響性能;間隔長了又可能導致訂單取消不及時。而且如果訂單量很大,每次查詢都可能是全表掃描,數據庫壓力山大,就像讓一個小學生去搬一堆磚,累得氣喘吁吁。
狠招二:JDK 自帶定時器 —— 稍微聰明一點的本地方案
Java 自帶了一個 Timer 類,可以實現定時任務,相當于在本地搞了個小鬧鐘,到時間就提醒系統去取消訂單。
實現原理
Timer 類可以安排 TimerTask 任務在指定的時間執行,或者周期性地執行。我們可以在訂單創建的時候,啟動一個 Timer,讓它在 30 分鐘后執行訂單取消任務。
實現步驟
- 訂單創建時,獲取訂單的創建時間,計算出 30 分鐘后的執行時間。
- 創建一個 TimerTask 任務,在 run 方法里實現訂單取消邏輯。
- 通過 Timer 的 schedule 方法,把任務安排在計算好的時間執行。
代碼示例
public class OrderService {
private Timer timer = new Timer("OrderCancelTimer");
public void createOrder(Order order) {
// 保存訂單到數據庫
saveOrder(order);
// 安排取消任務
scheduleCancelTask(order);
}
private void scheduleCancelTask(Order order) {
long delay = 30 * 60 * 1000; // 30分鐘
TimerTask task = new TimerTask() {
@Override
public void run() {
// 檢查訂單狀態,防止重復取消或已支付的情況
Order existingOrder = getOrderById(order.getId());
if (existingOrder.getStatus() == OrderStatus.UNPAID) {
cancelOrder(existingOrder);
}
}
};
timer.schedule(task, delay);
}
private void cancelOrder(Order order) {
// 執行取消邏輯,更新數據庫等
}
}
優缺點分析
- 優點:比數據庫輪詢更精準,每個訂單都有自己的 “鬧鐘”,到時間就執行,不會有延遲。而且是 JDK 自帶的,不需要額外依賴。
- 缺點:局限性很大,只適用于單節點部署的系統。如果是分布式系統,每個節點都得自己管理定時器,任務無法共享,容易出現重復執行或者漏執行的情況。而且 Timer 線程是非守護線程,如果程序不關閉,它會一直運行,可能會有資源泄漏的問題,就像你養了一堆小寵物,卻不管它們,最后家里亂成一團。
狠招三:消息隊列延遲隊列 —— 分布式場景的好幫手
現在很多系統都是分布式部署的,這時候就需要一個分布式的解決方案,延遲隊列就派上用場了。比如 RabbitMQ 的死信隊列、RocketMQ 的延遲消息,都可以實現這個功能。
以 RabbitMQ 死信隊列為例
- 什么是死信隊列:死信隊列就是當消息成為死信后,會被發送到的那個隊列。而消息成為死信的原因有很多,比如消息被拒絕、超時未消費等。我們可以利用消息超時未消費這一點來實現延遲隊列。
- 實現步驟:
創建一個普通隊列(死信源隊列),設置隊列的過期時間(TTL)為 30 分鐘。
創建一個死信隊列,用于接收過期的消息。
將死信源隊列和死信隊列綁定,當死信源隊列中的消息過期后,會自動轉發到死信隊列。
消費者監聽死信隊列,當收到消息時,執行訂單取消邏輯。
代碼示例(RabbitMQ)
// 配置類
@Configuration
public class RabbitMQConfig {
// 死信源隊列
public static final String DEAD_LETTER_QUEUE = "dead_letter_queue";
// 死信交換器
public static final String DEAD_LETTER_EXCHANGE = "dead_letter_exchange";
// 死信路由鍵
public static final String DEAD_LETTER_ROUTING_KEY = "dead_letter_routing_key";
// 真正的死信隊列
public static final String REAL_DEAD_LETTER_QUEUE = "real_dead_letter_queue";
@Bean
public Queue deadLetterQueue() {
Map<String, Object> arguments = new HashMap<>();
// 設置隊列過期時間30分鐘
arguments.put("x-message-ttl", 30 * 60 * 1000);
// 設置死信交換器
arguments.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
// 設置死信路由鍵
arguments.put("x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY);
return new Queue(DEAD_LETTER_QUEUE, true, false, false, arguments);
}
@Bean
public Exchange deadLetterExchange() {
return ExchangeBuilder.directExchange(DEAD_LETTER_EXCHANGE).durable(true).build();
}
@Bean
public Queue realDeadLetterQueue() {
return new Queue(REAL_DEAD_LETTER_QUEUE, true);
}
@Bean
public Binding binding() {
return BindingBuilder.bind(realDeadLetterQueue()).to(deadLetterExchange()).with(DEAD_LETTER_ROUTING_KEY).noargs();
}
}
// 生產者,訂單創建時發送消息到死信源隊列
@Service
public class OrderProducer {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendOrderToDeadLetterQueue(Order order) {
// 將訂單信息轉換為JSON
String orderJson = JSON.toJSONString(order);
rabbitTemplate.convertAndSend(RabbitMQConfig.DEAD_LETTER_EXCHANGE, RabbitMQConfig.DEAD_LETTER_ROUTING_KEY, orderJson);
}
}
// 消費者,監聽真正的死信隊列
@Service
public class OrderConsumer {
@RabbitListener(queues = RabbitMQConfig.REAL_DEAD_LETTER_QUEUE)
public void handleDeadLetterMessage(String orderJson) {
Order order = JSON.parseObject(orderJson, Order.class);
// 執行訂單取消邏輯
cancelOrder(order);
}
}
優缺點分析
- 優點:適合分布式系統,解耦了訂單創建和訂單取消邏輯,消息隊列可以承載大量的延遲任務,性能較好。而且通過設置 TTL,能比較準確地控制延遲時間。
- 缺點:不同的消息隊列實現方式略有不同,比如 RabbitMQ 的 TTL 是隊列級別的,一旦設置,隊列里所有消息的過期時間都一樣,不夠靈活;RocketMQ 雖然支持不同的延遲級別,但需要提前配置好,不能動態設置延遲時間。而且引入消息隊列會增加系統的復雜度,需要處理消息的可靠性、重復消費等問題,就像找了個幫手,但這個幫手有時候也會鬧點小脾氣,得花時間磨合。
狠招四:分布式定時器 —— 專業的定時任務框架
如果系統是分布式的,而且定時任務很多,要求也比較高,那就可以用專業的分布式定時器框架,比如 Quartz、Elastic-Job、XXL-JOB 等。這里以 Quartz 為例來看看。
Quartz 實現原理
Quartz 是一個功能強大的開源作業調度框架,支持分布式部署。它有一個調度器(Scheduler),可以管理多個作業(Job)和觸發器(Trigger)。觸發器可以設置觸發時間,比如在指定時間執行一次,或者周期性執行。作業就是具體要執行的任務。
實現步驟
- 引入 Quartz 依賴。
- 創建 Job 類,實現具體的訂單取消邏輯。
- 在訂單創建時,創建 Trigger 和 JobDetail,設置觸發時間為訂單創建時間 + 30 分鐘,然后將它們注冊到 Scheduler 中。
- 配置 Quartz 的集群,確保在分布式環境下任務不會重復執行。
代碼示例
// Job類
public class OrderCancelJob implements Job {
@Autowired
private OrderService orderService;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
JobDataMap dataMap = context.getJobDetail().getJobDataMap();
String orderId = dataMap.getString("orderId");
orderService.cancelOrder(orderId);
}
}
// 訂單創建時調度任務
public class OrderService {
@Autowired
private Scheduler scheduler;
public void createOrder(Order order) {
// 保存訂單
saveOrder(order);
// 調度取消任務
scheduleCancelJob(order);
}
private void scheduleCancelJob(Order order) throws SchedulerException {
// 創建JobDetail
JobDetail jobDetail = JobBuilder.newJob(OrderCancelJob.class)
.withIdentity("orderCancelJob_" + order.getId(), "orderCancelGroup")
.usingJobData("orderId", order.getId().toString())
.build();
// 創建Trigger,30分鐘后觸發
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("orderCancelTrigger_" + order.getId(), "orderCancelGroup")
.startAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
.build();
// 將Job和Trigger注冊到Scheduler
scheduler.scheduleJob(jobDetail, trigger);
}
}
// Quartz集群配置(application.properties)
# Quartz配置
quartz.job-store-type=jdbc
quartz.data-source=quartzDataSource
quartz.jdbc.driver=com.mysql.cj.jdbc.Driver
quartz.jdbc.url=jdbc:mysql://localhost:3306/quartz_db?useUnicode=true&characterEncoding=utf-8
quartz.jdbc.user=root
quartz.jdbc.password=123456
# 啟用集群
quartz.scheduler.instanceName=ClusterScheduler
quartz.scheduler.instanceId=AUTO
quartz.job-store-is-clustered=true
優缺點分析
- 優點:功能強大,支持分布式集群,任務調度精準,可配置性高,能處理大量的定時任務。而且有豐富的監聽器和管理接口,方便監控和管理。
- 缺點:引入 Quartz 框架需要學習成本,配置相對復雜,尤其是集群環境下的配置。而且如果任務量非常大,數據庫中存儲的 Trigger 和 Job 數據會越來越多,需要定期清理,否則會影響性能,就像家里東西太多不收拾,最后找東西都難。
狠招五:Redis 過期監聽 —— 利用緩存特性實現
Redis 是一個高性能的鍵值對數據庫,它支持為鍵設置過期時間,當鍵過期時,可以通過發布訂閱模式通知客戶端。我們可以利用這個特性來實現訂單的自動取消。
實現原理
- 在訂單創建時,將訂單信息存儲到 Redis 中,并設置 30 分鐘的過期時間。
- 開啟 Redis 的過期鍵通知功能,當訂單鍵過期時,Redis 會發布一個事件。
- 客戶端監聽這個事件,收到事件后,執行訂單取消邏輯。
實現步驟
- 配置 Redis,開啟過期鍵通知。在 redis.conf 中設置:
notify-keyspace-events Ex
然后重啟 Redis 服務。
- 訂單創建時,將訂單 ID 作為鍵,存儲到 Redis 中,設置過期時間 30 分鐘。可以選擇是否存儲訂單的其他信息,也可以只存儲訂單 ID,取消時再從數據庫查詢詳細信息。
- 使用 Redis 的發布訂閱功能,創建一個監聽器,監聽鍵過期事件。
- 監聽器收到事件后,獲取訂單 ID,執行取消邏輯。
代碼示例(Spring Boot 集成 Redis)
// 訂單創建時存儲到Redis
@Service
public class OrderService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void createOrder(Order order) {
// 保存訂單到數據庫
saveOrderToDatabase(order);
// 將訂單ID存儲到Redis,設置30分鐘過期
stringRedisTemplate.opsForValue().set("order:unpaid:" + order.getId(), "1", 30, TimeUnit.MINUTES);
}
}
// Redis監聽器
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
private final ApplicationEventPublisher applicationEventPublisher;
public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer, ApplicationEventPublisher applicationEventPublisher) {
super(listenerContainer);
this.applicationEventPublisher = applicationEventPublisher;
}
@Override
public void onMessage(Message message, byte[] pattern) {
// 獲取過期的鍵
String expiredKey = message.toString();
if (expiredKey.startsWith("order:unpaid:")) {
String orderId = expiredKey.split(":")[2];
// 發布訂單過期事件
applicationEventPublisher.publishEvent(new OrderExpiredEvent(this, orderId));
}
}
}
// 訂單過期事件處理
@Service
public class OrderExpiredEventHandler {
@EventListener
public void handleOrderExpiredEvent(OrderExpiredEvent event) {
String orderId = event.getOrderId();
// 查詢訂單狀態,防止已支付的情況
Order order = getOrderFromDatabase(orderId);
if (order.getStatus() == OrderStatus.UNPAID) {
cancelOrder(order);
}
}
}
優缺點分析
- 優點:利用 Redis 的高性能和過期通知特性,能快速處理大量的訂單過期事件,適用于高并發場景。而且實現相對簡單,不需要引入復雜的框架,只需要依賴 Redis 即可。
- 缺點:Redis 的過期通知不是實時的,可能會有一定的延遲,因為 Redis 是單線程處理,只有在處理到過期鍵時才會發布事件。另外,如果系統對 Redis 的依賴很強,一旦 Redis 出現故障,可能會影響訂單取消功能,需要做好容災處理,就像你太依賴一個朋友,他要是生病了,你可能就麻煩了。
三、五大方案對比與選擇建議
方案 | 優點 | 缺點 | 適用場景 |
數據庫輪詢 | 簡單易實現,無需額外依賴 | 性能差,實時性低,訂單量大時壓力大 | 小型系統,訂單量少 |
JDK 自帶定時器 | 精準,單節點適用 | 分布式支持差,資源管理麻煩 | 單節點應用,定時任務少 |
消息隊列延遲隊列 | 分布式支持好,解耦度高 | 不同 MQ 實現有局限,復雜度增加 | 分布式系統,對解耦有要求 |
分布式定時器 | 功能強大,支持集群,可配置性高 | 學習成本高,配置復雜 | 大型分布式系統,定時任務多 |
Redis 過期監聽 | 高性能,適合高并發 | 通知有延遲,依賴 Redis | 高并發場景,對實時性要求不是極高 |
選擇的時候可以根據自己的系統規模、并發量、架構復雜度來決定。如果是小型系統,訂單量不大,用數據庫輪詢或者 JDK 定時器就能搞定;要是分布式系統,訂單量中等,消息隊列延遲隊列是個不錯的選擇;如果是大型分布式系統,定時任務很多,對可靠性和功能要求高,那就選分布式定時器;要是高并發場景,Redis 過期監聽則更合適。
四、踩坑指南
- 冪等性問題:不管用哪種方案,都要保證訂單取消操作是冪等的,也就是多次執行和執行一次的結果是一樣的。比如在取消訂單前,先檢查訂單狀態,只有未支付的訂單才取消,防止重復取消已支付或已取消的訂單。
- 事務處理:在執行訂單取消時,涉及到更新訂單狀態、釋放庫存、通知用戶等操作,要保證這些操作要么全部成功,要么全部失敗,避免出現部分成功的情況。
- 性能優化:對于數據庫輪詢和分布式定時器等方案,要做好數據庫索引優化,避免全表掃描;對于消息隊列和 Redis 方案,要合理設置過期時間和隊列參數,提高系統吞吐量。
- 監控與報警:一定要對訂單取消功能進行監控,比如統計每分鐘取消的訂單量、失敗的訂單量等,一旦出現異常,及時報警處理,避免大量訂單未取消的情況發生。
五、總結
訂單 30 分鐘未支付自動取消這個功能,看似簡單,背后卻有很多技術細節需要考慮。從簡單的數據庫輪詢到復雜的分布式定時器,每種方案都有自己的適用場景和優缺點。大家在實際開發中,要根據自己的系統情況選擇合適的方案,同時注意冪等性、事務處理、性能優化和監控報警等問題。