SpringBoot与Quartz整合,实现订单自动取消功能

我们为什么选择Quartz?

虽然 Spring Boot 自带的 @Scheduled 注解对于简单的、单机、内存中的定时任务非常方便,但 Quartz 提供了几个 @Scheduled 无法比拟的关键优势,这些优势对于构建一个健壮、可管理、生产就绪的定时任务系统至关重要。

任务持久化 (Persistence):

  • @Scheduled 的限制:任务信息(何时执行、执行什么)仅存在于内存中。如果应用重启,所有任务的调度状态都会丢失。你需要手动重新配置和启动它们。这对于需要长期运行或不能中断的任务来说是不可接受的。
  • Quartz 的优势: Quartz 可以将任务(JobDetail)、触发器(Trigger)和调度器状态持久化到数据库(如我们项目中使用的 MySQL)。这意味着:
  1. 重启恢复: 应用重启后,Quartz 会从数据库中读取之前存储的任务和触发器信息,自动恢复调度。那些在应用关闭期间"错过"的执行,可以根据配置策略(如 MISFIRE_INSTRUCTION)进行处理。
  2. 状态一致性: 任务的状态(下次执行时间、是否暂停等)是持久化的,不会因为应用生命周期而丢失。

动态任务管理:

  • @Scheduled 的限制: 任务的执行计划(Cron表达式等)通常在代码中通过注解硬编码,或者通过配置文件定义。要在运行时动态地创建、修改、暂停、恢复或删除一个任务是非常困难甚至不可能的。
  • Quartz 的优势: 提供了丰富的 API (Scheduler 接口) 来实现任务的全生命周期管理。正如我们的项目所展示的:
  1. 可以通过 REST API (OrderCleanupJobController) 动态地创建 (scheduleJob) 一个带有特定 Cron 表达式和参数(如超时时间)的任务。
  2. 可以随时暂停 (pauseJob)、恢复 (resumeJob) 或删除 (deleteJob) 一个正在运行或已存在的任务。
  3. 这种灵活性对于运营、运维或业务配置至关重要,无需停机即可调整任务策略。

丰富的任务和触发器模型:

  • 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);
    }

}
相关推荐
勇往直前plus4 小时前
Sentinel微服务保护
java·spring boot·微服务·sentinel
小鸡脚来咯4 小时前
一个Java的main方法在JVM中的执行流程
java·开发语言·jvm
江团1io04 小时前
深入解析三色标记算法
java·开发语言·jvm
data myth4 小时前
力扣1210. 穿过迷宫的最少移动次数 详解
算法·leetcode·职场和发展
天天摸鱼的java工程师5 小时前
RestTemplate 如何优化连接池?—— 八年 Java 开发的踩坑与优化指南
java·后端
一乐小哥5 小时前
一口气同步10年豆瓣记录———豆瓣书影音同步 Notion分享 🚀
后端·python
惯导马工5 小时前
【论文导读】AI-Assisted Fatigue and Stamina Control for Performance Sports on IMU-Gene
深度学习·算法
LSTM975 小时前
如何使用C#实现Excel和CSV互转:基于Spire.XLS for .NET的专业指南
后端
你我约定有三5 小时前
java--泛型
java·开发语言·windows