Spring Boot Batch 6.x 项目里,Job Parameters 传到 Reader 后变成 null,是一个看起来特别不起眼、但能把人卡一下午的坑。你明明在@Bean方法上加了@StepScope,也写了@Value("#{jobParameters['xxx']}"),结果运行起来参数就是 null,严重一点的直接FileNotFoundException,根本不知道文件路径是从哪儿丢的。
我最近排查一个定时批处理任务时正好踩中这个坑,折腾了大半天才定位到根因。这篇文章把完整的排查过程和最终解决方案记录一下,希望能帮到同样被 Spring Batch 6.x 参数绑定折磨的朋友。内容不光是“加个注解”这么简单,还会讲清楚 Job Parameters 到底是怎么流到 Reader 的、@StepScope的原理是什么、以及 Spring Batch 6.x 下有哪些隐蔽的配置点会导致绑定失效。
1. 问题现象:一个看起来“没毛病”的代码,Job 参数就是 null
1.1 典型错误代码长什么样
先说一个最经典的“错误示范”。很多人在配置类里这么写:
@Configuration public class BatchConfig { @Bean public Job reportJob(JobRepository jobRepository, Step reportStep) { return new JobBuilder("reportJob", jobRepository) .start(reportStep) .build(); } @Bean public Step reportStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { return new StepBuilder("reportStep", jobRepository) .<String, String>chunk(100, transactionManager) .reader(reportReader()) .writer(items -> System.out.println("write: " + items.size())) .build(); } @Bean @StepScope public ItemReader<String> reportReader(@Value("#{jobParameters['input.date']}") String inputDate) { System.out.println("inputDate = " + inputDate); return new ReportReader(inputDate); } }这段代码的问题在哪?reportStep方法里直接调用了reportReader()。如果这个配置类是一个标准的、由 CGLIB 代理的@Configuration,那么reportReader()会返回容器中的 StepScope 代理对象,问题可能不大。但如果配置类是@Component、final类、或者用了一些特殊方式导致 Spring 没有对该配置类做 CGLIB 代理,那么reportReader()就是一次普通 Java 方法调用,@StepScope根本不会生效,inputDate在容器启动阶段就被绑定成 null 了。
这种“配置类是否是标准@Configuration”的坑,在 Spring Batch 6.x 中尤其容易踩。因为 6.x 里JobBuilderFactory和StepBuilderFactory都被移除了,很多人迁移的时候会把配置类大改一通,一不小心就把@Configuration改成别的注解,或者把配置类设计成了普通类。
1.2 null 的现场:日志、报错和影响范围
如果只是读到 null,运气好可能只是空指针,运气不好就会让你的批处理任务在第一步就崩溃。我当时遇到的日志是这样的:
2025-03-18 10:22:31.123 INFO 12345 --- [main] c.example.BatchConfig : inputDate = null 2025-03-18 10:22:31.200 ERROR 12345 --- [main] o.s.b.c.l.support.SimpleJobLauncher : Job terminated with error java.lang.IllegalArgumentException: Source must not be null at org.springframework.util.Assert.notNull(Assert.java:202) at org.springframework.batch.item.file.FlatFileItemReader.doOpen(FlatFileItemReader.java:252)能看到参数在 Reader 创建时就打印了 null,后续读取文件直接崩。这种问题的影响范围不只是“读不到参数”这么简单,它会让你根本没法定位是“参数没传进来”还是“参数传进来了但绑定不上”。如果项目里还接了多数据源、分片任务、多线程 Step,排查复杂度会直线上升。
2. 先搞清根因:Job 参数是“什么时候”进入 Reader 的
2.1 JobParameters 走到 Reader 的完整链路
要理解为什么参数会变成 null,得先看一遍 Spring Batch 里 Job 参数的流转链路。
整个流程大致是这样:
- 调用方通过
JobLauncher.run(job, jobParameters)提交任务。 JobLauncher拿到JobParameters后,创建JobExecution,并把参数绑定到JobExecution上。JobExecution里创建StepExecution,StepExecution持有JobExecution的引用,所以可以通过stepExecution.getJobParameters()拿到参数。- Step 执行时,Spring 会把当前
StepExecution放到一个StepContext中,StepContext会注册到StepScope这个自定义作用域里。 - 如果 Reader Bean 是
@StepScope的,那么 Spring 容器里保存的是一个代理对象,真正读取数据时,代理对象会从当前线程的StepContext中解析出真实的 Reader 实例,同时解析@Value("#{jobParameters['xxx']}")表达式。
关键点在于第 4 步和第 5 步。Job Parameters 并不是通过“方法参数传递”的方式直接给到 Reader 的,而是先存到StepContext里,再由@StepScope的代理在合适时机去取。
用个生活化的类比:@StepScopeReader 就像一台需要“任务单”才能开工的机器。机器先摆在车间(Spring 容器)里,等任务单(StepExecution)下来后,工人拿着任务单上的参数再去调机器。如果你在任务单下来之前就强行开机,机器上当然没有参数,全是默认 null。
2.2 @StepScope 到底做了什么,什么情况下会“假装不存在”
@StepScope本质上是 Spring 自定义 Scope 机制的一个实现,底层对应的是StepScope这个类。当一个 Bean 上标注了@StepScope,Spring 容器不会在启动时立刻创建真实 Bean,而是创建一个基于 AOP 的 Scoped Proxy 放在容器中。
这个代理对象的作用是:在容器启动阶段,它只是“占位符”。等到 Step 真正开始执行时,StepScope里会有一个StepContext(里面封装了StepExecution),代理对象会从当前线程的StepContext中获取真实的 Bean 实例,并完成 SpEL 表达式的绑定。这个机制官方叫 late binding,也就是“延迟绑定”。
所以@StepScope要生效,必须满足三个前提条件:
- 容器里确实注册了
StepScope这个自定义 Scope。 - Reader Bean 的
@Bean方法确实被 Spring 容器识别为 StepScope 作用域。 - Job 执行时,
StepContext能正确绑定到当前执行线程。
如果其中任何一条不满足,@StepScope就是“假装存在”,参数自然就取不到。
有一个很容易忽略的点:在 Spring Boot 项目中,BatchAutoConfiguration会自动注册StepScope,所以大部分人不会遇到“Scope 未注册”的问题。但如果你自己实现了DefaultBatchConfiguration的某些方法,比如重写了jobRepository、transactionManager、jobLauncher等,一定要确认没有破坏自动注册逻辑。否则最终效果就是@StepScope失效,参数永不绑定。
2.3 Spring Batch 6.x 的新配置带来的坑
Spring Batch 6.x 相比 5.x 做了不少清理。最直接的变化是:
JobBuilderFactory和StepBuilderFactory被移除,必须用JobBuilder、StepBuilder配合JobRepository来构建任务。- 基于 Java 17 和 Spring Framework 6。
- 配置类推荐继承
DefaultBatchConfiguration,由它提供默认的JobRepository、JobLauncher、TransactionManager等。
这个变化本身是好事,但迁移时容易造成“新旧写法混搭”。比如我见过一个项目,一边用了DefaultBatchConfiguration提供基础组件,一边又在自定义配置类里手动new了一个StepScope,结果容器里出现两个StepScope实例。虽然 Bean 名相同会覆盖,但谁也说不准覆盖的是哪个,最后表现出来的就是偶发性参数绑定不上。
另外,6.x 里StepBuilder的.reader(...)方法接收的是一个ItemReader实例。如果直接传reportReader()这样“方法调用”的结果,上面 1.1 节已经说过:只有配置类被 CGLIB 代理时才会正确返回 StepScope 代理对象。而判断一个配置类是否被 CGLIB 代理有一个简单标准:看这个类是不是用@Configuration标注的。如果是@Component、@Service这类普通 Spring 注解,方法间的直接调用就不会被拦截,@StepScope注解形同虚设。
还有一个迁移时常犯的错:在 Reader 里通过构造器直接接收参数。比如:
@Bean @StepScope public ItemReader<String> reportReader() { String date = jobParameters.getString("input.date"); // 编译都编译不过,但有人会这样写 return new ReportReader(date); }这行代码里jobParameters根本不存在,Spring Batch 的 late binding 只针对 SpEL 表达式和StepExecution上下文,不会魔法般地给普通方法注入JobParameters。正确做法是把参数作为@Bean方法参数,通过@Value("#{jobParameters['xxx']}")注入。
3. 排障实操:从启动入口一路查到 Reader
3.1 第一步:确认 JobLauncher 传入的参数到底有没有
遇到参数为 null,我的第一个动作永远是确认“Job 入口的参数到底有没有”。这不是废话,因为很多问题根本不是绑定机制坏了,而是参数压根没传进来,或者传进来的 JobParameters 是一个空对象。
有一种特别常见的写法是:
jobLauncher.run(job, new JobParameters());这个new JobParameters()创建了一个空的参数对象,里面一个 key 都没有。如果你用JobParametersBuilder去构建参数,至少能看到参数列表。所以排查时先在启动入口打印一下:
JobParameters jobParameters = new JobParametersBuilder() .addString("input.date", "2025-03-18") .toJobParameters(); JobExecution jobExecution = jobLauncher.run(job, jobParameters); System.out.println("jobExecution JobParameters = " + jobExecution.getJobParameters());这一步能快速区分问题是在“入口没传参”还是“传了参但 Reader 绑定失败”。如果jobExecution.getJobParameters()打印出来是空的,那就别往 Reader 那边找了,先把入口修好再说。
判断标准很简单:JobExecution里的JobParameters有值,才存在后续的绑定问题;如果这里就是空的,那 Reader 里拿到 null 是必然的。
3.2 第二步:确认 StepScope 代理是否真的生成
确认参数入口没问题后,再排查@StepScope代理是否真的创建了。
一个很直接的验证方式:在配置类里打印一下容器中reportReader这个 Bean 的 Class 类型。
@Bean public CommandLineRunner checkScope(ApplicationContext context) { return args -> { Object reader = context.getBean("reportReader"); System.out.println("reportReader class = " + reader.getClass()); }; }如果@StepScope生效,这个 Bean 应该是一个代理对象,Class 名称中会带着Proxy或类似的关键字样。比如:
reportReader class = class com.sun.proxy.$Proxy123或者 CGLIB 代理:
reportReader class = class com.example.ReportReader$$SpringCGLIB$$0如果打印出来直接是原始com.example.ReportReader,说明这个 Bean 根本没有被代理,@StepScope没生效。这时候就去查配置类、查有没有手动覆盖 StepScope 注册、查是不是把@StepScope写在了类上但实际返回类型是接口等。
这里有一个细节:@StepScope可以放在@Bean方法上,也可以放在 Reader 实现类上。如果是自定义 Reader 类,你可以直接:
@Component @StepScope public class ReportReader implements ItemReader<String> { ... }这两种方式都可以。但如果类上标了@StepScope,同时又用@Bean @StepScope返回这个类的实例,有时候会产生冗余代理的奇怪问题。我的建议是二选一:要么全用@Bean @StepScope,要么全用@Component @StepScope,不要混着来。
3.3 第三步:验证 late binding 是否生效
即使@StepScope代理创建了,也不代表参数绑定一定发生在 Step 执行阶段。Spring Batch 的 late binding 有一个隐藏前提:StepScopebean 的解析必须发生在 Step 执行期间。
最简单的验证方法是在@Bean @StepScope方法里加一行日志:
@Bean @StepScope public ItemReader<String> reportReader(@Value("#{jobParameters['input.date']}") String inputDate) { System.out.println("[reportReader] created at = " + System.currentTimeMillis()); return new ReportReader(inputDate); }然后在启动入口也打一条时间戳:
System.out.println("[jobLauncher] start at = " + System.currentTimeMillis()); jobLauncher.run(job, jobParameters);如果[reportReader] created at出现在[jobLauncher] start at之前,说明 StepScope Bean 在 Job 启动前就被创建了,那 late binding 没有生效。正常情况下,StepScope Bean 的真实创建应该发生在 Step 执行阶段,也就是[jobLauncher] start at之后。
这一步能精准定位“代理创建了,但方法体执行时机不对”的情况。对应到代码上,往往是配置类里@Bean方法之间直接调用了 StepScope 方法,导致方法体在容器启动时就执行了一遍。
3.4 第四步:检查多线程/异步对 StepScope 上下文的影响
还有一个比较容易忽略的场景:如果你的批量任务用了多线程 Step 或者异步处理器,StepScope 的上下文可能没有正确传递到子线程。
Spring Batch 的StepScope是基于ThreadLocal实现的。当前线程执行 Step 时会绑定StepContext,代理对象在这个线程内能正确解析参数。但如果你在 reader 或者 processor 里自己开了线程池,在新线程里去调用 StepScope Bean 的方法,StepContext在新线程里是不存在的,轻则参数 null,重则直接抛IllegalStateException: No context holder available for step scope。
官方推荐的多线程批处理方案是使用TaskExecutorStep或AsyncItemProcessor,前者是 Step 级别并行处理,后者是 processor 异步执行。AsyncItemProcessor不会影响 Reader 的执行线程,因为 reader 仍然在主 Step 线程中调用,所以这个问题主要出现在你手动搞线程池的场景里。排查时如果发现是手动开线程导致的,要么把参数提取放到切线程之前,要么干脆改用官方异步机制。
4. 问题解决:三种可靠写法与最终选型
4.1 方案一:@StepScope + @Value("#{jobParameters['key']}")(推荐)
最标准、也是最推荐的写法,直接利用 Spring Batch 的 late binding 机制。关键是让StepBuilder的.reader(...)通过方法参数注入容器中的代理对象,而不是直接调用@StepScope方法。
@Configuration public class BatchConfig { @Bean public Job reportJob(JobRepository jobRepository, Step reportStep) { return new JobBuilder("reportJob", jobRepository) .start(reportStep) .build(); } @Bean public Step reportStep(JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReader<String> reportReader) { return new StepBuilder("reportStep", jobRepository) .<String, String>chunk(100, transactionManager) .reader(reportReader) .writer(items -> System.out.println("write: " + items.size())) .build(); } @Bean @StepScope public ItemReader<String> reportReader(@Value("#{jobParameters['input.date']}") String inputDate) { System.out.println("[reportReader] inputDate = " + inputDate); return new ReportReader(inputDate); } }注意这里reportStep方法的第三个参数ItemReader<String> reportReader,Spring 会从容器中注入reportReader这个 Bean 的代理对象。Step 执行时,代理对象会从当前StepContext中解析jobParameters['input.date'],参数绑定自然成功。
这个方案的好处是不需要额外代码,Spring 容器帮我们处理了所有绑定逻辑。问题排查起来也简单:只要@StepScope生效、JobParameters有值、参数名拼写正确,就能拿到值。
参数名拼写这一点要特别提醒。@Value("#{jobParameters['input.date']}")里的input.date必须和JobParametersBuilder.addString("input.date", ...)里的 key 完全一致。这个 key 是区分大小写的,而且点号、横杠都是普通字符,没有任何魔法。我见过有人入口addString("input.date"),Reader 里写#{jobParameters['inputDate']},对不上,参数就 null,排查半天。
如果希望参数为空时给一个默认值,可以这样写:
@Value("#{jobParameters['input.date'] ?: '2025-01-01'}") String inputDate这是标准 SpEL 语法,Spring Batch 里同样适用。
4.2 方案二:实现 StepExecutionListener,从 StepExecution 里取
如果你的 Reader 结构比较复杂,强依赖StepExecution里的其他信息,比如想拿到StepExecution.getStepName()、getExecutionContext(),那可以考虑让 Reader 实现StepExecutionListener,在beforeStep里取出参数。
@Component public class ReportReader implements ItemReader<String>, StepExecutionListener { private StepExecution stepExecution; private ReportReaderDelegate delegate; @Override public void beforeStep(StepExecution stepExecution) { this.stepExecution = stepExecution; String inputDate = stepExecution.getJobParameters().getString("input.date"); System.out.println("[beforeStep] inputDate = " + inputDate); this.delegate = new ReportReaderDelegate(inputDate); } @Override public String read() { if (delegate == null) { throw new IllegalStateException("beforeStep not called"); } return delegate.read(); } }这种写法的核心是:StepExecutionListener.beforeStep会在 Step 正式读数据之前被 Spring Batch 框架回调。在这个时机,StepExecution已经完整创建并且持有JobParameters,你从stepExecution.getJobParameters()拿到的参数一定是可靠的。
不过要注意,如果你把这个 Reader 声明成了普通@Component,同时它又被多个 Step 共用,那么stepExecution和delegate这两个字段会被多个 Step 的线程并发写,存在线程安全问题。多线程 Step 场景下,建议配合在 Reader 里使用synchronized或使用@StepScope声明,让每个 Step 单独用一个实例。
这里有一个小技巧:Spring Batch 的每个 Step 执行时,即使 Reader 是单例,框架也会先调用一次beforeStep,然后才调read。所以理论上read()里的delegate不会为 null,但防御式编程还是建议加上判空,方便排查问题。
4.3 方案三:通过 ExecutionContext 二次传递参数
第三种方案适合“参数需要经过二次加工”的场景。比如从JobParameters拿到一个日期字符串,但 Reader 需要的是一个格式化后的日期对象,或者需要查询数据库拿到一个 ID 列表。这时候你可以先在第一步 Tasklet 里做加工,把结果写入StepExecutionContext,Reader 再从 ExecutionContext 里读取。
@Bean public Tasklet prepareTasklet() { return (contribution, chunkContext) -> { StepExecution stepExecution = chunkContext.getStepContext().getStepExecution(); String inputDate = stepExecution.getJobParameters().getString("input.date"); String resolvedDate = resolveDate(inputDate); stepExecution.getExecutionContext().put("resolved.date", resolvedDate); return RepeatStatus.FINISHED; }; }然后在 Reader 中:
@Component public class ReportReader implements ItemReader<String>, StepExecutionListener { private StepExecution stepExecution; @Override public void beforeStep(StepExecution stepExecution) { this.stepExecution = stepExecution; } @Override public String read() { String resolvedDate = stepExecution.getExecutionContext().getString("resolved.date"); if (resolvedDate == null) { throw new IllegalStateException("resolved.date not found in ExecutionContext"); } // 使用 resolvedDate return null; } }ExecutionContext的写入时机是prepareTasklet执行阶段,读取时机是 Reader 的beforeStep之后。这两者之间的先后顺序由 Step 定义决定,所以你要确保prepareTasklet在 Reader 所在的 Step 之前执行。
这种方案的优点是解耦:Reader 不直接依赖JobParameters的 key,而是依赖 ExecutionContext 中已经加工好的数据。缺点是多了几步,层级深了以后不太直观。如果你只是取一个原始参数,没必要绕这么一圈。
4.4 各方案对比与适用场景
| 方案 | 核心机制 | 适用场景 | 风险点 |
|---|---|---|---|
| @StepScope + @Value | late binding + SpEL | 大部分常规批处理任务 | 配置类不被代理时失效,参数名需严格一致 |
| StepExecutionListener | beforeStep 回调 | Reader 需要完整 StepExecution 信息 | 有状态字段需注意线程安全 |
| ExecutionContext 传递 | Tasklet 加工后写入上下文 | 参数需要二次处理、多 Step 间共享参数 | 增加步骤依赖,层级变深 |
从我自己的实践看,第一种方案解决 80% 的问题,第二种适合复杂 Reader,第三种属于“特定场景再上”的手段。如果三种都没有解决问题,那基本不是“取参数”的问题,而是 Job 启动方式、配置类结构、或者多线程上下文丢失的问题,需要回到第三节的排查流程里逐项排除。
5. 避坑清单:让 Job Parameters 不再为 null
5.1 常见“null”姿势速查表
这节总结一下我在这类问题上遇到过的典型场景和处理方法,最后整理成一张表,方便大家直接对照:
| 现象 | 可能原因 | 排查/解决方向 |
|---|---|---|
| Reader 构造参数为 null | 没有加 @StepScope,或 StepScope 注册被破坏 | 检查配置类,确认 @EnableBatchProcessing 或 DefaultBatchConfiguration 正常 |
| @Value("#{jobParameters['xxx']}") 永远 null | JobLauncher 没传参数,或参数 key 拼写不一致 | 打印 jobExecution.getJobParameters(),核对 key 与实际传入参数 |
| 容器启动时就执行了 @StepScope 方法体 | 配置类不是标准 @Configuration,方法间直接调用导致 early binding | 配置类统一用 @Configuration,StepBuilder 里用方法参数注入 Reader |
| 自定义线程池里拿参数为 null | StepContext 基于 ThreadLocal,没有传递到子线程 | 避免跨线程调用 StepScope Bean,参数提前提取或改用官方异步机制 |
| 多 Step 共用 Reader 出现参数串扰 | Reader 有状态,且作用域是单例 | Reader 使用 @StepScope 隔离状态 |
| 参数能打印,但打开文件时报 Source is null | 参数绑定成功,但 delegate 初始化失败 | 检查 Reader 构造逻辑和参数类型转换 |
| 偶发性 null,重启后可能消失 | 容器中存在多个 StepScope Bean 或配置冲突 | 检查有没有手动 new StepScope,统一交给 Boot 自动配置 |
5.2 补充几个 Spring Batch 6.x 实践建议
最后再分享几个 Spring Batch 6.x 的实践建议,跟参数绑定有关,但也不全是为了参数绑定,很多是顺手避坑。
第一个建议是:开发阶段把spring.batch.job.enabled=false设上,避免应用启动时自动执行 Job。Spring Boot 的 BatchAutoConfiguration 在 classpath 下有JobLauncherApplicationRunner时,默认会尝试执行容器里找到的 Job。如果你只想手动触发测试,一定要先关掉这个自动执行,否则可能在没传参数的情况下就把 Job 跑了一遍,误导排查方向。
第二个建议是:配置类尽量统一继承DefaultBatchConfiguration,不要自己手动创建JobRepository或JobLauncher去覆盖全部逻辑。Spring Batch 6.x 的DefaultBatchConfiguration已经提供了合理默认值,你只需要覆写需要定制的方法。如果整个配置类都是自定义的,那StepScope、JobScope这些基础 Bean 很容易被遗漏或重复注册。
第三个建议是:参数 key 定义成常量类,不要散落字符串。这个看起来是代码规范问题,但实际排障时非常有用。项目里如果有几十个 Job,参数 key 各不相同,放在统一常量类里能避免拼写错误,也让排查的人一目了然:
public final class JobParamKeys { public static final String INPUT_DATE = "input.date"; public static final String INPUT_FILE = "input.file"; public static final String TARGET_DIR = "target.dir"; }第四个建议是:在JobExecutionListener里加一个统一的参数打印。比如:
@Component public class JobLoggingListener implements JobExecutionListener { @Override public void beforeJob(JobExecution jobExecution) { JobParameters params = jobExecution.getJobParameters(); System.out.println("[beforeJob] params = " + params); } @Override public void afterJob(JobExecution jobExecution) { System.out.println("[afterJob] status = " + jobExecution.getStatus()); } }在 Job 一开始就把参数打印出来,能省掉大量“参数到底传没传”的争论。
我个人实际排查这类问题的习惯是:先看 JobLauncher 入口,再确认容器里 StepScope 代理是否存在,然后用日志确认 late binding 时机,最后才去看代码逻辑。绝大部分“Job Parameters are null”的坑,都出在“入口参数本身是空的”或者“配置类结构导致 StepScope 失效”这两个环节。把这两点盯住了,问题基本在半小时内能定位。
最后再放一个小技巧:如果你在排查过程中想临时确认某个参数能不能绑定,不需要重启整个 Batch 任务,可以直接写一个最小的JobLauncherTestUtils测试,手动构建一个JobParameters,然后启动 Job 看参数是否到达 Reader。这个方法在 Spring Batch 6.x 里一样适用,能帮你把问题缩小到“某个具体 Job 的配置”还是“整个项目的 Batch 基础设施”。我踩过几次这个坑之后,已经把这个排障流程固化成了团队内部的技术分享文档,每次新成员遇到类似问题,照着查一遍基本都能解决。