当百万级数据遇上复杂定时策略,一套框架组合拳搞定批处理与调度
去年双十一大促期间,我所在的小组接到一个需求:每天凌晨两点,系统需要从前一天的所有订单中统计出各品类的销售额和转化率,然后把报表推送给运营团队。数据量大概在百万级别,涉及订单表、商品表、用户表、支付表的关联查询和聚合计算。
一开始的方案很简单——用一个 @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_INSTANCE、BATCH_JOB_EXECUTION、BATCH_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() 开启了容错模式,配合 skip 和 retry 配置,让批处理在面对脏数据和临时故障时能继续跑下去。
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,其他节点会认为它已经宕机,接管它未完成的任务。
部署时的注意事项:
- 所有节点必须使用同一个数据库,且 Quartz 的表结构要提前建好
- 所有节点的时区必须一致,否则 Cron 表达式的执行时间会乱
- 不要手动修改 Quartz 的系统表,通过 API 操作
- 滚动升级时,先停掉老节点,再启动新节点,避免两个版本同时抢占任务
六、监控与运维
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 管理调度——每天凌晨两点自动触发,同时还支持运营手动触发补跑。从那以后,报表再也没出过问题。
如果你的项目里也有大规模数据处理的场景,不妨试试这套组合。
系列拓展阅读
- 《Spring Data JPA 与 MyBatis 终极对决:Java 数据访问层选型指南》 —— 批处理中的数据访问层选型
- 《Spring Boot + Kafka 深度实战:可靠消息传递与 Exactly-Once 语义》 —— 批处理与消息队列的协同
- 《从 Docker 到 K8s:Spring Boot 应用部署与自动伸缩实战》 —— 批处理任务在 K8s 中的部署
- 《Java 应用接入 Prometheus + Grafana 全记录》 —— 批处理任务的监控体系建设
参考文献
- Spring Batch Reference Documentation. https://docs.spring.io/spring-batch/reference/
- Spring Boot Quartz Starter Documentation. https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.quartz
- Spring Batch 5.2.6 Release Notes. Spring.io, June 2026
- Quartz 2.5.0 Migration Guide. https://www.quartz-scheduler.net/
- “Spring Boot 3.5.4 整合Quartz 定时任务.” CSDN, 2026
- “深度解析 Spring Boot 中 Quartz 任务调度的难点与解决方案.” runebook.dev, 2026
- “Spring Batch 5 模块化作业配置.” php.cn, 2026
- “Quartz云原生定时任务K8s扩缩容容错实践.” OSC, 2026
- “Java定时任务Quartz调度怎么用?Quartz集群部署配置详解.” idctop.com, 2026









