Spring Batch生产级骨架:金融级批处理架构与避坑指南
1. 这不是“又一个Spring Boot示例”而是一套可直接进生产环境的批处理骨架你搜“SpringBoot整合Spring Batch示例”刷出来的十篇里八篇跑的是HelloWorldJob两篇在EnableBatchProcessing上卡了三天——最后发现是Spring Boot 3.x默认禁用了EnableBatchProcessing但文档没写清楚示例代码还照着2.x抄。我带团队落地过7个金融级批量作业系统从日终对账、客户画像更新到监管报送文件生成最深的体会是Spring Batch不是“能跑就行”的玩具框架它是银行核心系统里默默扛住每晚200万条交易流水的那根承重梁。它不炫技但要求你懂事务边界怎么切、重启点怎么设、内存溢出在哪埋雷、失败后怎么精准重试——这些细节恰恰是面试官问“你用过Batch吗”时真正想听的答案。本文标题里的“示例”二字绝不是指“复制粘贴就能跑通的玩具代码”而是指一套经过真实业务压力验证的最小可行骨架它包含完整的作业定义、健壮的异常处理、可监控的执行状态、可配置的资源策略以及最关键的——当凌晨三点作业挂掉时你能立刻定位到是第84217条记录解析失败而不是重启整个Job干等两小时。如果你正在准备Java后端面试别再背“Step、Job、JobRepository”这些名词解释了如果你正要给新项目加批量能力请把这篇当成你的第一份架构检查清单。下面所有内容都来自我们踩过坑、改过源码、压测过千万级数据的真实经验。2. 为什么必须放弃“HelloWorld式整合”——Batch不是Spring Boot的装饰品2.1 Spring Batch的本质一个被严重低估的“企业级作业引擎”很多人把Spring Batch当成Spring Boot的插件装上就完事。这是最大的认知偏差。Spring Batch的核心价值根本不在“读-处理-写”这个三段式流程而在于它构建了一套可审计、可恢复、可监控、可伸缩的企业级作业生命周期管理体系。我们拆开看可审计性每次Job执行都会在BATCH_JOB_INSTANCE、BATCH_JOB_EXECUTION、BATCH_STEP_EXECUTION三张表里留下完整快照。这不是日志是结构化元数据——比如STATUSFAILED时EXIT_CODE字段会精确到FAILED:java.lang.NumberFormatExceptionEXIT_MESSAGE里甚至包含Caused by: java.lang.NumberFormatException: For input string: abc这样的原始堆栈片段。这比ELK里翻半天日志强十倍。可恢复性Batch的Restart机制不是简单重跑。它依赖JobExecution的VERSION和CREATE_TIME结合JobRepository的乐观锁确保同一Job实例不会被并发重启。更关键的是ItemReader的saveState属性——当读取到第10000条时崩溃重启后JdbcCursorItemReader会自动从WHERE id 10000继续而不是从头扫全表。这点在处理千万级订单表时直接决定作业能否在窗口期内完成。可监控性JobExplorer接口暴露了所有执行状态配合Actuator的/actuator/batch端点需手动注册你能实时看到ACTIVE、STOPPED、FAILED作业数甚至每个Step的READ_COUNT、WRITE_COUNT、COMMIT_COUNT。我们曾用Prometheus抓取这些指标当WRITE_COUNT突降为0时自动触发告警——后来发现是下游数据库连接池耗尽比应用日志早17分钟发现问题。提示Spring Boot 2.5默认禁用EnableBatchProcessing因为其自动配置会覆盖自定义JobRepository。正确做法是显式声明Bean并注入DataSource和TransactionManager——这不是配置麻烦而是强制你思考你的作业事务该用哪个数据源是否需要独立的Batch专用库2.2 Spring Boot的“便利性陷阱”自动配置如何悄悄毁掉你的作业Spring Boot的spring-boot-starter-batch确实省事但它的自动配置藏着三个致命默认值内存型JobRepositoryMapJobRepositoryFactoryBean在application.yml里没配spring.batch.jdbc.initialize-schemaalways时会用ConcurrentHashMap存状态。这意味着重启应用后所有Job执行记录清零——你永远不知道昨天的对账Job到底成功没。生产环境必须强制切换到JDBC实现。单线程TaskExecutorSimpleAsyncTaskExecutor是伪异步每次创建新线程且不复用。当Step里有10000个Chunk并发处理时线程数爆炸直接OOM。必须替换为ThreadPoolTaskExecutor并设置corePoolSize4、maxPoolSize8、queueCapacity100——这个值不是拍脑袋按我们压测单核CPU处理JSON解析类Step4个核心线程刚好吃满CPU而不阻塞IO。无超时控制的JobLauncherSimpleJobLauncher没有JobParameters校验和超时机制。如果一个Job卡死在某个StepJobOperator.stop()可能失效。必须包装一层带Future.get(30, TimeUnit.MINUTES)的调用否则运维半夜接到告警只能杀进程。注意Spring Boot 3.x移除了EnableBatchProcessing注解改用EnableBatch。但更重要的是spring.batch.job.enabledfalse这个配置项必须显式设置否则启动时会自动运行jobBean——这在微服务里极其危险你改个配置重启所有定时Job全被触发。2.3 真实场景倒逼的架构选择为什么我们不用XML而坚持Java Config网上大量示例用XML定义Job因为Spring Batch 2.x时代这是唯一方式。但XML有三大硬伤无法动态参数化batch:job idimportJob里的batch:step不能根据Value(${batch.chunk.size:100})动态改chunk-size。而Java Config里new SimpleStepBuilder(step, jobRepository).chunk(chunkSize)一行搞定。调试成本高XML里batch:reader引用的ItemReaderBeanIDE无法跳转到具体实现类。而Java Config里new JdbcCursorItemReader()直接点进去就是源码。版本兼容性差Spring Batch 4.x的JdbcPagingItemReader在XML里要写bean classorg.springframework.batch.item.database.JdbcPagingItemReader而5.x已废弃Java Config里直接new JdbcPagingItemReader()编译期报错立刻暴露问题。我们团队的规范所有Job定义必须用Configuration类且每个Job单独一个类如CustomerImportJobConfig。这样做的好处是——当业务方说“把客户导入Job的重试次数从3次改成5次”你只需要改CustomerImportJobConfig.java里一行retryLimit(5)而不是在XML里翻100行找batch:retry-limit。3. 核心细节解析从零搭建一个抗压的批处理骨架3.1 数据库初始化Batch元数据表不是“可选”而是“生命线”Spring Batch必须依赖四张核心元数据表BATCH_JOB_INSTANCE、BATCH_JOB_EXECUTION、BATCH_STEP_EXECUTION、BATCH_JOB_EXECUTION_PARAMS来管理作业状态。很多人以为spring.batch.jdbc.initialize-schemaalways能自动建表但实际踩坑如下MySQL 8.0的严格模式BATCH_JOB_EXECUTION_PARAMS.PARAMETER_VALUE字段类型为VARCHAR(2500)但MySQL 8.0默认sql_modeSTRICT_TRANS_TABLES当插入超长参数如JSON字符串时直接报错Data too long for column PARAMETER_VALUE。解决方案是修改字段为TEXT或在application.yml中添加spring: batch: jdbc: initialize-schema: always schema: classpath:org/springframework/batch/core/schema-mysql.sql并手动编辑schema-mysql.sql将PARAMETER_VALUE VARCHAR(2500)改为PARAMETER_VALUE TEXT。PostgreSQL的序列问题BATCH_JOB_EXECUTION_SEQ序列默认起始值为1但当Job执行ID超过INT范围21亿时序列溢出。必须提前执行ALTER SEQUENCE BATCH_JOB_EXECUTION_SEQ AS BIGINT;多数据源场景如果你的业务库和Batch元数据库分离强烈推荐必须显式指定JobRepository的数据源。常见错误是只配了Primary DataSource导致Batch元数据写进业务库——这会让DBA半夜打电话骂人。正确配置Bean Primary public DataSource businessDataSource() { return DataSourceBuilder.create().url(jdbc:mysql://biz-db:3306/business).build(); } Bean Qualifier(batchDataSource) public DataSource batchDataSource() { return DataSourceBuilder.create().url(jdbc:mysql://batch-db:3306/batch_meta).build(); } Bean public JobRepository jobRepository(Qualifier(batchDataSource) DataSource dataSource, PlatformTransactionManager transactionManager) throws Exception { JobRepositoryFactoryBean factory new JobRepositoryFactoryBean(); factory.setDataSource(dataSource); // 关键指定Batch专用数据源 factory.setTransactionManager(transactionManager); factory.setDatabaseType(mysql); return factory.getObject(); }实操心得我们给Batch元数据库单独建库并设置innodb_buffer_pool_size2G针对日均百万级作业。监控发现BATCH_JOB_EXECUTION表增长过快时会自动清理30天前的记录——但绝不删BATCH_JOB_INSTANCE因为它是作业唯一性的依据。3.2 Job与Step的粒度设计别让一个Step扛下所有脏活新手常犯的错误是把整个业务逻辑塞进一个Step读Excel→解析→校验→转换→写DB。这会导致三个问题重启成本高Step失败时整个流程重跑哪怕只是最后100条数据写失败。资源争抢JdbcCursorItemReader和JdbcBatchItemWriter共用同一个DataSource连接读写锁竞争严重。监控失真READ_COUNT1000000、WRITE_COUNT1000000但你不知道哪部分慢——是读取慢还是写入慢我们的分层方案以客户导入为例Step名称职责技术选型关键参数validateStep读取原始文件校验格式、必填字段、唯一性约束FlatFileItemReader 自定义LineMapperlinesToSkip1跳过表头transformStep将校验通过的数据转换为领域对象处理空值、日期格式CompositeItemProcessor链式处理器processorcustomerValidator→phoneNormalizer→addressGeocoderwriteStep批量写入数据库处理主键冲突ON DUPLICATE KEY UPDATEJdbcBatchItemWritersqlINSERT INTO customer ... ON DUPLICATE KEY UPDATE每个Step独立事务validateStep失败不影响transformStep的缓存结果。更重要的是transformStep的ItemProcessor可以抛ValidationException由FaultTolerantStepBuilder捕获并跳过坏数据——这比在writeStep里try-catch优雅得多。注意Chunk大小不是越大越好。我们测试过MySQL批量插入时chunkSize1000比10000快37%因为10000条SQL拼成的INSERT语句超过max_allowed_packet默认4MB。计算公式chunkSize ≈ max_allowed_packet / (单条记录平均字节数 * 1.5)。例如单条客户记录平均2KB则4*1024*1024 / (2048 * 1.5) ≈ 1365取整1000最稳妥。3.3 异常处理的三层防御从跳过坏数据到精准告警Batch作业不可能100%干净必须设计弹性容错。我们采用三层策略第一层Chunk级跳过最常用当某条记录解析失败如手机号非数字用FaultTolerantStepBuilderBean public Step transformStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { return new StepBuilder(transformStep, jobRepository) .CustomerRaw, Customerchunk(100, transactionManager) .reader(validateReader()) .processor(transformProcessor()) .writer(customerWriter()) .faultTolerant() .skip(ValidationException.class) // 跳过校验失败的记录 .skipLimit(10) // 最多跳过10条超限则Step失败 .noRollback(ValidationException.class) // 跳过时不回滚事务 .build(); }关键点noRollback确保跳过一条记录不影响其他99条的提交。skipLimit防止单个坏文件导致整个Job被忽略。第二层Step级重试网络抖动场景当调用外部API如地址编码服务超时时.faultTolerant() .retry(RemoteAccessException.class) // 重试远程异常 .retryLimit(3) // 最多重试3次 .retryBackoff(1000, 2.0) // 指数退避1s, 2s, 4s第三层Job级告警兜底方案在Job监听器里捕获最终失败Component public class JobCompletionNotificationListener implements JobExecutionListener { Override public void afterJob(JobExecution jobExecution) { if (jobExecution.getStatus() BatchStatus.FAILED) { String failedStep jobExecution.getStepExecutions().stream() .filter(s - s.getStatus() BatchStatus.FAILED) .findFirst() .map(StepExecution::getStepName) .orElse(unknown); // 发送企业微信告警附带ExitCode和ExitMessage alertService.sendAlert( String.format(Job %s failed at step %s: %s, jobExecution.getJobInstance().getJobName(), failedStep, jobExecution.getExitStatus().getExitDescription()) ); } } }实操心得我们把ExitMessage截取前500字符存入告警消息因为完整堆栈可能超微信限制。同时在BATCH_STEP_EXECUTION表里加EXTENSION_INFO字段存JSON记录失败详情供后续排查。4. 实操过程手把手实现一个银行日终对账Job4.1 需求还原真实的业务约束比技术难点更致命假设我们要实现一个“交易流水与账户余额对账Job”需求如下数据源上游系统每天02:00推送transaction_20240520.csv到FTP服务器含120万条交易记录。校验规则每笔交易必须对应有效账户且交易金额≤账户当前余额防止透支。输出要求生成reconciliation_report_20240520.xlsx含三张Sheetsuccess对账成功、failed余额不足、error账户不存在。SLA必须在04:00前完成否则触发人工干预流程。这些业务约束直接决定技术选型CSV文件120万行不能用FlatFileItemReader全加载到内存——必须流式读取。“交易金额≤账户余额”需实时查库不能预加载所有账户——必须用JdbcCursorItemReader分页查。输出Excel需支持多SheetApache POI的SXSSFWorkbook是唯一选择内存可控。4.2 完整代码实现去掉所有“示例感”只留生产级代码Step 1定义Job参数强制校验日期Configuration EnableBatchProcessing public class ReconciliationJobConfig { Bean public Job reconciliationJob(JobRepository jobRepository, JobCompletionNotificationListener listener, Step validateStep, Step writeReportStep) { return new JobBuilder(reconciliationJob, jobRepository) .listener(listener) .start(validateStep) .next(writeReportStep) .build(); } // 关键Job参数必须包含date且校验格式 Bean public JobParametersIncrementer dateIncrementer() { return new RunIdIncrementer(); // 允许重复日期重跑 } Bean public JobParametersValidator dateValidator() { return new DefaultJobParametersValidator( new String[]{date}, // 必填参数 new String[]{} // 可选参数 ) { Override public void validate(JobParameters parameters) throws JobParametersInvalidException { super.validate(parameters); String date parameters.getString(date); if (!date.matches(\\d{8})) { throw new JobParametersInvalidException(date must be YYYYMMDD format); } } }; } }Step 2流式读取CSV避免OOMBean StepScope public FlatFileItemReaderTransaction csvTransactionReader( Value(#{jobParameters[date]}) String date) { FlatFileItemReaderTransaction reader new FlatFileItemReader(); reader.setResource(new UrlResource(ftp://ftp.example.com/transaction_ date .csv)); reader.setLinesToSkip(1); // 跳过表头 DefaultLineMapperTransaction lineMapper new DefaultLineMapper(); lineMapper.setLineTokenizer(new DelimitedLineTokenizer() {{ setNames(id, accountId, amount, type); setDelimiter(,); }}); lineMapper.setFieldSetMapper(fieldSet - { Transaction t new Transaction(); t.setId(fieldSet.readString(id)); t.setAccountId(fieldSet.readString(accountId)); t.setAmount(fieldSet.readBigDecimal(amount)); t.setType(TransactionType.valueOf(fieldSet.readString(type))); return t; }); reader.setLineMapper(lineMapper); // 关键设置缓冲区大小避免大文件OOM reader.setBufferSize(4096); return reader; }Step 3实时查账户余额分页防锁表Bean StepScope public JdbcPagingItemReaderAccount accountReader( Value(#{jobParameters[date]}) String date, DataSource dataSource) { JdbcPagingItemReaderAccount reader new JdbcPagingItemReader(); reader.setDataSource(dataSource); reader.setFetchSize(1000); // 每次取1000条减少网络往返 MySqlPagingQueryProvider queryProvider new MySqlPagingQueryProvider(); queryProvider.setSelectClause(SELECT id, balance FROM account); queryProvider.setFromClause(FROM account); queryProvider.setWhereClause(WHERE status ACTIVE); queryProvider.setSortKeys(Map.of(id, Order.ASC)); reader.setQueryProvider(queryProvider); reader.setRowMapper((rs, rowNum) - { Account a new Account(); a.setId(rs.getString(id)); a.setBalance(rs.getBigDecimal(balance)); return a; }); return reader; }Step 4多Sheet Excel写入内存可控Bean StepScope public ItemWriterReconciliationResult excelWriter( Value(#{jobParameters[date]}) String date) { return items - { // 使用SXSSFWorkbookrowAccessWindowSize100内存只存100行 SXSSFWorkbook workbook new SXSSFWorkbook(100); // 创建三个Sheet Sheet successSheet workbook.createSheet(success); Sheet failedSheet workbook.createSheet(failed); Sheet errorSheet workbook.createSheet(error); // 写入表头 writeHeader(successSheet, 交易ID, 账户ID, 金额); writeHeader(failedSheet, 交易ID, 账户ID, 金额, 余额); writeHeader(errorSheet, 交易ID, 账户ID, 错误原因); // 分类写入 for (ReconciliationResult result : items) { switch (result.getStatus()) { case SUCCESS: writeRow(successSheet, result.getTransaction().getId(), result.getTransaction().getAccountId(), result.getTransaction().getAmount()); break; case BALANCE_INSUFFICIENT: writeRow(failedSheet, result.getTransaction().getId(), result.getTransaction().getAccountId(), result.getTransaction().getAmount(), result.getAccountBalance()); break; case ACCOUNT_NOT_FOUND: writeRow(errorSheet, result.getTransaction().getId(), result.getTransaction().getAccountId(), Account not found); break; } } // 写入文件 try (FileOutputStream out new FileOutputStream( /reports/reconciliation_report_ date .xlsx)) { workbook.write(out); } finally { workbook.dispose(); // 关键释放临时文件 } }; }Step 5启动Job带超时保护Service public class ReconciliationJobLauncher { Autowired private JobLauncher jobLauncher; Autowired private Job reconciliationJob; public void launch(String date) throws Exception { JobParameters params new JobParametersBuilder() .addString(date, date) .addLong(time, System.currentTimeMillis()) .toJobParameters(); try { // 关键Future超时控制防止Job无限挂起 FutureJobExecution future CompletableFuture .supplyAsync(() - { try { return jobLauncher.run(reconciliationJob, params); } catch (Exception e) { throw new RuntimeException(e); } }); JobExecution execution future.get(2, TimeUnit.HOURS); // 2小时超时 log.info(Job completed: {}, execution.getStatus()); } catch (TimeoutException e) { log.error(Job timeout after 2 hours, killing...); // 这里应调用JobOperator.stop()但需先获取executionId throw e; } } }4.3 生产环境部署 checklist少一项都可能凌晨被call类别检查项说明我们的实践JVM-Xms2g -Xmx2g -XX:UseG1GCBatch作业内存波动大固定堆大小防GC抖动G1GC的MaxGCPauseMillis200避免单次GC超时数据库Batch元数据库连接池最小连接数≥5防止Job启动时连接池饥饿HikariCPminimumIdle5,maximumPoolSize20文件系统/tmp目录空间≥5GBSXSSFWorkbook临时文件存放地监控df -h /tmp低于2GB自动告警网络FTP服务器白名单放行Batch服务器IP防止读取失败使用ftp://user:passhost/密码加密存储监控Prometheus抓取batch_job_execution_seconds_count统计Job成功率、耗时分布Grafana看板失败率0.1%自动标红踩过的坑某次升级Spring Boot 2.7JdbcPagingItemReader的fetchSize默认值从100变成10导致分页查询变慢3倍。根源是HikariCP的connection-test-query未配置连接池返回的连接未校验有效性。解决方案spring.datasource.hikari.connection-test-querySELECT 1。5. 常见问题与排查技巧实录那些文档里不会写的真相5.1 “Job stuck in STARTING state” —— 不是代码问题是锁表了现象Job启动后一直卡在STARTINGBATCH_JOB_EXECUTION.STATUS为STARTING但BATCH_STEP_EXECUTION无记录。排查路径查BATCH_JOB_EXECUTION表找到JOB_EXECUTION_ID对应的记录执行SELECT * FROM information_schema.PROCESSLIST WHERE COMMANDSleep AND TIME 60;找长时间Sleep的连接对应连接的INFO字段显示UPDATE BATCH_JOB_EXECUTION SET STATUSSTARTED...说明事务未提交执行SHOW ENGINE INNODB STATUS\G在TRANSACTIONS部分找lock struct发现BATCH_JOB_INSTANCE表被锁。根本原因JobInstance唯一性校验时INSERT IGNORE INTO BATCH_JOB_INSTANCE在高并发下产生间隙锁Gap Lock导致后续插入阻塞。MySQL 5.7默认REPEATABLE READ隔离级别会这样。解决方案降低并发JobLauncher用单线程SimpleAsyncTaskExecutor或升级到Spring Batch 5.0其JobRepository使用INSERT ... ON CONFLICT DO NOTHINGPostgreSQL或INSERT IGNOREMySQL优化锁竞争最彻底在BATCH_JOB_INSTANCE.JOB_NAME字段加唯一索引让数据库层面保证唯一性Batch不再做双重校验。5.2 “OutOfMemoryError: Java heap space” —— Chunk不是万能解药现象JdbcCursorItemReader读取大数据集时OOM即使chunkSize100。真相JdbcCursorItemReader底层用ResultSetMySQL驱动默认fetchSize0即一次拉全量。chunkSize只控制写入批次不控制读取批次。解决步骤在application.yml中强制设置spring: datasource: hikari: connection-timeout: 30000 # 关键让JDBC驱动分页取数 >writer.setPreparedStatementSetter((ps, item) - { ps.setString(1, item.getId()); ps.setBigDecimal(2, item.getAmount()); ps.setString(3, date); // date从StepScope注入 });方案2SQL里用占位符sqlINSERT INTO report (id, amount, report_date) VALUES (?, ?, ?)然后JdbcBatchItemWriter自动绑定item属性额外参数。5.4 “Job restarts but processes same data” —— State保存失效的三种可能现象Job失败后重启ItemReader从头开始读而非断点续传。检查清单ItemReader是否实现ItemStream接口JdbcCursorItemReader实现了但自定义Reader必须手动实现open()/update()/close()Step是否启用allowStartIfComplete(true)默认false重启时会跳过已完成StepJobRepository是否用JDBC实现内存版MapJobRepository不持久化StateJobParameters是否包含唯一标识RunIdIncrementer生成的run.id每次不同导致JobInstance新建无法关联旧State。终极验证法查BATCH_STEP_EXECUTION表READ_COUNT字段在重启前后是否递增。若不变说明State未保存。我们的经验在ItemStream.update(ExecutionContext executionContext)里务必executionContext.putLong(current.id, currentId)且currentId必须是数据库主键如transaction.id不能是行号rowNum因为分页查询时行号不唯一。6. 面试高频题深度拆解别再说“我用过Spring Batch”了6.1 “Spring Batch的Chunk处理原理是什么”标准答案教科书“Chunk是读一批、处理一批、写一批的单元”。真实答案生产视角Chunk不是简单的循环而是一个事务边界容器。chunkSize100意味着开启事务→读100条→逐条处理→收集100个结果→批量写入→提交事务。如果第50条处理失败前49条回滚后51条不读。关键细节ItemWriter的write()方法接收List? extends T但不保证顺序。JdbcBatchItemWriter内部用PreparedStatement.addBatch()顺序由List保证但自定义Writer若用线程池并发写顺序就乱了。性能真相chunkSize影响三次数据库交互读、处理、写。我们测试过MySQL下chunkSize100时TPS1200chunkSize1000时TPS1800但chunkSize5000时TPS降到1500——因为addBatch()的ArrayList扩容耗时增加。6.2 “如何实现Job的幂等性”套路答案“用JobParameters去重”。破局思路我们线上方案Level 1参数去重JobParameters包含date和versionversion由上游系统生成如20240520_v2确保同日期不同版本可重跑。Level 2业务去重在ItemWriter里加INSERT IGNORE或ON DUPLICATE KEY UPDATE避免重复写入。Level 3状态锁Job启动时在Redis里SETNX reconciliation:20240520:lock 1 EX 7200失败则直接退出——这比数据库锁更轻量。6.3 “Batch和Quartz怎么选”错误回答“Quartz是调度Batch是处理一起用”。决策树我们团队规范如果作业必须严格按时间窗口执行如日终02:00跑用Quartz触发JobLauncher如果作业由事件驱动如FTP文件到达用EventListener监听FileEvent再启动Job如果作业需复杂依赖Step A成功后才跑Step B用Batch原生的FlowBuilder别用Quartz串联多个Job绝对禁忌用Quartz每5秒轮询BATCH_JOB_EXECUTION表查状态——这会给数据库加压用JobExplorer的getJobExecutionsForJobName()替代。最后分享一个小技巧在application-dev.yml里配spring.batch.job.enabledfalse开发时禁用自动Job启动在application-prod.yml里配spring.batch.job.enabledtrue并加spring.batch.job.namesreconciliationJob只启动指定Job——避免测试环境误跑生产Job。