我们为什么选择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
<?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
yaml
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:5
logback-spring.xml 日志配置
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 实体类
typescript
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
less
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 工具类
typescript
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);
}
}
订单业务逻辑服务
java
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类
ini
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
java
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
yaml
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always
job-factory: com.example.quartzorder.config.AutowiringSpringBeanJobFactory
DTO
typescript
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
ini
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
typescript
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
typescript
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);
}
}