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