上篇把Spring Batch的基础概念捋完之后,我以为搭建一个能跑的项目是水到渠成的事。结果从选Spring Boot版本开始,我就被网上五花八门的教程卡了一整个周末——有的用2.1,有的用2.7,有的直接上3.x,代码里JobBuilderFactory一会儿能用一会儿报红,光是消除这些差异就消耗了大量耐心。这篇自学笔记就把从零搭建一个可运行Spring Batch项目的完整过程写下来:版本怎么选、最小依赖怎么配、第一个读CSV写数据库的Job怎么写、怎么把Job跑起来,以及我实际踩过的几个坑。如果你刚看完概念准备动手搭自己的第一个项目,这篇文章可以直接照着走。
1. 环境准备与工程骨架:版本选型和依赖清单
1.1 JDK与Spring Boot版本组合怎么选
先说版本,这是自学路上第一个大坑。Spring Batch本身不挑运行环境,但你几乎不会单独用裸的Spring Batch,都是通过Spring Boot的starter引入。当前主流组合有两种:JDK 8 + Spring Boot 2.7.x和JDK 17 + Spring Boot 3.2.x。
我个人的建议是:如果你是自学新项目,直接选JDK 17加Spring Boot 3.2.x。原因很简单——Spring Boot 3.x是当前官方主推的方向,网上老教程里大量出现的JobBuilderFactory、StepBuilderFactory在新版本中已经标记为废弃,早晚要淘汰。与其先学一套马上要被替换的API,不如从一开始就用新写法。如果公司环境锁定了JDK 8,那再回头用2.7.x也不迟,但学习成本会多一份“新旧API对照”的开销。
| 组合 | 适合场景 | 常见问题 |
|---|---|---|
| JDK 8 + Spring Boot 2.7.x | 企业存量项目、老系统改造 | 网上资料多但API偏旧,学完迁移有成本 |
| JDK 17 + Spring Boot 3.2.x | 新项目、自学练手 | 新写法资料相对少,但官方文档结构清晰 |
我在搭建时用的是Spring Boot 3.2.5加JDK 17。这个组合下,安全补丁、依赖兼容性都处于活跃维护状态,踩坑时更容易找到官方答案。
1.2 最小依赖清单与各自动能
搭建Spring Batch项目其实不需要引入太多东西。为了跑通一个最基础的Demo,下面这几个依赖就够了。过多引入反而会让新手分不清哪些是必要的,哪些只是为了方便。
| 依赖 | 作用 | 备注 |
|---|---|---|
spring-boot-starter-batch | 批处理核心,包含Spring Batch框架和自动配置 | 必选 |
spring-boot-starter-jdbc | JDBC与数据源支持 | JobRepository需要连接数据库 |
com.h2database:h2 | 内存数据库 | 开发阶段零配置,启动自动建表 |
org.projectlombok:lombok | 减少实体类样板代码 | 可选,不习惯可以手写getter/setter |
spring-boot-starter-test | 编写测试 | 可选,建议加上 |
一个容易困惑的点是:为什么批处理项目需要数据库?因为Spring Batch要把每一个Job和Step的执行状态、启动时间、结束时间、退出状态都持久化下来。没有数据库,这些元数据没地方存。但数据库不一定非要用你业务系统的MySQL或PostgreSQL,开发阶段直接用H2内存库最省事,应用一启动表就自动建好,应用关闭数据清空,非常适合练手。
1.3 手动创建的工程目录
我不推荐用IDE的Spring Initializr向导时把一堆依赖一次勾满,尤其是spring-boot-starter-web,后面我会讲到它带来的坑。你先建一个最朴素的工程,目录结构大致是这样:
spring-batch-demo/ ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/example/batchdemo │ │ │ ├── BatchDemoApplication.java │ │ │ ├── config/BatchConfig.java │ │ │ ├── processor/PersonItemProcessor.java │ │ │ ├── runner/DemoJobRunner.java │ │ │ └── model/Person.java │ │ └── resources │ │ ├── application.yml │ │ ├── schema.sql │ │ └── person.csv └── targetpom.xml里最核心的部分如下。注意H2要加runtime作用域,它只在运行期使用,不影响编译期依赖。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-batch</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>工程骨架到位后不要急着写业务代码,先把应用启动一次。你会发现控制台里已经出现大量以BATCH_开头的建表SQL,这说明Spring Boot的批处理自动配置已经开始工作了。
2. 自动配置这件事:JobRepository、JobLauncher与五张元数据表
2.1 @EnableBatchProcessing背后发生了什么
很多人照着教程在配置类上敲一行@EnableBatchProcessing,但并不知道这行注解触发了什么。简单说,它是Spring Batch的“总开关”。加上它之后,Spring Boot会帮你装配一批批处理基础设施,其中最核心的两个是JobRepository和JobLauncher。
JobRepository负责所有作业元数据的持久化。你可以把它理解成“航班调度日志”:每次飞行任务的名称、计划起飞时间、实际起飞时间、是否平安落地,全部记录在案。没有这个仓库,批处理框架就无法回答“上次任务跑到哪一步了”“是不是需要重启”这类问题。
JobLauncher则是触发器的角色。它是执行入口,接收一个Job实例和一组JobParameters,然后启动整个作业流程。在Spring Boot项目中,你需要执行的Job会自动注册到容器里,通过JobLauncher.run()就能手动拉起。
这两个基础组件的创建过程在Spring Batch 5中已经高度自动化。你只要在配置类上标注@EnableBatchProcessing,框架就会根据你的数据源自动创建实现类。这也是为什么搭建过程这么快的原因——对新手来说,你只需要关心Job和Step怎么组装,底层的生命周期管理先交给框架。
2.2 为什么开发阶段选H2内存库
Spring Batch的元数据表结构是固定的,框架启动时会自动检测数据库类型,然后执行对应的建表脚本。H2在开发阶段最大的优势是“零准备”:不需要安装服务,不需要创建数据库,不需要配置账号密码,应用启动后一切自动完成。
如果你换成MySQL,至少要保证数据库实例可用、连接串正确、账号权限足够,还要处理字符集问题。这些和Spring Batch本身完全无关,却会消耗大量排查时间。所以我的建议是:第一个Demo务必用H2,跑通了之后再按需切换到真实数据库。
H2有个隐藏价值:你可以把它的连接地址从jdbc:h2:mem:testdb改成jdbc:h2:file:./batch-data,这样元数据会持久化到本地文件。重启应用后,之前跑过的Job记录依然存在,你就能直观地观察到Spring Batch对“重复执行同一Job”的限制。这个点后面讲Job参数时会详细说。
2.3 Spring Batch自动创建的五张核心表
不管用哪种数据库,框架启动后都会出现一组以BATCH_开头的表。它们的职责分别是:
| 表名 | 职责说明 |
|---|---|
BATCH_JOB_INSTANCE | 作业实例表,记录Job名称和Job参数的关联关系,每次“业务含义”不同的执行对应一条记录 |
BATCH_JOB_EXECUTION | 作业执行表,记录某次Job实例的每次实际执行过程、状态、起止时间 |
BATCH_JOB_EXECUTION_PARAMS | 作业参数表,保存每次执行的参数快照,便于追溯“当时用什么参数跑的” |
BATCH_JOB_EXECUTION_CONTEXT | 作业上下文表,保存执行过程需要跨步骤共享的数据 |
BATCH_STEP_EXECUTION | 步骤执行表,记录每个Step的运行详情,包括读了多少条、写了多少条、有哪些异常 |
这些表是Spring Batch的“病历本”。任务跑挂之后,你排查的第一步往往不是看业务日志,而是查这些表里的状态字段和异常信息。这也是批处理和普通接口开发最大的区别——普通人关注的是数据是否处理完,开发者和运维更关心的是“这次任务为什么跑到一半停了”。
需要特别注意:不要手动修改这些表的结构。框架对字段的依赖很强,擅自调整列定义可能导致状态恢复、重启续跑等功能失效。我就是一开始图省事删过几条记录,结果后续任务状态全乱了,最后只能重建库重跑。
3. 第一个三段式Job:CSV读取、Processor转换、批量写入数据库
3.1 场景设计与数据类定义
现在动手写第一个真正有业务含义的Job。场景不要设计得太复杂,但必须完整覆盖Spring Batch的三段式核心流程:读、处理、写。我选了一个最经典的例子:读取person.csv文件里的用户数据,过滤掉未成年的记录,然后把剩余记录批量写入数据库的person表。
CSV文件内容大致是这样:
id,name,age 1,张三,25 2,李四,17 3,王五,30 4,赵六,16对应的实体类可以使用Lombok简化:
@Data public class Person { private Integer id; private String name; private Integer age; }这里有个小细节:实体类的字段类型和CSV中的文本之间是如何转换的?Spring Batch的BeanWrapperFieldSetMapper会自动把字符串类型的字段值按目标类型转换。比如CSV里的25会被映射为Integer类型的age。你要是字段类型定义不当,比如写成String age,后面的比较逻辑就会变得很别扭。
3.2 ItemReader:FlatFileItemReader读CSV
读取CSV是批处理中最常见的输入方式。Spring Batch提供了FlatFileItemReader,配合FlatFileItemReaderBuilder可以快速完成配置:
@Bean @StepScope public FlatFileItemReader<Person> personItemReader() { return new FlatFileItemReaderBuilder<Person>() .name("personItemReader") .resource(new ClassPathResource("person.csv")) .encoding("UTF-8") .delimited() .names("id", "name", "age") .targetType(Person.class) .build(); }这段配置里,最容易出问题的是encoding("UTF-8")。很多老教程没有这一行,在Windows环境下运行就会出现中文乱码,原因是FlatFileItemReader默认编码并不保证是UTF-8。我的建议是无论什么平台,都把字符集显式写出来,不要依赖环境默认值。
还有一个关键点是@StepScope。这个注解表示Reader在Step执行时才创建,而不是应用启动时就创建。为什么要这样?因为ClassPathResource指向的CSV文件路径可以通过JobParameters动态传入,如果用默认的单例作用域,参数还没来得及绑定,Bean就已经创建好了,动态路径就失效了。这个设计在后面做“按日期读取不同文件”的场景时会用到。
3.3 ItemProcessor:过滤和转换
ItemProcessor是三者中唯一可选的环节。它接收一条数据,返回一条数据,返回null时表示“这条数据不要了,跳过它”。这个语义很容易被新手忽视——你不需要在Reader里判断条件,只需要在Processor里返回null即可。
public class PersonItemProcessor implements ItemProcessor<Person, Person> { @Override public Person process(Person person) throws Exception { if (person.getAge() < 18) { return null; } person.setName("[" + person.getName() + "]"); return person; } }在这个Demo中,我同时做了两件事:过滤掉未成年用户,并给姓名加上前缀标记。这样的好处是一个Processor演示了两种常见操作:数据筛选和字段加工。实际项目里,Processor还经常承担数据清洗、字段校验、调用外部服务补充信息等职责。
有一点必须提醒:不要在process()方法里修改没有必要的共享状态。Spring Batch默认是单线程逐条处理,但如果你开启了多线程Step,Processor可能被多个线程并发调用。这时候如果在里面维护一个非线程安全的聚合对象,就会出现数据错乱。
另外,process()方法抛出的异常会导致当前chunk事务回滚,这一点和Reader、Writer是一样的。所以不要在Processor里吞掉异常然后返回null试图“跳过”,否则你根本不知道数据哪里有问题。
3.4 ItemWriter:JdbcBatchItemWriter批量入库
写端的效率直接决定批处理任务的整体吞吐量。Spring Batch最常用的写方式是JdbcBatchItemWriter,它会把当前批次的数据攒在一起,用JDBC的批量更新一次性发送给数据库。
@Bean public JdbcBatchItemWriter<Person> personItemWriter(DataSource dataSource) { return new JdbcBatchItemWriterBuilder<Person>() .dataSource(dataSource) .sql("INSERT INTO person(id, name, age) VALUES (:id, :name, :age)") .beanMapped() .build(); }beanMapped()的作用是告诉框架:SQL里的:id、:name、:age这些命名参数,去实体类里找同名的属性取值。如果你用Map方式,则可以在ItemWriter之前把数据转换成Map<String, Object>。
写库之前,必须确保person表存在。我在resources/schema.sql里放了建表语句:
CREATE TABLE IF NOT EXISTS person ( id INT PRIMARY KEY, name VARCHAR(50), age INT );Spring Boot默认不会自动执行schema.sql,需要在application.yml里开启:
spring: sql: init: mode: always这个配置的意思是:每次应用启动时都执行SQL初始化脚本。需要注意,如果你同时引入了多个SQL脚本文件,执行顺序要自己确认好。这里我只有person表的建表语句,所以没问题。
4. Job与Step的装配逻辑:Builder、Chunk边界和Job参数唯一性
4.1 Job和Step的Builder装配方式
有了Reader、Processor、Writer三个组件之后,接下来要把它们组装成Step,再把Step组装成Job。这一步容易写错,尤其是在Spring Boot 3.x中,老写法已经不适配。
@Configuration @EnableBatchProcessing public class BatchConfig { @Bean public Job demoJob(JobRepository jobRepository, Step demoStep) { return new JobBuilder("demoJob", jobRepository) .start(demoStep) .build(); } @Bean public Step demoStep(JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReader<Person> reader, ItemProcessor<Person, Person> processor, ItemWriter<Person> writer) { return new StepBuilder("demoStep", jobRepository) .<Person, Person>chunk(10, transactionManager) .reader(reader) .processor(processor) .writer(writer) .build(); } }这段代码有两个关键点。第一,JobBuilder和StepBuilder的构造方法都要求传入JobRepository,这是Spring Batch 5的新设计:明确把元数据仓库作为Builder的依赖,而不是靠全局状态。第二,Step名称和Job名称都要唯一。如果你不小心把两个Step都命名为demoStep,框架会在启动时抛出校验异常。网上的示例代码经常出现这种“多份示例拼在一起”后的重名问题,我在第6章会专门展开。
4.2 chunk(10)不只是“十条一提交”
chunk(10)可能是Spring Batch里最被低估的配置项。直观理解是每10条记录一个批次,但背后的含义远不止于此。
Spring Batch的默认事务边界就是“一个Chunk一个事务”。框架的工作流程是:循环执行Reader读取数据,攒够一个Chunk大小后停下来,然后逐个调用Processor和Writer,最后提交事务。注意Reader读取到文件末尾时要返回null,表示读取结束。
假设你设置了chunk(10),读到了第7条时数据库暂时不可用,那这7条数据的处理会整体回滚,不会出现“前3条成功、后4条失败”的半截状态。这就是批处理最重要的可靠性保障。
那么chunk设置多大合适?取决于你的业务和数据量。值太小,事务提交频繁,性能上不去;值太大,单条数据出错会导致大批量回滚,耗时增加。开发阶段建议先用10或20,观察日志确认链路无误后再调大。我看过有人第一个项目就把chunk调到5000,结果一条脏数据进来,整个批次回滚,排查了半天才发现是某条记录里有不可见特殊字符。
4.3 JobBuilderFactory为什么在新版中失宠
如果你在自学时翻了大量老教程,一定会对jobBuilderFactory.get("demoJob")非常熟悉。这个写法在Spring Batch的早期版本中很常见,但在Spring Batch 5中已经被标记为废弃。原因是框架希望你通过JobRepository直接创建Builder,而不是依赖一个持有全局状态的工厂Bean。
我专门在这篇文章里提这个点,是想给自学者提个醒:看教程时先看发布时间和依赖版本。一个2023年之前写的Spring Batch教程,很大概率默认是Spring Boot 2.x,代码拿过来放到Spring Boot 3.x里,第一行就报红。不是你的问题,也不是教程错了,是版本演进带来的API变化。遇到这种情况,最快的处理方式就是参考官方文档里对应版本的示例改写。
4.4 Job参数与JobInstance唯一性:为什么第二次运行会报错
Job参数是Spring Batch里一个非常容易让人困惑的机制。同一个Job,每次运行都可以带上不同的参数,比如业务日期、文件路径、批次号。这些参数会和Job名称一起,决定这次运行对应的是哪个JobInstance。
规则是:同一个Job的名称加上完全相同的Job参数,只能对应一个JobInstance。如果这个JobInstance已经执行过且状态不是FAILED,你再提交相同参数,框架会直接拒绝,提示“A job instance already exists and is not currently running”。
这背后的逻辑是:批处理任务讲究幂等,同一份参数意味着同一份业务数据,重复跑会产生重复结果。H2内存模式下应用重启后表数据清空,你可能感受不到这个问题;但换成H2文件模式或者MySQL,第二次启动如果参数没变,就会立刻撞上。
解决办法很简单:每次运行时让参数发生变化。最朴素的方案是加上时间戳:
JobParameters params = new JobParametersBuilder() .addString("runTime", LocalDateTime.now().toString()) .toJobParameters();这种方式给了每个JobExecution一个唯一的参数快照,让它成为一个全新的JobInstance。生产环境中更常见的做法是使用JobParametersIncrementer,或者传入业务日期作为参数,保证日报、月报任务在每天运行时都有不同的参数。
5. 把Job真正跑起来:启动方式、日志要点和执行状态判断
5.1 通过ApplicationRunner在启动时执行
Spring Boot应用启动后,Job并不会自己跑起来。常见做法是实现ApplicationRunner接口,在应用初始化完成后通过JobLauncher手动触发Job。
@Component public class DemoJobRunner implements ApplicationRunner { private final JobLauncher jobLauncher; private final Job demoJob; public DemoJobRunner(JobLauncher jobLauncher, @Qualifier("demoJob") Job demoJob) { this.jobLauncher = jobLauncher; this.demoJob = demoJob; } @Override public void run(ApplicationArguments args) throws Exception { JobParameters params = new JobParametersBuilder() .addString("runTime", LocalDateTime.now().toString()) .toJobParameters(); jobLauncher.run(demoJob, params); } }这里有个细节:注入Job时要加@Qualifier("demoJob")。为什么?因为Spring Boot工程里可能同时存在多个Job Bean,不指定名称的话,按类型注入会因存在多个候选而启动失败。如果你只有一个Job,不写@Qualifier也能通过,但为了工程后续扩展,还是建议一开始就写清楚。
还有一种方式是使用Spring Boot默认注册的JobLauncherApplicationRunner。当检测到Job类型Bean存在时,它会在应用启动后自动执行发现的Job。如果不需要自动执行,可以在application.yml中关闭:
spring: batch: job: enabled: false实际开发中,我倾向于关闭自动执行,改用ApplicationRunner手动控制。因为你总有需要跳过Job启动、只起应用调试其他逻辑的时候,自动执行会让这种调试变得很烦。
5.2 从IDEA传入任务参数
在IDE里运行应用时,你可以通过运行配置的Program arguments传递命令行参数。Spring Boot的命令行参数规则是双横线加参数名。例如:
--job.name=demoJob如果开启了spring.batch.job.enabled,并且配置了--job.name,框架会启动对应名称的Job。配合--spring.batch.job.enabled=true,你可以通过命令行决定跑哪个Job。不过对于新手,我还是建议先在DemoJobRunner里写死Job名称,链路跑通后再研究动态调度。
5.3 日志里要看的几行关键信息
第一次运行成功后,控制台日志里最重要的几行大概长这样:
Job [FlowJob: [name=demoJob]] launched with the following parameters: [{runTime=2025-01-01T10:00:00}] Executing step: [demoStep] Step: [demoStep] executed in 298ms Job [FlowJob: [name=demoJob]] completed with the following parameters: [{runTime=2025-01-01T10:00:00}] and the following status: [COMPLETED]这些日志的含义非常直观:
| 日志片段 | 含义 |
|---|---|
launched with the following parameters | 本次作业启动时携带的参数快照 |
Executing step: [demoStep] | 当前开始执行哪个Step |
Step: [demoStep] executed in 298ms | Step执行耗时 |
status: [COMPLETED] | 最终状态,COMPLETED表示成功 |
判断一个任务是否成功,不要只看控制台有没有报错,要重点看状态是否为COMPLETED。FAILED状态也不一定代表所有数据都白跑了,这取决于Spring Batch的重启策略,后续我会专门写一篇关于状态恢复和断点续跑的文章。
如果想看更细致的读写数量,可以在application.yml里调整日志级别:
logging: level: org.springframework.batch: DEBUG打开DEBUG日志后,每一步的读取条数、处理成功数、写入失败数都会显示出来。定位“为什么某一步一条数据都没处理”这类问题时,这套日志信息能省大量时间。
5.4 第一次运行的验证方法
日志显示COMPLETED之后,怎么确认数据真的写进了数据库?H2内存库在应用结束后数据就没了,所以要在应用运行期间查看。最简单的办法是在DemoJobRunner的run方法最后加一行代码,用JdbcTemplate查询person表并打印结果,例如:
List<Map<String, Object>> result = jdbcTemplate.queryForList("SELECT * FROM person"); result.forEach(System.out::println);这时候你会看到库里只有两行数据:张三和王五。李四和赵六因为年龄小于18,被Processor过滤掉了。这行输出是验证整个读、处理、写链路的最好方式。
如果你更愿意用可视化工具,可以把H2连接改为TCP模式,然后在浏览器里打开H2 Console查表。不过对第一个项目来说,JdbcTemplate打印已经足够直观。
6. 搭建期踩坑记录:乱码、自动执行与重复运行
6.1 中文乱码:FlatFileItemReader的默认编码坑
我在第3章提过编码问题,这里展开一下具体场景。第一次在Windows上跑Demo,CSV里的中文名字全部变成了乱码,而程序本身没有任何报错。排查时看了一眼FlatFileItemReader的源码,发现它内部读取文件时使用的Reader默认编码并不是我预期的UTF-8。
处理方式就是在构建Reader时显式设置encoding("UTF-8")。更进一步,如果你要让编码可配置,可以把编码值放到配置文件中,通过@Value注入。这样换环境时不需要改代码,只改配置就行。编码问题属于那种“不报错但结果错”的隐蔽问题,在批处理任务里尤其危险,因为数据一旦写入数据库,脏数据就要靠后续脚本清洗。
6.2 Job自动执行导致的意外运行
spring-boot-starter-batch引入之后,如果你同时引了spring-boot-starter-web,启动应用时经常出现“还没等手动触发,Job自己跑完了”的现象。原因是Spring Boot自动注册了JobLauncherApplicationRunner,它会在应用启动后自动发现并执行所有可执行的Job。
这个行为对纯批处理任务来说是便利,但对“边开发边调试”的模式就很烦。我的建议是尽早关闭自动执行:
spring: batch: job: enabled: false然后通过自己的Runner统一管理Job触发时机。这样应用启动不跑任何批处理任务,需要跑的时候手动触发,行为更可控。等到后面需要定时调度时,再用调度框架去调用同一个JobLauncher。
6.3 Job和Step名称重复导致的校验异常
自学阶段最容易遇到“把网上几个Demo拼在一起”导致的重名问题。你从教程A复制了一个demoJob,从教程B又复制了一个也叫demoJob的Job,启动时Spring Batch会校验发现有两个Job使用同一个名称,然后抛出异常。
类似的情况也发生在Step上。即使Job不同,如果你定义了多个同名Step,某些场景下元数据表里的步骤记录也会混淆。解决方式很简单,命名时带上业务语义,比如processUserDataStep、cleanExpiredRecordStep。一方面能避免重名冲突,另一方面日志里看到Step名称,你一眼就能知道当前执行到哪一步了。
6.4 用LoggingItemWriter先验证链路,再写数据库
把Writer直接配成JdbcBatchItemWriter有一个小风险:如果SQL写错或者表结构不对,你根本分不清是读端错了还是写端错了。我的习惯是先用一个简单的LoggingItemWriter跑通链路,确认Reader和Processor没问题后,再换成真正的数据库写入。
@Bean public ItemWriter<Person> personItemWriter() { return items -> items.forEach(System.out::println); }这个方法在搭建新任务时特别实用。尤其是当你的输入文件字段特别多、映射关系复杂时,先把数据原样打印出来检查字段对应是否正确,再进入后续环节,能避免在错误的数据结构上写一堆无用的调试代码。
6.5 区分“没执行”和“执行了但没数据”
新手经常把“任务执行成功”和“任务处理了数据”混为一谈。Spring Batch的COMPLETED状态只表示整个流程正常走完,并不代表数据都处理正确。最常见的情况是:CSV文件路径写错了,Reader读取到的是不存在的文件,但Spring Batch并不会报错,它只是正常返回null,Job照样以COMPLETED状态收场。
所以每一步都要有验证手段。读文件,就确认文件路径和行数;写数据库,就查询表里的记录数;调用Processor,就打印处理前后的值。把这些验证点埋好,跑批任务时才不会对着“成功日志”一头雾水。
搭完第一个项目后我最大的体会是,Spring Batch的复杂度不在于API怎么写,而在于“批处理思想”本身——把任务切分成读、处理、写三块,用事务和状态表保证可靠性,用Job参数控制每次运行的业务边界。建议你下一步把元数据表打开仔细看一遍,把JobInstance、JobExecution、StepExecution之间的关系对照着日志理一遍,这几个概念理顺了,后面学重启、跳过、多线程Step都会轻松很多。
最后再分享一个小技巧:开发阶段把chunk值调小到1到5,每处理一条都能看到日志变化,排查逻辑问题时非常直观。等确认代码没问题了,再根据数据量调到一个合理的批次大小。这个习惯让我少走了很多弯路,希望也能帮到你。