Modern Architecture
& Coding Solutions

Spring Batch 与 Quartz 实战:企业级批处理与任务调度体系

当百万级数据遇上复杂定时策略,一套框架组合拳搞定批处理与调度

去年双十一大促期间,我所在的小组接到一个需求:每天凌晨两点,系统需要从前一天的所有订单中统计出各品类的销售额和转化率,然后把报表推送给运营团队。数据量大概在百万级别,涉及订单表、商品表、用户表、支付表的关联查询和聚合计算。

一开始的方案很简单——用一个 @Scheduled 定时任务,在凌晨两点执行一段 SQL,查出来直接导出 Excel。跑了三天,问题来了:SQL 执行时间越来越长,从最初的 30 秒膨胀到了 8 分钟;而且有一次任务执行到一半,数据库连接超时,整个事务回滚,当天报表直接没了。

这时候我们意识到:批处理不是写一个定时任务那么简单。百万级数据的处理需要分块(Chunk)、需要断点续传、需要失败重试、需要执行监控——而 Spring 框架里专门有一整套东西来解决这些问题。

一、为什么需要 Spring Batch?

先搞清楚一个基本问题:Spring Batch 不是定时任务框架。很多人把 Spring Batch 和 @Scheduled 混为一谈,其实它们是两码事。

@Scheduled 解决的是“什么时候执行”的问题,而 Spring Batch 解决的是“怎么处理大量数据”的问题。前者是调度,后者是批处理。

Spring Batch 的核心价值在于它提供了一套批处理的标准范式

  • 分块处理(Chunk):一百万条数据不一次性加载到内存,而是每 1000 条作为一个事务单元,处理完提交,再处理下一批
  • 读-处理-写(Reader-Processor-Writer):把批处理流程拆成三个清晰的阶段,每一层都可以独立扩展和替换
  • 执行状态持久化:每次 Job 运行的状态都记录在数据库里,失败了可以知道从哪一步重来
  • 跳过和重试:某条数据格式异常可以跳过,不影响整批任务;网络抖动可以重试

如果你的批处理任务满足以下任意一条,就应该考虑用 Spring Batch:

场景说明
数据量超过 10 万条内存一次性加载可能撑爆 JVM
需要失败重跑从断点续跑,而不是从头再来
多种数据源从文件读、从数据库读、从 API 读,处理后写到不同地方
需要监控和审计每次执行了多少条、成功多少、失败多少,都要有记录

如果你只是每天凌晨执行一个简单的 SQL,@Scheduled 加 JPA 就够了,不需要上 Spring Batch。杀鸡不用牛刀

二、Spring Batch 核心概念

在开始写代码之前,先搞清楚 Spring Batch 的几个核心概念。

Job:一个完整的批处理任务。比如“每日订单统计报表”就是一个 Job。

Step:Job 里的一个执行步骤。一个 Job 可以包含多个 Step,Step 之间可以顺序执行,也可以条件执行。

ItemReader:数据的来源。可以从数据库(JdbcCursorItemReader)、文件(FlatFileItemReader)、消息队列等地方读取数据。

ItemProcessor:数据的处理逻辑。对读进来的每一条数据进行转换、过滤、清洗、聚合等操作。

ItemWriter:数据的输出目标。可以写入数据库、文件、消息队列等。

JobRepository:存储 Job 和 Step 的执行元数据。每次 Job 跑了多久、处理了多少条、成功还是失败——这些都记录在 JobRepository 里。默认存在内存里,生产环境需要配置成数据库。

JobLauncher:启动 Job 的入口。可以用命令行启动,也可以用 API 启动。

三、实战:从零搭建一个 Spring Batch 批处理任务

3.1 引入依赖

<dependencies>
    <!-- Spring Batch -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-batch</artifactId>
    </dependency>

    <!-- 数据库驱动(用于 JobRepository) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

3.2 配置文件

spring:
  batch:
    job:
      enabled: false  # 启动时不自动执行 Job,由 Quartz 触发
    jdbc:
      initialize-schema: always  # 自动创建 Batch 元数据表
  datasource:
    url: jdbc:mysql://localhost:3306/batch_db
    username: root
    password: 123456

spring.batch.jdbc.initialize-schema: always 会在数据库里自动创建 Spring Batch 所需的元数据表(BATCH_JOB_INSTANCEBATCH_JOB_EXECUTIONBATCH_STEP_EXECUTION 等)。生产环境第一次启动时用 always,之后改成 never

3.3 定义实体和数据访问

假设我们要处理一个用户数据导入任务:从 CSV 文件读取用户信息,清洗后写入数据库。

@Data
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private Integer age;
    private String phone;
    private LocalDateTime createdAt;
}

3.4 实现 Reader、Processor、Writer

Reader:从 CSV 文件读取数据

@Component
@StepScope
public class UserItemReader {

    @Bean
    public FlatFileItemReader<UserInput> reader() {
        return new FlatFileItemReaderBuilder<UserInput>()
            .name("userItemReader")
            .resource(new FileSystemResource("data/users.csv"))
            .delimited()
            .names("name", "email", "age", "phone")
            .fieldSetMapper(fieldSet -> {
                UserInput user = new UserInput();
                user.setName(fieldSet.readString("name"));
                user.setEmail(fieldSet.readString("email"));
                user.setAge(fieldSet.readInt("age"));
                user.setPhone(fieldSet.readString("phone"));
                return user;
            })
            .linesToSkip(1)  // 跳过 CSV 表头
            .build();
    }
}

@StepScope 很重要。没有这个注解,Reader 在整个 Job 的生命周期里只会被初始化一次;加了 @StepScope,每个 Step 执行时都会重新创建,保证 Step 之间的隔离性。

Processor:数据清洗和转换

@Component
@StepScope
public class UserItemProcessor implements ItemProcessor<UserInput, User> {

    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");

    @Override
    public User process(UserInput input) throws Exception {
        // 数据清洗:去除首尾空格
        String name = input.getName().trim();
        String email = input.getEmail().trim().toLowerCase();
        String phone = input.getPhone().trim();

        // 数据校验:无效数据抛出异常,触发跳过逻辑
        if (!EMAIL_PATTERN.matcher(email).matches()) {
            throw new IllegalArgumentException("邮箱格式无效: " + email);
        }
        if (input.getAge() < 0 || input.getAge() > 150) {
            throw new IllegalArgumentException("年龄超出范围: " + input.getAge());
        }

        User user = new User();
        user.setName(name);
        user.setEmail(email);
        user.setAge(input.getAge());
        user.setPhone(phone);
        user.setCreatedAt(LocalDateTime.now());
        return user;
    }
}

Writer:写入数据库

@Component
@StepScope
public class UserItemWriter {

    @Bean
    public JdbcBatchItemWriter<User> writer(DataSource dataSource) {
        return new JdbcBatchItemWriterBuilder<User>()
            .dataSource(dataSource)
            .sql("INSERT INTO users(name, email, age, phone, created_at) " +
                 "VALUES (:name, :email, :age, :phone, :created_at)")
            .beanMapped()
            .build();
    }
}

也可以用 JPA 的 RepositoryItemWriter,但批量插入场景下 JdbcBatchItemWriter 的性能更好——它用的是 JDBC 的批量提交(addBatch() + executeBatch()),而 JPA 每条数据都要经过 EntityManager 的生命周期管理。

3.5 配置 Job 和 Step

@Configuration
@EnableBatchProcessing
public class BatchConfig {

    @Autowired
    private JobBuilderFactory jobBuilderFactory;

    @Autowired
    private StepBuilderFactory stepBuilderFactory;

    @Bean
    public Job userImportJob(Step importStep) {
        return jobBuilderFactory.get("userImportJob")
            .incrementer(new RunIdIncrementer())  // 每次执行生成新的 JobInstance
            .flow(importStep)
            .end()
            .build();
    }

    @Bean
    public Step importStep(
            FlatFileItemReader<UserInput> reader,
            UserItemProcessor processor,
            JdbcBatchItemWriter<User> writer) {

        return stepBuilderFactory.get("importStep")
            .<UserInput, User>chunk(1000)  // 每 1000 条提交一次事务
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .skip(IllegalArgumentException.class)
            .skipLimit(100)  // 最多跳过 100 条异常数据
            .retry(DataAccessException.class)
            .retryLimit(3)
            .build();
    }
}

chunk(1000) 是 Spring Batch 最核心的配置——它决定了每批处理多少条数据。每批 1000 条是一个事务单元:要么全部成功,要么全部回滚。这个值需要根据数据量和数据库性能来调,太小了事务太多影响性能,太大了单次事务时间过长容易超时。

faultTolerant() 开启了容错模式,配合 skipretry 配置,让批处理在面对脏数据和临时故障时能继续跑下去。

3.6 手动启动 Job

如果不想让 Job 在应用启动时自动执行,可以在配置文件里设置 spring.batch.job.enabled=false,然后通过 API 触发:

@RestController
@RequestMapping("/api/batch")
public class BatchController {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job userImportJob;

    @PostMapping("/import")
    public String runImportJob() {
        try {
            JobParameters params = new JobParametersBuilder()
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();

            JobExecution execution = jobLauncher.run(userImportJob, params);
            return "Job 执行完成,状态: " + execution.getStatus();
        } catch (Exception e) {
            return "Job 执行失败: " + e.getMessage();
        }
    }
}

每次启动 Job 都需要传入不同的 JobParameters,否则 Spring Batch 会认为这是一个重复的 JobInstance,不会执行。用 System.currentTimeMillis() 作为参数是最简单的做法。

四、Quartz:当 @Scheduled 不够用的时候

Spring 自带的 @Scheduled 注解用起来简单,但有几个硬伤:

  • 不支持持久化:应用重启后,所有定时任务配置丢失
  • 不支持集群:多节点部署时,同一个任务会在所有节点上同时执行
  • 不支持动态增删改:想新增一个定时任务,得改代码、重新部署
  • 不支持复杂的调度规则:比如“每月最后一个工作日执行”

Quartz 就是来解决这些问题的。它是一个功能完整的任务调度框架,支持持久化、集群、动态管理、复杂的 Cron 表达式。

Spring Boot 3.5 提供了 spring-boot-starter-quartz,自动配置了 Scheduler、JobStore 等核心组件。从 2026 年的版本来看,Quartz 2.5.x 已经全面迁移到了 Jakarta 命名空间,与 Spring Boot 3.x 的兼容性不再是问题。

4.1 引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

4.2 配置文件

spring:
  quartz:
    job-store-type: jdbc  # 使用 JDBC 存储,支持集群
    jdbc:
      initialize-schema: always
    properties:
      org:
        quartz:
          scheduler:
            instanceName: MyScheduler
            instanceId: AUTO
          jobStore:
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true
            clusterCheckinInterval: 20000
            useProperties: false
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10
            threadPriority: 5

job-store-type: jdbc 是关键配置——它让 Quartz 把任务信息存到数据库里,而不是内存里。这样应用重启后任务不会丢失,而且多个节点可以共享同一个数据库实现集群调度。

isClustered: true 开启集群模式,多个节点共享任务调度,确保同一个任务在同一时间只在一个节点上执行。

4.3 定义一个 Quartz Job

@Component
public class DailyReportJob extends QuartzJobBean {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job dailyReportJob;

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        try {
            // 从 Quartz 的 JobDataMap 中获取参数
            JobDataMap dataMap = context.getMergedJobDataMap();
            String reportType = dataMap.getString("reportType");

            JobParameters params = new JobParametersBuilder()
                .addString("reportType", reportType)
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();

            // 启动 Spring Batch Job
            JobExecution execution = jobLauncher.run(dailyReportJob, params);

            log.info("日报生成完成,状态: {}", execution.getStatus());

        } catch (Exception e) {
            log.error("日报生成失败", e);
            throw new JobExecutionException(e);
        }
    }
}

QuartzJobBean 是 Spring 提供的适配器,让 Quartz Job 可以享受 Spring 的依赖注入。

4.4 配置 JobDetail 和 Trigger

@Configuration
public class QuartzConfig {

    @Bean
    public JobDetail dailyReportJobDetail() {
        return JobBuilder.newJob(DailyReportJob.class)
            .withIdentity("dailyReportJob", "reportGroup")
            .usingJobData("reportType", "daily")
            .storeDurably()
            .build();
    }

    @Bean
    public Trigger dailyReportTrigger() {
        return TriggerBuilder.newTrigger()
            .forJob(dailyReportJobDetail())
            .withIdentity("dailyReportTrigger", "reportGroup")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
            .build();
    }
}

Cron 表达式 0 0 2 * * ? 表示每天凌晨 2 点执行。Quartz 支持比 @Scheduled 更丰富的 Cron 配置,包括秒级别的精度和更复杂的日期规则。

4.5 动态管理任务

生产环境里,任务的增删改不应该靠改代码和重启。Quartz 提供了 API 来动态管理任务:

@Service
public class DynamicJobService {

    @Autowired
    private Scheduler scheduler;

    /**
     * 动态添加一个定时任务
     */
    public void addJob(String jobName, String groupName, 
                       String cronExpression, Class<? extends Job> jobClass) 
                       throws SchedulerException {

        JobDetail jobDetail = JobBuilder.newJob(jobClass)
            .withIdentity(jobName, groupName)
            .storeDurably()
            .build();

        Trigger trigger = TriggerBuilder.newTrigger()
            .withIdentity(jobName + "Trigger", groupName)
            .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
            .build();

        scheduler.scheduleJob(jobDetail, trigger);
    }

    /**
     * 暂停一个任务
     */
    public void pauseJob(String jobName, String groupName) throws SchedulerException {
        scheduler.pauseJob(new JobKey(jobName, groupName));
    }

    /**
     * 恢复一个任务
     */
    public void resumeJob(String jobName, String groupName) throws SchedulerException {
        scheduler.resumeJob(new JobKey(jobName, groupName));
    }

    /**
     * 删除一个任务
     */
    public void deleteJob(String jobName, String groupName) throws SchedulerException {
        scheduler.deleteJob(new JobKey(jobName, groupName));
    }

    /**
     * 立即执行一次(不改变原有调度计划)
     */
    public void triggerNow(String jobName, String groupName) throws SchedulerException {
        scheduler.triggerJob(new JobKey(jobName, groupName));
    }
}

把这些方法暴露成 REST API,就可以通过前端页面或运维脚本动态管理定时任务了。Spring Boot 3.5.0+ 还提供了 Actuator 的 Quartz Endpoint,可以直接通过 /actuator/quartz 查看和管理任务。

4.6 Quartz + Spring Batch 的完整工作流

五、集群部署:让任务高可用

单机部署的定时任务有个致命问题:如果这台机器宕了,今天的报表就没人跑了。

Quartz 的集群模式解决了这个问题。多个节点共享同一个数据库,Quartz 通过数据库行锁来保证同一个任务在同一时刻只被一个节点执行。

集群部署的核心配置:

spring:
  quartz:
    job-store-type: jdbc
    properties:
      org:
        quartz:
          scheduler:
            instanceName: MyScheduler
            instanceId: AUTO  # 每个节点自动生成唯一 ID
          jobStore:
            isClustered: true
            clusterCheckinInterval: 20000  # 节点每 20 秒向数据库报告一次存活

instanceId: AUTO 让每个节点在启动时自动生成一个唯一 ID。clusterCheckinInterval 控制节点向数据库报告存活的频率——如果一个节点超过这个时间没有 checkin,其他节点会认为它已经宕机,接管它未完成的任务。

部署时的注意事项

  1. 所有节点必须使用同一个数据库,且 Quartz 的表结构要提前建好
  2. 所有节点的时区必须一致,否则 Cron 表达式的执行时间会乱
  3. 不要手动修改 Quartz 的系统表,通过 API 操作
  4. 滚动升级时,先停掉老节点,再启动新节点,避免两个版本同时抢占任务

六、监控与运维

6.1 执行日志

Spring Batch 的 JobRepository 会自动记录每次执行的详细信息:

-- 查看最近的 Job 执行记录
SELECT JOB_NAME, START_TIME, END_TIME, STATUS, EXIT_CODE 
FROM BATCH_JOB_EXECUTION 
ORDER BY START_TIME DESC 
LIMIT 20;

-- 查看某个 Job 的 Step 执行详情
SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, 
       COMMIT_COUNT, ROLLBACK_COUNT, EXIT_CODE
FROM BATCH_STEP_EXECUTION 
WHERE JOB_EXECUTION_ID = ?

6.2 Actuator 监控

Spring Boot 3.5 为 Quartz 提供了 Actuator Endpoint:

management:
  endpoints:
    web:
      exposure:
        include: quartz

访问 /actuator/quartz 可以看到当前 Scheduler 中所有的 Job 和 Trigger 的状态。

6.3 告警机制

在 Job 执行完成或失败时发送通知:

@Component
public class BatchNotificationListener implements JobExecutionListener {

    @Override
    public void afterJob(JobExecution jobExecution) {
        String jobName = jobExecution.getJobInstance().getJobName();
        BatchStatus status = jobExecution.getStatus();

        if (status == BatchStatus.COMPLETED) {
            // 发送成功通知(企业微信、邮件等)
            notificationService.sendSuccess(jobName, jobExecution);
        } else if (status == BatchStatus.FAILED) {
            // 发送失败告警
            List<Throwable> exceptions = jobExecution.getAllFailureExceptions();
            notificationService.sendAlert(jobName, exceptions);
        }
    }
}

七、性能调优建议

1. Chunk Size 调优

chunk(1000) 这个值不是越大越好。太大的 Chunk 会导致单次事务时间过长,容易引发数据库锁超时;太小了事务提交次数太多,影响吞吐量。

一般建议从 1000 开始,根据数据量和数据库负载逐步调整。如果单条数据处理逻辑复杂(比如调用了外部 API),Chunk 可以设小一点(比如 100-200),避免单批处理时间过长。

2. 多线程并行 Step

如果 Step 之间没有依赖关系,可以配置为并行执行:

@Bean
public Job parallelJob() {
    return jobBuilderFactory.get("parallelJob")
        .flow(step1).split(new SimpleAsyncTaskExecutor()).add(step2, step3)
        .end()
        .build();
}

3. 异步 JobLauncher

默认 JobLauncher 是同步的——调用 run() 方法会阻塞直到 Job 执行完成。如果不想阻塞主线程,可以配置异步的 TaskExecutorJobLauncher

八、总结

Spring Batch 和 Quartz 解决的是两个不同维度的问题,但它们天然互补:

  • Spring Batch 管的是“怎么处理大量数据”——分块、事务、跳过、重试、状态持久化
  • Quartz 管的是“什么时候执行”——复杂的定时规则、持久化、集群高可用、动态管理

把两者结合起来,就构成了一套完整的企业级批处理调度体系。Spring Batch 负责执行具体的批处理逻辑,Quartz 负责触发和管理这些任务的执行时机。

回到开头那个双十一报表的案例。我们用 Spring Batch 重构了数据统计逻辑——按 5000 条一批分块处理,失败了从断点续跑,异常数据记录到单独的日志表里不影响整批任务。然后用 Quartz 管理调度——每天凌晨两点自动触发,同时还支持运营手动触发补跑。从那以后,报表再也没出过问题。

如果你的项目里也有大规模数据处理的场景,不妨试试这套组合。

系列拓展阅读

参考文献

  1. Spring Batch Reference Documentation. https://docs.spring.io/spring-batch/reference/
  2. Spring Boot Quartz Starter Documentation. https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.quartz
  3. Spring Batch 5.2.6 Release Notes. Spring.io, June 2026
  4. Quartz 2.5.0 Migration Guide. https://www.quartz-scheduler.net/
  5. “Spring Boot 3.5.4 整合Quartz 定时任务.” CSDN, 2026
  6. “深度解析 Spring Boot 中 Quartz 任务调度的难点与解决方案.” runebook.dev, 2026
  7. “Spring Batch 5 模块化作业配置.” php.cn, 2026
  8. “Quartz云原生定时任务K8s扩缩容容错实践.” OSC, 2026
  9. “Java定时任务Quartz调度怎么用?Quartz集群部署配置详解.” idctop.com, 2026
赞(0) 打赏
未经允许不得转载:MACS Dev Hub » Spring Batch 与 Quartz 实战:企业级批处理与任务调度体系

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续提供更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫

登录

找回密码

注册