SpringBoot與Quartz整合,實現訂單自動取消功能
作者:Java知識日歷
@Scheduled 的限制:任務信息(何時執行、執行什么)僅存在于內存中。如果應用重啟,所有任務的調度狀態都會丟失。你需要手動重新配置和啟動它們。這對于需要長期運行或不能中斷的任務來說是不可接受的。
當遇到用戶下單后需在規定時間內完成支付的這種需求的時候,若用戶未支付,訂單將長時間占用庫存或資源,影響其他用戶購買并降低運營效率。為解決此問題,就有了"訂單自動取消"功能。該功能支持靈活配置檢查頻率與超時時間,并可動態管理任務啟停,實現自動化運營,自動識別并取消超時未支付的訂單,從而釋放資源,提升系統效率與用戶體驗。
我們為什么選擇Quartz?
雖然 Spring Boot 自帶的 @Scheduled 注解對于簡單的、單機、內存中的定時任務非常方便,但 Quartz 提供了幾個 @Scheduled 無法比擬的關鍵優勢,這些優勢對于構建一個健壯、可管理、生產就緒的定時任務系統至關重要。
任務持久化 (Persistence):
- @Scheduled 的限制:任務信息(何時執行、執行什么)僅存在于內存中。如果應用重啟,所有任務的調度狀態都會丟失。你需要手動重新配置和啟動它們。這對于需要長期運行或不能中斷的任務來說是不可接受的。
- Quartz 的優勢: Quartz 可以將任務(JobDetail)、觸發器(Trigger)和調度器狀態持久化到數據庫(如我們項目中使用的 MySQL)。這意味著:
- 重啟恢復: 應用重啟后,Quartz 會從數據庫中讀取之前存儲的任務和觸發器信息,自動恢復調度。那些在應用關閉期間“錯過”的執行,可以根據配置策略(如 MISFIRE_INSTRUCTION)進行處理。
- 狀態一致性: 任務的狀態(下次執行時間、是否暫停等)是持久化的,不會因為應用生命周期而丟失。
動態任務管理:
- @Scheduled 的限制: 任務的執行計劃(Cron表達式等)通常在代碼中通過注解硬編碼,或者通過配置文件定義。要在運行時動態地創建、修改、暫停、恢復或刪除一個任務是非常困難甚至不可能的。
- Quartz 的優勢: 提供了豐富的 API (Scheduler 接口) 來實現任務的全生命周期管理。正如我們的項目所展示的:
- 可以通過 REST API (OrderCleanupJobController) 動態地創建 (scheduleJob) 一個帶有特定 Cron 表達式和參數(如超時時間)的任務。
- 可以隨時暫停 (pauseJob)、恢復 (resumeJob) 或刪除 (deleteJob) 一個正在運行或已存在的任務。
- 這種靈活性對于運營、運維或業務配置至關重要,無需停機即可調整任務策略。
豐富的任務和觸發器模型:
- Quartz 提供了比 @Scheduled 更精細和強大的任務(Job)和觸發器(Trigger)模型。支持多種觸發器類型(CronTrigger, SimpleTrigger 等),以及更復雜的調度需求。
代碼實操
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>quartz-order-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>quartz-order-demo</name>
<description>Demo project for Spring Boot, Quartz, and Scheduled Order Cleanup using MySQL</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>application.yml
spring:
datasource:
url:jdbc:mysql://localhost:3306/quartz_order_demo?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
username:root
password:123456
driver-class-name:com.mysql.cj.jdbc.Driver
jpa:
database-platform:org.hibernate.dialect.MySQLDialect
hibernate:
ddl-auto:update# Hibernate會根據實體自動創建/更新表結構
show-sql:true# 顯示執行的SQL語句,方便調試
properties:
hibernate:
format_sql:true# 格式化SQL輸出
logging:
level:
root:INFO
com.example.quartzorder:DEBUG
org.springframework.scheduling.quartz:INFO
org.hibernate.SQL:DEBUG
org.hibernate.type.descriptor.sql.BasicBinder:TRACE# 顯示SQL參數值
# Quartz 使用數據庫存儲任務信息
spring:
quartz:
job-store-type:jdbc# 使用 JDBC 存儲
jdbc:
initialize-schema:always# 啟動時總是初始化Quartz表 (生產環境慎用)
properties:
org:
quartz:
scheduler:
instanceName:OrderCleanupScheduler
instanceId:AUTO
jobStore:
class:org.quartz.impl.jdbcjobstore.JobStoreTX
driverDelegateClass:org.quartz.impl.jdbcjobstore.StdJDBCDelegate
tablePrefix:QRTZ_
isClustered:false
threadPool:
class:org.quartz.simpl.SimpleThreadPool
threadCount:5logback-spring.xml 日志配置
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
<logger name="com.example.quartzorder" level="DEBUG"/>
<logger name="org.springframework.scheduling.quartz" level="INFO"/>
<logger name="org.hibernate.SQL" level="DEBUG"/>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE"/>
</configuration>Order 實體類
package com.example.quartzorder.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "orders")
publicclass Order {
publicenum Status {
PENDING_PAYMENT, PAID, SHIPPED, CANCELLED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String orderNumber;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Status status;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
// Constructors
public Order() {}
public Order(String orderNumber, Status status, LocalDateTime createdAt) {
this.orderNumber = orderNumber;
this.status = status;
this.createdAt = createdAt;
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getOrderNumber() {
return orderNumber;
}
public void setOrderNumber(String orderNumber) {
this.orderNumber = orderNumber;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
@Override
public String toString() {
return"Order{" +
"id=" + id +
", orderNumber='" + orderNumber + '\'' +
", status=" + status +
", createdAt=" + createdAt +
'}';
}
}Order Repository
package com.example.quartzorder.repository;
import com.example.quartzorder.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Repository
publicinterface OrderRepository extends JpaRepository<Order, Long> {
/**
* 查找狀態為 PENDING_PAYMENT 且創建時間早于指定時間的訂單
* @param beforeTime 指定的時間點
* @return 訂單列表
*/
@Query("SELECT o FROM Order o WHERE o.status = 'PENDING_PAYMENT' AND o.createdAt < :beforeTime")
List<Order> findPendingPaymentOrdersBefore(@Param("beforeTime") LocalDateTime beforeTime);
/**
* 批量更新訂單狀態為 CANCELLED
* @param orderIds 要更新的訂單ID列表
* @return 更新的行數
*/
@Modifying
@Transactional
@Query("UPDATE Order o SET o.status = 'CANCELLED' WHERE o.id IN :orderIds")
int cancelOrdersByIds(@Param("orderIds") List<Long> orderIds);
}JobDataMapUtil 工具類
package com.example.quartzorder.util;
import org.quartz.JobDataMap;
publicclass JobDataMapUtil {
publicstaticfinal String TIMEOUT_MINUTES_KEY = "timeoutMinutes";
public static int getTimeoutMinutes(JobDataMap jobDataMap) {
return jobDataMap.getInt(TIMEOUT_MINUTES_KEY);
}
public static void setTimeoutMinutes(JobDataMap jobDataMap, int timeoutMinutes) {
jobDataMap.put(TIMEOUT_MINUTES_KEY, timeoutMinutes);
}
}訂單業務邏輯服務
package com.example.quartzorder.service;
import com.example.quartzorder.entity.Order;
import com.example.quartzorder.repository.OrderRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
publicclass OrderService {
privatestaticfinal Logger logger = LoggerFactory.getLogger(OrderService.class);
@Autowired
private OrderRepository orderRepository;
/**
* 查找并取消超時的未支付訂單
* @param timeoutMinutes 超時分鐘數
*/
public void cancelUnpaidOrders(int timeoutMinutes) {
LocalDateTime beforeTime = LocalDateTime.now().minusMinutes(timeoutMinutes);
logger.info("Searching for PENDING_PAYMENT orders created before {}", beforeTime);
List<Order> ordersToCancel = orderRepository.findPendingPaymentOrdersBefore(beforeTime);
if (ordersToCancel.isEmpty()) {
logger.info("No PENDING_PAYMENT orders found to cancel.");
return;
}
List<Long> orderIds = ordersToCancel.stream().map(Order::getId).collect(Collectors.toList());
logger.info("Found {} orders to cancel: {}", ordersToCancel.size(), orderIds);
// 執行批量更新
int updatedCount = orderRepository.cancelOrdersByIds(orderIds);
logger.info("Cancelled {} orders.", updatedCount);
// 模擬:打印被取消的訂單號
ordersToCancel.forEach(order -> System.out.println(">>> Order Cancelled: " + order.getOrderNumber()));
}
}Quartz Job類
package com.example.quartzorder.job;
import com.example.quartzorder.service.OrderService;
import com.example.quartzorder.util.JobDataMapUtil;
import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
// 為了讓 @Autowired 生效,需要配置 SpringBeanJobFactory
@Component
publicclass CancelUnpaidOrdersJob implements Job {
privatestaticfinal Logger logger = LoggerFactory.getLogger(CancelUnpaidOrdersJob.class);
// 需要配合自定義的 SpringBeanJobFactory 使用
@Autowired
private OrderService orderService;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
int timeoutMinutes = JobDataMapUtil.getTimeoutMinutes(jobDataMap);
String jobName = context.getJobDetail().getKey().getName();
logger.info("Executing job [{}] with timeout [{}] minutes", jobName, timeoutMinutes);
if (orderService != null) {
orderService.cancelUnpaidOrders(timeoutMinutes);
} else {
logger.error("OrderService is not injected. Cannot execute job [{}]", jobName);
// 在實際項目中,應確保 JobFactory 配置正確
}
}
}配置正確的 JobFactory
package com.example.quartzorder.config;
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;
import org.springframework.stereotype.Component;
@Component
publicclass AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware {
privatetransient AutowireCapableBeanFactory beanFactory;
@Override
public void setApplicationContext(final ApplicationContext context) {
beanFactory = context.getAutowireCapableBeanFactory();
}
@Override
protected Object createJobInstance(final TriggerFiredBundle bundle) throws Exception {
final Object job = super.createJobInstance(bundle);
beanFactory.autowireBean(job);
return job;
}
}application.yml
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always
job-factory: com.example.quartzorder.config.AutowiringSpringBeanJobFactoryDTO
package com.example.quartzorder.model;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
publicclass OrderCleanupJobRequest {
@NotBlank(message = "Job name cannot be blank")
private String jobName;
@NotBlank(message = "Cron expression cannot be blank")
private String cronExpression;
@Min(value = 1, message = "Timeout minutes must be at least 1")
privateint timeoutMinutes = 10; // 默認10分鐘
// Getters and Setters
public String getJobName() {
return jobName;
}
public void setJobName(String jobName) {
this.jobName = jobName;
}
public String getCronExpression() {
return cronExpression;
}
public void setCronExpression(String cronExpression) {
this.cronExpression = cronExpression;
}
public int getTimeoutMinutes() {
return timeoutMinutes;
}
public void setTimeoutMinutes(int timeoutMinutes) {
this.timeoutMinutes = timeoutMinutes;
}
}Service
package com.example.quartzorder.service;
import com.example.quartzorder.job.CancelUnpaidOrdersJob;
import com.example.quartzorder.util.JobDataMapUtil;
import org.quartz.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
publicclass OrderCleanupJobService {
privatestaticfinal Logger logger = LoggerFactory.getLogger(OrderCleanupJobService.class);
@Autowired
private Scheduler scheduler;
public void addJob(String jobName, String cronExpression, int timeoutMinutes) throws SchedulerException {
if (scheduler.checkExists(JobKey.jobKey(jobName))) {
logger.warn("Job [{}] already exists.", jobName);
thrownew SchedulerException("Job already exists: " + jobName);
}
JobDataMap jobDataMap = new JobDataMap();
JobDataMapUtil.setTimeoutMinutes(jobDataMap, timeoutMinutes);
JobDetail jobDetail = JobBuilder.newJob(CancelUnpaidOrdersJob.class)
.withIdentity(jobName)
.usingJobData(jobDataMap)
.build();
CronTrigger cronTrigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(jobName + "_trigger")
.withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
.build();
scheduler.scheduleJob(jobDetail, cronTrigger);
logger.info("Scheduled order cleanup job [{}] with cron [{}] and timeout [{}] minutes", jobName, cronExpression, timeoutMinutes);
}
public void deleteJob(String jobName) throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName);
if (!scheduler.checkExists(jobKey)) {
logger.warn("Job [{}] does not exist.", jobName);
thrownew SchedulerException("Job does not exist: " + jobName);
}
scheduler.deleteJob(jobKey);
logger.info("Deleted order cleanup job [{}]", jobName);
}
public void pauseJob(String jobName) throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName);
if (!scheduler.checkExists(jobKey)) {
logger.warn("Job [{}] does not exist.", jobName);
thrownew SchedulerException("Job does not exist: " + jobName);
}
scheduler.pauseJob(jobKey);
logger.info("Paused order cleanup job [{}]", jobName);
}
public void resumeJob(String jobName) throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName);
if (!scheduler.checkExists(jobKey)) {
logger.warn("Job [{}] does not exist.", jobName);
thrownew SchedulerException("Job does not exist: " + jobName);
}
scheduler.resumeJob(jobKey);
logger.info("Resumed order cleanup job [{}]", jobName);
}
}Controller
package com.example.quartzorder.controller;
import com.example.quartzorder.model.OrderCleanupJobRequest;
import com.example.quartzorder.service.OrderCleanupJobService;
import jakarta.validation.Valid;
import org.quartz.SchedulerException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/order-cleanup-jobs")
publicclass OrderCleanupJobController {
@Autowired
private OrderCleanupJobService jobService;
@PostMapping("/schedule")
public ResponseEntity<Map<String, Object>> scheduleJob(@Valid@RequestBody OrderCleanupJobRequest request) {
Map<String, Object> response = new HashMap<>();
try {
jobService.addJob(request.getJobName(), request.getCronExpression(), request.getTimeoutMinutes());
response.put("status", "success");
response.put("message", "Order cleanup job '" + request.getJobName() + "' scheduled successfully.");
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (SchedulerException e) {
response.put("status", "error");
response.put("message", "Failed to schedule job: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} catch (Exception e) {
response.put("status", "error");
response.put("message", "Invalid request data: " + e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
}
@DeleteMapping("/delete/{jobName}")
public ResponseEntity<Map<String, Object>> deleteJob(@PathVariable String jobName) {
Map<String, Object> response = new HashMap<>();
try {
jobService.deleteJob(jobName);
response.put("status", "success");
response.put("message", "Order cleanup job '" + jobName + "' deleted successfully.");
return ResponseEntity.ok(response);
} catch (SchedulerException e) {
response.put("status", "error");
response.put("message", "Failed to delete job: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@PostMapping("/pause/{jobName}")
public ResponseEntity<Map<String, Object>> pauseJob(@PathVariable String jobName) {
Map<String, Object> response = new HashMap<>();
try {
jobService.pauseJob(jobName);
response.put("status", "success");
response.put("message", "Order cleanup job '" + jobName + "' paused successfully.");
return ResponseEntity.ok(response);
} catch (SchedulerException e) {
response.put("status", "error");
response.put("message", "Failed to pause job: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@PostMapping("/resume/{jobName}")
public ResponseEntity<Map<String, Object>> resumeJob(@PathVariable String jobName) {
Map<String, Object> response = new HashMap<>();
try {
jobService.resumeJob(jobName);
response.put("status", "success");
response.put("message", "Order cleanup job '" + jobName + "' resumed successfully.");
return ResponseEntity.ok(response);
} catch (SchedulerException e) {
response.put("status", "error");
response.put("message", "Failed to resume job: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
}Application
package com.example.quartzorder;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class QuartzOrderApplication {
public static void main(String[] args) {
SpringApplication.run(QuartzOrderApplication.class, args);
}
}責任編輯:武曉燕
來源:
Java知識日歷





































