
做数据迁移和批处理的时候,踩过坑的团队基本都经历过从"Crontab + 脚本一把梭"到"引入专业框架"的阵痛。数据量小的时候怎么折腾都行,一旦订单量破千万、日志文件上 GB,内存溢出、中断后不知道跑哪儿了、一条脏数据卡死整个任务这些问题就会集中爆发。Spring Batch 在 Spring 生态里算是个"重武器",它的分层抽象、Chunk 事务边界和内置的状态持久化,确实能把这些脏活累活规范化。下面直接进正题,聊聊在生产环境里怎么用 Spring Batch 搭一套能扛事、好排查的批处理管线。
1. 为什么传统脚本扛不住海量数据
早年团队喜欢用 Linux Crontab 调个 Java 或 Shell 脚本,数据量少的时候跑得挺欢,但规模一上来,底层缺陷根本藏不住:
- OOM 是常态 :很多人图省事,直接
SELECT *或者File.readAllBytes()把数据全塞进内存。JVM 堆一打满,应用直接 Crash,连日志都来不及吐。 - 状态黑盒:脚本中途挂了,根本不知道处理到第几条。哪些入库了?哪些还在半事务?只能靠人工翻日志或查业务表,恢复成本极高。
- 容错全靠硬编码:遇到格式错误或唯一键冲突,整个任务直接中断。重试逻辑写死在循环里,没有退避策略,瞬间打满数据库连接池。
- 扩容困难:单节点串行跑,遇到几十 GB 的文件或亿级表,耗时直接拉胯。想水平扩展得自己写分片逻辑,容易引入数据倾斜或重复消费。
批处理的核心诉求其实就四点:流式读写保内存、分片并行提吞吐、Chunk 切事务边界、元数据落库保断点续传。Spring Batch 的设计正好踩在这些点上。
2. 核心模型:Job / Step / Chunk 到底在干什么
Spring Batch 把批处理拆得很干净,Job → Step → Chunk 是它的骨架,Reader → Processor → Writer 是数据流转的血脉。
2.1 分层职责
- Job:顶层容器,代表一次完整的业务流。里面可以串多个 Step,定义执行顺序和重启策略。
- Step:实际干活的单元。事务控制、容错策略、监听器都是挂在 Step 上的。
- Chunk :这才是 Spring Batch 的灵魂。它不是指数据块大小,而是事务提交边界 。比如配了
chunk(1000),框架会读 1000 条 → 走 Processor → 交给 Writer → 统一提交事务 → 记录进度。这种"微批提交"直接砍掉了长事务带来的锁竞争和 Undo 日志膨胀问题。
2.2 数据流转管道
- ItemReader :只负责读。每次
read()吐一条记录,底层必须是无状态或游标驱动的。千万别自己搞全量查询塞 List,框架自带的 Reader 都是按需拉取的,内存占用恒定。 - ItemProcessor :做转换、过滤、校验。返回
null就自动丢弃该条,不往下走。这里尽量保持轻量,别塞重型计算或阻塞 I/O,否则会把整个 Step 拖慢。 - ItemWriter:负责写。一次接收一个 List(长度就是 Chunk Size),批量刷到目标端。Writer 自己不碰事务,全由 Step 代理提交。
这套模型之所以稳,是因为内存里永远只扛着固定数量的对象。处理完一批就清掉,O 级别的内存开销,彻底跟 OOM 绝缘。
3. 生产级配置实战
现在基本都用 Spring Boot Starter 集成。下面直接上分片、大文件流读和 JDBC 批量写入的配置,附带线上调过参数。
3.1 分片并行(Partitioner)
单表破亿或者文件超 50GB,单线程肯定不够看。Spring Batch 的 PartitionStep 可以把数据切块,分给多个 Worker 跑。
java
@Bean
public Step partitionedStep(PartitionHandler partitionHandler) {
return stepBuilderFactory.get("partitionedStep")
.partitioner("workerStep", rangePartitioner())
.partitionHandler(partitionHandler)
.build();
}
@Bean
public Partitioner rangePartitioner() {
return gridSize -> {
// 实际项目里建议从 DB 查 min/max ID,这里简化示意
long minId = 1L;
long maxId = 10000000L;
long range = (maxId - minId + 1) / gridSize;
Map<String, ExecutionContext> map = new HashMap<>();
for (int i = 0; i < gridSize; i++) {
ExecutionContext ctx = new ExecutionContext();
ctx.putLong("startId", minId + i * range);
ctx.putLong("endId", (i == gridSize - 1) ? maxId : minId + (i + 1) * range - 1);
map.put("partition_" + i, ctx);
}
return map;
};
}
配合 TaskExecutorPartitionHandler 和线程池,就能在单机内多线程跑分片。如果节点多,结合 Spring Cloud Data Flow 或 K8s Job,把 ExecutionContext 序列化下发,就能实现跨节点并行。
3.2 大文件流式读取
CSV 或文本文件解析,必须走流。FlatFileItemReader 底层就是 BufferedReader,每次只读一行,内存几乎不涨。
java
@Bean
public FlatFileItemReader<UserRecord> flatFileItemReader() {
FlatFileItemReader<UserRecord> reader = new FlatFileItemReader<>();
reader.setResource(new FileSystemResource("/data/export_2023.csv"));
reader.setLinesToSkip(1); // 跳过表头
DefaultLineMapper<UserRecord> lineMapper = new DefaultLineMapper<>();
lineMapper.setLineTokenizer(new DelimitedLineTokenizer(","));
BeanWrapperFieldSetMapper<UserRecord> fieldMapper = new BeanWrapperFieldSetMapper<>();
fieldMapper.setTargetType(UserRecord.class);
lineMapper.setFieldSetMapper(fieldMapper);
reader.setLineMapper(lineMapper);
reader.setStrict(false); // 文件不存在时不直接抛异常,方便幂等重试
return reader;
}
遇到编码乱码或者自定义分隔符,自己实现个 LineTokenizer 就行。注意大文件务必保证 Reader 和 Writer 的线程安全,Spring Batch 的 Reader 默认是单线程安全的,分片模式下每个分片会拿到独立的 Reader 实例。
3.3 JDBC 批量写入调优
JdbcBatchItemWriter 的性能卡点通常在网络往返和驱动层。配置本身不复杂,但参数不对吞吐差好几倍。
java
@Bean
public JdbcBatchItemWriter<UserRecord> jdbcBatchWriter(DataSource dataSource) {
JdbcBatchItemWriter<UserRecord> writer = new JdbcBatchItemWriter<>();
writer.setDataSource(dataSource);
writer.setSql("INSERT INTO users (id, name, age, created_at) VALUES (:id, :name, :age, :created_at)");
writer.setItemSqlParameterSourceProvider(new BeanPropertySqlParameterSourceProvider());
writer.setAssertUpdates(true); // 校验影响行数,防止静默失败
return writer;
}
线上实测有效的几个点:
- Batch Size 对齐 :Writer 内部调用
addBatch()的次数默认跟 Chunk Size 一致。建议 Chunk 设在500~2000之间。设太大 JDBC 驱动层的内部缓冲区会撑爆,GC 频繁。 - 关闭主键回填 :除非业务强依赖自增 ID 回写,否则别用
GeneratedKeyHolder。关掉RETURN_GENERATED_KEYS,MySQL 场景下能稳定提升 30% 以上的吞吐。 - 连接池与 URL 参数 :HikariCP 的
maximumPoolSize得跟分片线程数对齐,别省连接。MySQL JDBC URL 必须带rewriteBatchedStatements=true,不然驱动会把批量 SQL 拆成单条发,白白浪费带宽。
4. 容错与断点续传
生产环境的数据从来都不干净,框架得能优雅兜底。
4.1 Skip 与 Retry 的配置逻辑
- Retry:对付瞬时故障,比如网络抖动、DB 锁等待、下游服务偶尔超时。配指数退避,别死循环重试。
- Skip:对付脏数据,比如格式解析失败、违反唯一约束。记个日志或扔死信队列,任务继续往前跑。
java
@Bean
public Step faultTolerantStep(ItemReader<User> reader, ItemWriter<User> writer, PlatformTransactionManager tm) {
return stepBuilderFactory.get("faultTolerantStep")
.<User, User>chunk(1000, tm)
.reader(reader)
.writer(writer)
.faultTolerant()
.retryLimit(3)
.retry(DeadlockLoserDataAccessException.class, ResourceAccessException.class)
.skipLimit(50)
.skip(DataIntegrityViolationException.class, IllegalArgumentException.class)
.listener(new CustomSkipListener()) // 把跳过的记录落地到死信表,事后人工核对
.build();
}
注意:skipLimit 和 retryLimit 是并存的。框架会先重试,达到上限后再走 Skip。别把 Skip 策略写得太宽泛,否则脏数据全漏过去,下游对账能哭死。
4.2 事务边界与避坑
Spring Batch 的事务由 Step 全权代理。Chunk 提交前,Reader 和 Writer 的操作在同一个事务里。中途报错,整个 Chunk 回滚,元数据表不会更新,完美保证原子性。
血泪经验 :绝对不要在 Processor 里手动 @Transactional 或者调 DataSource 开新事务。这会打断框架的代理链,导致 JobRepository 里的进度更新失败,断点续传直接失效。Processor 只负责纯计算或轻量校验。
4.3 断点续传的正确姿势
Spring Batch 把执行状态存在 6 张 BATCH_* 表里。重启时,框架去表里捞上次成功的 Chunk 偏移量,接着读。
这里有个很多人搞错的细节:断点续传依赖相同的 JobParameters 。JobInstance 的标识是 JobName + JobParameters。如果你每次启动都加个时间戳:
java
JobParameters params = new JobParametersBuilder()
.addLong("triggerTime", System.currentTimeMillis()) // ❌ 每次参数不同,框架会当成新任务,无法续跑
.addString("source", "order_center")
.toJobParameters();
这样永远跑的是新实例。想续传,要么传一模一样的参数,要么通过 JobExplorer 查出上次挂掉的 JobExecution,调用 jobLauncher.restart(jobExecution)。如果确实需要每次生成新实例但又想支持手动回滚,建议在业务表层面做幂等(INSERT IGNORE 或 ON DUPLICATE KEY UPDATE),别死磕框架状态。
5. 线上调优与可观测性
架构搭起来只是第一步,线上稳不稳,全看调优和监控。
5.1 内存与游标选择
分页读取(JpaPagingItemReader)在深分页时 LIMIT offset, size 会越拖越慢,而且如果排序键不唯一,容易漏数据或重复。很多人想换游标(JdbcCursorItemReader),但游标会一直占用数据库连接,Step 跑半小时,连接就占半小时,连接池很容易被打满。
我的建议是:数据量在千万级以内,用分页但务必加唯一排序键(比如主键 ID);超大规模迁移优先用游标,但配合 FetchSize 控制(MySQL JDBC 默认是 Integer.MIN_VALUE 流式拉取,别改),并且单独给 Reader 配个连接池,别跟 Writer 抢连接。
5.2 长事务与批大小动态化
别把 chunk 大小写死。业务高峰期数据库压力大,Chunk 设 500 能减少锁持有时间;夜间低峰期拉到 2000,减少事务提交开销。可以通过 StepListenerSupport 结合 Prometheus 指标动态调整,或者干脆用两个不同参数的 Step 错峰执行。
写入前如果表里有一堆非唯一索引,归档场景下可以临时 ALTER TABLE ... DISABLE KEYS,写完再 ENABLE KEYS,重建索引比逐行维护快得多。当然,线上核心交易表别这么玩,只适用于日志/流水类归档表。
5.3 监控大盘怎么接
Spring Boot Actuator 原生暴露了 Batch 的 Micrometer 指标,直接接 Grafana 就行。
yaml
management:
endpoints:
web:
exposure:
include: health,metrics
metrics:
tags:
application: data-sync-service
重点盯这几个指标:
spring.batch.job.execution.count:任务触发频次spring.batch.step.read.count/write.count:读写量对齐,排查数据丢失spring.batch.step.skip.count:跳过率突增通常是上游数据源出了问题- 耗时分布:通过自定义 Timer 记录 Processor 阶段耗时,80% 的瓶颈都在数据转换逻辑太重。
配个告警规则,Skip 率超过 5% 或者 Job 状态变 FAILED,直接推钉钉/企微。别等对账发现少了数据才去查日志。
6. 选型建议与调度协同
Spring Batch 不是万能药,得看场景用。
如果是夜间 ETL、跨库同步、大文件清洗这类离线任务,Spring Batch 的事务控制和状态机机制非常顺手。但如果是秒级高频微批,或者需要滑动窗口聚合的实时流,硬塞 Spring Batch 只会适得其反。它的元数据落库和 Chunk 状态机本身就有开销,延迟下不去。这种场景直接上 Kafka Streams 或 Flink 更合适,轻量级任务用定时任务+消息队列就能搞定。
生产环境的标准打法是"调度层 + 执行层"解耦 :
调度层交给 XXL-JOB、Airflow 或 DolphinScheduler。它们负责任务依赖编排、分片参数下发、全局失败重试和告警。执行层用 Spring Batch 接收调度器传来的 shardIndex 和 shardTotal,通过 Partitioner 切分数据范围,独立跑流式处理,自己管 Chunk 事务和 JobRepository 状态。调度器看宏观进度,Batch 管微观数据流转,两边通过 Webhook 或 MQ 对齐状态。这套组合拳打下来,数据管道基本能做到"调度可管、执行可控、断点可续、异常可溯"。
框架只是工具,真正决定稳定性的是对事务边界、数据流向和失败场景的预判。把 Chunk 粒度调对,把 Skip 策略写准,把调度与执行拆干净,批处理就不再是定时炸弹。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
