Skip to content

Spring Batch 批处理

提出问题

大批量数据处理(如月度报表生成、数据迁移、日志清洗)是后端开发绕不开的场景。如果直接用 for 循环逐条处理,碰上几十万条数据,内存会爆、事务会超时、失败得从头重来。Spring Batch 正是为了解决这些问题而生的——它提供事务管理、Chunk 式处理、断点续跑、并行分区等能力,是大数据量批处理的事实标准。面试问 Spring Batch 通常不是问怎么 Hello World,而是问它的架构模型、事务边界、如何调优、以及断点续跑的机制是否真的可靠。

分析问题

Job 与 Step 的分层模型

Spring Batch 的核心抽象是 Job → Step → Chunk → Item 四层。一个 Job 包含一到多个 Step,每个 Step 按 Chunk 逐批处理:

java
@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)            ← 这里是一个事务边界
→ 事务提交
→ 重复下一个 chunk

Chunk 机制是事务边界的关键:每批 1000 条用同一事务,失败时只回滚这一批,不会丢掉已处理的数据。这与逐条事务的开销对比鲜明——逐条事务每秒只能处理几百条,而 Chunk 1000 的批提交在 MySQL 下能到 5000-10000 条/秒。

断点续跑与 JobRepository

Spring Batch 的断点续跑不是「存个 checkpoint」那么简单。它依赖 JobRepository(默认用数据库表)记录每一步的执行上下文。底层表结构如下:

表名作用关键字段
BATCH_JOB_INSTANCEJob 实例定义JOB_NAME, JOB_KEY(由 JobParameters 哈希生成)
BATCH_JOB_EXECUTION每次运行记录STATUS(COMPLETED/FAILED/STOPPED), START_TIME, END_TIME
BATCH_STEP_EXECUTIONStep 级别状态STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, COMMIT_COUNT
BATCH_JOB_EXECUTION_CONTEXT可序列化上下文SERIALIZED_CONTEXT(短字符串), SHORT_CONTEXT(长文本)
BATCH_STEP_EXECUTION_CONTEXTStep 级别上下文同上
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 陷阱JobParametersequals/hashCode 默认只比较 IDENTIFYING 为 true 的参数。如果两次调用传了同样的 identify 参数但其他参数不同,JobOperator 会认为这是同一个 JobInstance,不允许重启。这是面试常问的细节——要区分 JobInstance(参数唯一标识)和 JobExecution(每次运行)。

分区与多线程并行

当单线程处理几百万行不够快时,Spring Batch 提供两种并行方案:

  1. 多线程 StepTaskExecutor):一个 Step 内多个线程各自处理 Chunk,但共用同一个 Reader。这要求 Reader 是线程安全的,实际操作中风险较大——JdbcCursorItemReader 不是线程安全的,多线程并发读会导致 Cursor 状态错乱。我见过有人用 SynchronizedItemStreamReader 包裹,但这样读变成了串行,并行度等于零。

  2. 分区 Step(Partitioning):把数据切分成多个分区,每个分区交给独立的 Step 线程处理,每个分区有自己的 Reader/Processor/Writer。推荐方式:

java
@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 优于 JdbcCursorItemReaderCursor 长期持有数据库连接(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。

面试追问方向:

  1. JobRepository 表结构有哪几张?各字段作用?
  2. 分区处理的 Partitioner 实现,如果数据倾斜怎么处理?
  3. 重启后如何保证 Reader 不重复读?排序字段不唯一怎么办?
  4. 多线程 Step 的 Reader 线程安全问题怎么解决?
  5. Spring Batch 5.x 相比 4.x 的变化(主要是 Jakarta EE 迁移、@EnableBatchProcessing 废弃)

参考

参考:Spring Batch 官方参考文档(Chunk-oriented Processing、Configuring Step)、Spring Batch 5.0 Migration Guide、JobRepository 表结构(BATCH_* 系列表)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。