Spring Batch 批处理
提出问题
大批量数据处理(如月度报表生成、数据迁移、日志清洗)是后端开发绕不开的场景。如果直接用 for 循环逐条处理,碰上几十万条数据,内存会爆、事务会超时、失败得从头重来。Spring Batch 正是为了解决这些问题而生的——它提供事务管理、Chunk 式处理、断点续跑、并行分区等能力,是大数据量批处理的事实标准。面试问 Spring Batch 通常不是问怎么 Hello World,而是问它的架构模型、事务边界、如何调优、以及断点续跑的机制是否真的可靠。
分析问题
Job 与 Step 的分层模型
Spring Batch 的核心抽象是 Job → Step → Chunk → Item 四层。一个 Job 包含一到多个 Step,每个 Step 按 Chunk 逐批处理:
@Bean
public Job importJob(JobRepository jobRepository, Step step1) {
return new JobBuilder("importJob", jobRepository)
.start(step1)
.build();
}
@Bean
public Step step1(JobRepository jobRepository, PlatformTransactionManager tm,
ItemReader<Transaction> reader, ItemProcessor<Transaction, Transformed> processor,
ItemWriter<Transformed> writer) {
return new StepBuilder("step1", jobRepository)
.<Transaction, Transformed>chunk(1000, tm) // 每 1000 条一个事务
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}Chunk 处理时序(文字时序图):
Reader.read() → 返回 1 条
Reader.read() → 返回 1 条
... 重复直到 chunk 满(1000 条)
→ Processor.process(item) × 1000
→ Writer.write(items) ← 这里是一个事务边界
→ 事务提交
→ 重复下一个 chunkChunk 机制是事务边界的关键:每批 1000 条用同一事务,失败时只回滚这一批,不会丢掉已处理的数据。这与逐条事务的开销对比鲜明——逐条事务每秒只能处理几百条,而 Chunk 1000 的批提交在 MySQL 下能到 5000-10000 条/秒。
断点续跑与 JobRepository
Spring Batch 的断点续跑不是「存个 checkpoint」那么简单。它依赖 JobRepository(默认用数据库表)记录每一步的执行上下文。底层表结构如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
BATCH_JOB_INSTANCE | Job 实例定义 | JOB_NAME, JOB_KEY(由 JobParameters 哈希生成) |
BATCH_JOB_EXECUTION | 每次运行记录 | STATUS(COMPLETED/FAILED/STOPPED), START_TIME, END_TIME |
BATCH_STEP_EXECUTION | Step 级别状态 | STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, COMMIT_COUNT |
BATCH_JOB_EXECUTION_CONTEXT | 可序列化上下文 | SERIALIZED_CONTEXT(短字符串), SHORT_CONTEXT(长文本) |
BATCH_STEP_EXECUTION_CONTEXT | Step 级别上下文 | 同上 |
BATCH_JOB_EXECUTION_PARAMS | 参数记录 | KEY_NAME, TYPE, VALUE, IDENTIFYING |
重启流程(文字时序):
1. JobOperator.start(Job, new JobParameters)
2. JobRepository 查询 BATCH_JOB_INSTANCE:
- 用 JOB_NAME + JOB_PARAMETERS 的哈希作为 JOB_KEY
- 如果匹配到已有实例,检查上次执行状态
3. 如上次 BATCH_JOB_EXECUTION.STATUS = FAILED:
- 创建新的 BATCH_JOB_EXECUTION,继承上次的上下文
- 找到失败的 Step,从 BATCH_STEP_EXECUTION 读取 READ_COUNT
- 恢复 StepExecutionContext 中的偏移量
4. ItemReader.open(ExecutionContext) 被调用:
- 从上下文中读取保存的游标/行号/页码
- 跳过已读取的记录,继续读取
5. 对于 COMPLETED 的 Step(多 Step Job 中,有的 Step 跑完了,有的没跑完):
- 已完成的 Step 直接跳过
- 从不完成的 Step 开始续跑
6. 全部完成后 BATCH_JOB_EXECUTION.STATUS 更新为 COMPLETED关键前提:ItemReader 必须支持重启态。JdbcCursorItemReader 需要设置 setSaveState(true) 并记录当前行号;JdbcPagingItemReader 则需要保证排序字段的稳定性——如果排序字段有重复值,分页边界会漂移,导致重启后漏读或重复读。我踩过这个坑:一张用户表按 create_time 排序,但同一秒有 1000 条数据入库,分页 reader 重启后读到了重复数据,最终插入了 2 万条重复记录。
JobParameters 的 Equals 陷阱:JobParameters 的 equals/hashCode 默认只比较 IDENTIFYING 为 true 的参数。如果两次调用传了同样的 identify 参数但其他参数不同,JobOperator 会认为这是同一个 JobInstance,不允许重启。这是面试常问的细节——要区分 JobInstance(参数唯一标识)和 JobExecution(每次运行)。
分区与多线程并行
当单线程处理几百万行不够快时,Spring Batch 提供两种并行方案:
多线程 Step(
TaskExecutor):一个 Step 内多个线程各自处理 Chunk,但共用同一个 Reader。这要求 Reader 是线程安全的,实际操作中风险较大——JdbcCursorItemReader不是线程安全的,多线程并发读会导致 Cursor 状态错乱。我见过有人用SynchronizedItemStreamReader包裹,但这样读变成了串行,并行度等于零。分区 Step(Partitioning):把数据切分成多个分区,每个分区交给独立的 Step 线程处理,每个分区有自己的 Reader/Processor/Writer。推荐方式:
@Bean
public Step partitionedStep(JobRepository jobRepository, PlatformTransactionManager tm,
ItemReader<Transaction> reader) {
return new StepBuilder("partitionedStep", jobRepository)
.partitioner("workerStep", partitioner()) // 分区逻辑
.gridSize(4) // 4 个线程
.taskExecutor(new SimpleAsyncTaskExecutor())
.build();
}
public Partitioner partitioner() {
return gridSize -> {
Map<String, ExecutionContext> partitions = new HashMap<>(gridSize);
// 假设 10 万条数据,按 ID 范围分成 4 份
int totalRows = 100000;
int batchSize = totalRows / gridSize; // 25000
for (int i = 0; i < gridSize; i++) {
ExecutionContext ctx = new ExecutionContext();
ctx.putInt("fromId", i * batchSize + 1);
ctx.putInt("toId", (i == gridSize - 1) ? totalRows : (i + 1) * batchSize);
partitions.put("partition" + i, ctx);
}
return partitions;
};
}分区后各 Worker Step 的事务是独立的,一个失败不影响其他分区,非常适合大数据量并行处理。但要注意:分区数不要超过数据库连接池的最大连接数,否则 Worker Step 会排队等连接,并行度打折扣。
大数据量调优要点
| 维度 | 关键配置 | 说明 | 真实案例 |
|---|---|---|---|
| Chunk 大小 | commit-interval 建议 500-2000 | 过小事务频繁(500 的 chunk 在 50 万数据下要提交 1000 次事务),过大事务锁范围大(超过 5000 条 MySQL 行锁升级为表锁) | |
| 读取 | JdbcPagingItemWriter 优于 JdbcCursorItemReader | Cursor 长期持有数据库连接(10 万条可能要几分钟),Paging 分页查询更可控;但 Paging 要求排序字段必须唯一,否则分页偏移量会漂移 | |
| 写 | 批量写替代逐条写 | JdbcBatchItemWriter 单次 batch commit 1000 条耗时约 50ms,逐条写 1000 次要 2-3 秒;MyBatisBatchItemWriter 类似 | |
| 事务 | READ_COMMITTED 隔离级别 | 避免脏读,也不至于 Serializable 的锁冲突;个别场景下(如统计报表)可以用 REPEATABLE_READ | |
| 异常跳过 | skipLimit + skipPolicy | 配置 skipLimit(10) 允许 10 条异常数据跳过,避免整批回滚;但需要记录跳过的行到日志,事后人工排查 | |
| 内存 | 不要 List 全部加载 | 用游标读取或分页,避免 50 万数据一次性加载到内存(大约 500MB 对象开销,GC 会频繁 Full GC) | |
| 间隔 | Chunk 之间的间隔 | 默认 chunk 完成后立即开始下一个,可以在 CompletionPolicy 中加 timeout 避免数据库压力集中在瞬间 |
常见坑点
坑 1:JobRepository 表结构未初始化 Spring Batch 自动建表依赖 spring.batch.jdbc.initialize-schema=always,但很多生产环境 DBA 不给建表权限。我有一次上线后 Job 一直报 Table 'BATCH_JOB_INSTANCE' doesn't exist,排查半天发现是 MySQL 用户没 CREATE 权限,手动在 schema-mysql.sql 里跑了一遍才解决。生产环境建议 DBA 提前跑一遍建表脚本,不要在线上让 Spring 自动建。
坑 2:Restartable 设置为 false 导致无法重启StepBuilder 默认 allowStartIfComplete(true),但很多教程教你 preventRestart() 来防止重复运行。如果设置了这个,任务失败后重新启动会直接跳过,不会从断点续跑。面试官会问:「如果任务失败了怎么恢复?」回答「改配置重启」是不行的——正确做法是 JobOperator.restart(executionId)。
坑 3:JobParameters 不区分导致无法运行第二次 JobParameters 默认所有参数都是 IDENTIFYING。如果同一个 Job 跑第二次(比如每月报表生成),必须加一个区分参数,比如 runDate。否则 Spring Batch 会认为第二次运行是重复的 JobInstance,直接拒绝。
坑 4:多线程 Step 的 Reader 线程安全问题JdbcCursorItemReader 不是线程安全的。如果使用多线程 Step,必须用 SynchronizedItemStreamReader 包裹,但这样读变成了串行锁,还不如单线程快。正确做法是用分区 Step,每个分区独立的 Reader。
与其他方案的对比
| 特性 | Spring Batch | 纯 SQL 脚本 | 自建 Chunk 循环 |
|---|---|---|---|
| 事务管理 | 自动 Chunk 级事务 | 手动控制 | 手动控制 |
| 断点续跑 | 内置(JobRepository) | 无 | 自建 checkpoint |
| 并行处理 | 分区/多线程 Step | 手动分片 | 无 |
| 监控 | 内置 JobRepository 表 | 无 | 自建日志 |
| 内存控制 | 游标/分页 Reader | 依赖数据库 | 无 |
| 学习成本 | 中等 | 低 | 低 |
总结
Spring Batch 的 Job/Step/Chunk 三层模型解决了批处理的核心问题:事务边界清晰、失败可恢复、并行可控。面试中要讲清楚 Chunk 和事务的关系(不是每条一个事务,是每批一个事务),以及断点续跑依赖 JobRepository 表结构和 Reader 的状态保存。实际生产调优的核心是 Chunk 大小、Reader 选型(游标 vs 分页)、分区并行度三件事。如果说不清「重启后怎么知道从哪接着跑」,那面试官会觉得你只写过 demo。
面试追问方向:
- JobRepository 表结构有哪几张?各字段作用?
- 分区处理的 Partitioner 实现,如果数据倾斜怎么处理?
- 重启后如何保证 Reader 不重复读?排序字段不唯一怎么办?
- 多线程 Step 的 Reader 线程安全问题怎么解决?
- Spring Batch 5.x 相比 4.x 的变化(主要是 Jakarta EE 迁移、@EnableBatchProcessing 废弃)
参考
参考:Spring Batch 官方参考文档(Chunk-oriented Processing、Configuring Step)、Spring Batch 5.0 Migration Guide、JobRepository 表结构(BATCH_* 系列表)