SpringBoot4系列10 - 使用批处理Spring Batch
上一章的 RabbitMQ 解决的是「通知得可靠」,这一章解决「算得完」。场景还是订单:每天凌晨把当天的对账文件倒进数据库,月底把全量用户跑一遍账单,每个季度把失效数据归档。这类活的共同点是数据量大、允许延迟、绝不允许算一半。你可以接受结果晚几个小时出来,但不能接受一个算了一半的结果被当成完整结果用。
批处理和实时接口是两种脾气。实时接口怕慢,批处理怕断;实时接口一个请求一个事务,事务边界是框架划好的,批处理一口气吞几万条,事务边界得自己划。拿写接口的思路去写批处理,多半要吃亏,我就是吃过亏才学的乖。
我一个支付项目的对账模块就闹过这样的笑话:一个 for 循环,从文件里读一行,校验一下,往库里插一行,全部插完打印一句「对账完成」。逻辑上挑不出毛病,跑起来也能跑。直到有一天财务说数据少了三万条,我排查了一整晚,最后发现文件中间有一行格式是坏的,代码直接抛异常,整个循环当场死了,前面插进去的两万条留在库里,没有回滚,也没有任何记录说这次对账只跑了一半。说实话,跑了半截的账比没跑更可怕,因为没人知道它跑到了哪,下一次补跑也不知道该从哪一行接着来。

这就是批处理要解决的事:读、处理、写要有事务边界,坏数据要有去处,跑没跑完要有记录,跑挂了要能接着跑。Spring Batch 把这四件事全做成了框架能力,Spring Boot 4 配的是 Spring Batch 6。这篇全按 6 的写法来,文中所有行为我都在 Boot 4.1.1 加 Batch 6.0.5 的组合上实测过,正文里出现的每个数字都来自那几轮真实运行。
工程在 springboot4-batch 目录,场景是订单对账:CSV 文件进,MySQL 出,三条坏行跳过落台账,HTTP 触发,五个测试用例全连真实 MySQL 跑。对账这个场景不大,但批处理的四个关切它一个不缺,拿来讲机制刚好。
Spring Batch 6 的编程模型
starter 按存储拆成三层
Boot 4 的批处理 starter 不再只有一个。4.1.1 里实际存在三个,我把它们的 POM 逐个拆开看过,结构是这样的:
| |
拆分的关键在默认行为。Batch 6 的默认 JobRepository 是 ResourcelessJobRepository:不配数据库也能跑批,执行记录不走任何存储。挺有意思的一个取舍,很多应用要的只是「分块 + 容错」这套批处理模型,执行历史对它们没有价值,那就不必为了一个数据库连接付出部署成本。代价也得认:记录不落库,重启就没有断点可续,同一个参数跑两遍框架也拦不住你。需要重跑幂等、断点续跑、执行历史这些能力,就显式上 starter-batch-jdbc,把 BATCH_* 元数据表落到真数据库里。
还有个 spring-boot-starter-batch-data-mongodb,把执行记录记到 MongoDB,思路一样,这篇不展开。测试侧也有对应的拆分,-test 结尾的 starter 就是本体加测试工具的合体,后面写测试那节会用到。
怎么选?问自己一个问题:这次跑挂了,你需不需要知道它跑到哪了。不需要,第一个 starter 就够,部署还轻;需要,就上 jdbc 版。我在两个项目里分别走过这两条路,判断标准一直是这一条,没变过。
接口合并后的运行时角色
Spring Batch 的运行时角色有四个:JobRepository 管执行记录的写入,JobExplorer 管查询,JobLauncher 管启动,JobOperator 管启停和运维操作。Batch 6 用接口继承收拢了它们的注入面:
| |
业务代码里注入一个 JobOperator,启动、重启、停止、查询执行历史,全在这一个对象上。我第一次看到这条继承关系还愣了一下,反应过来之后觉得这刀切得干净:四个角色一个没少,职责还是各管各的,但你要注入的东西只剩两个,写查询逻辑时也不再需要在两个对象之间来回翻。
类在哪个包
写代码前先确认 import。Spring Batch 6 里几个天天要写的类,包位置我整理成一份事实清单:
| |
item 全家整体住在 infrastructure.item,不跟 core 混在一起。从资料里抄 import 是我这一篇踩到的最大的坑,网上能搜到的示例代码十有八九带着旧包名,编译器报「找不到符号」的时候,先核对包名,别急着怀疑自己写的逻辑。这份清单是我把工程里实际用到的 import 逐个对着源码包路径核出来的,不是从文档抄的,Batch 6 的二手资料里旧包名太多,抄之前最好都过一遍。
ChunkOrientedStepBuilder
chunk 模型的 builder 是 ChunkOrientedStepBuilder,构造参数三个:step 名、JobRepository、chunk 尺寸。事务管理器有独立的 .transactionManager() 挂点,chunk 尺寸只由构造参数决定,别处配了也不算数。容错配置从 .faultTolerant() 进入,先开总开关,再挂 .skip() 和 .skipLimit(),跳过的语义落在 SkipPolicy 接口上。
一个 chunk 的生命周期值得在纸上走一遍:reader 一条一条读,processor 一条一条过,框架攒着不提交;攒满 4 条,writer 一次写入,事务提交,元数据表里的计数跟着更新。中途任何一步炸了,这个事务整体回滚,框架再按容错配置决定是跳过坏行重来,还是整块失败。把这段走通了,后面的 skip、断点续跑就都是自然推论。
chunk 尺寸就是事务的粒度。给小了事务开销大,给大了单块回滚的代价高,对账这种量级我取 4,纯粹是为了让后面的实测数字好观察。
容错的重试引擎
chunk 容错的重试用的是 Framework 7 的 RetryTemplate,.retryPolicy() 收 org.springframework.core.retry.RetryPolicy。上一篇 RabbitMQ 的消费重试走的是同一个引擎。重试在 Boot 4 里是平台能力,各模块共用同一套语义,你在批处理里调熟的重试参数,到消息消费那边照样认识。这篇的重点在跳过不在重试,对账的坏行重试一万次也不会变好,所以重试我只交代出处,不展开配置。
依赖和配置
starter 我用 spring-boot-starter-batch-jdbc,因为这篇要把执行记录落 MySQL:
| |
三个依赖各干各的:batch-jdbc 带编程模型和 JDBC 版 JobRepository,connector-j 只在运行期连库,test starter 把 spring-batch-test 和 JDBC 测试支撑一起带进来,不用再单独引。
MySQL 在我树莓派的 Docker Compose 里跑着,IP 是 192.168.1.97,库名 springboot4_batch。本机跑批、五个测试用例、后面所有实测,连的都是这同一个库,不存在本地一套、测试一套的口径差。
配置文件里有个必须显式声明的开关:
| |
batch.job.enabled 设成 false 是因为这篇用 HTTP 触发,应用一启动就自动跑 job 不是我要的行为。initialize-schema 我要单独拎出来说:Boot 的元数据表初始化默认只认嵌入式数据库,H2 之类开箱即建,MySQL 这种外挂数据库默认不动。少了这一行,第一次跑批就报 BATCH_JOB_INSTANCE 表不存在的 BadSqlGrammar,我第一次跑就栽在这。更隐蔽的是属性前缀:这个属性挂在 spring.batch.jdbc 前缀下,写到顶层 batch.jdbc 下面会静默不生效,应用照常启动,连个 warning 都没有,九成是以为配置类没装配,实际是建表那步被跳过了。属性前缀拿不准的时候,javap 一下 BatchJdbcProperties,注解里的字符串就是答案。

核心代码
Job 定义,一个 builder 走到底
整个对账 Job 集中在一个 @Bean 方法里,step 和 job 一口气搭完:
| |
三个配置点值得说。事务管理器是独立的 .transactionManager(),chunk 尺寸只由构造参数决定,这里是 4。容错先过 .faultTolerant() 总开关,再用 .skip() 和 .skipLimit() 细化:跳过的异常类型是 ValidationException 和 FlatFileParseException 两种,前者是业务校验不过,后者是 CSV 那一行列数对不上,上限给了 10。.skipListener() 收一个普通对象,这里的 ReconSkipListener 同时实现了 SkipListener 和 StepExecutionListener 两个接口,step 开始时把 jobExecutionId 交给台账,坏行才有归属。没有这个 id,台账里的坏行就不知道自己属于哪一次执行。
job 这一侧就两件事:JobBuilder 把 step 挂上去,.listener(stats) 挂一个 job 级监听器。ReconStatsService 既在 processor 和 writer 里当计数器,又当 job 监听器,job 跑完它手里就有全套数字,台账接口直接从它取值。一个 bean 身兼两职,省掉了中间传状态的代码。
校验逻辑就是个普通函数,金额非正、状态不在白名单、订单号格式不对,抛 ValidationException:
| |
规则本身没什么可说的,三条检查,哪条不过就把原因记进 bad,最后统一抛。注意这里没有 try-catch 兜底,没有降级,就是抛。坏数据的去处不归 processor 管,归容错配置管。这是批处理和业务代码的一个心态差别,我一开始也没扭过来,总想在 processor 里把一切处理得体面,后来才想明白:processor 只负责把坏行喊出来,怎么处置是容错层的事。
reader 参数化,文件名从 jobParameters 来
对账文件每天一个,reader 不能写死,标准做法是 @StepScope:
| |
#{jobParameters['fileName']} 这个 SpEL 表达式只在 step 作用域能解析,reader 这个 bean 每次 job 启动才创建,参数自然就进来了。linesToSkip(1) 跳过 CSV 的表头,.delimited() 按列名把字段映射到 OrderRow 的属性上。FlatFileItemReaderBuilder 的 .delimited() 支持传 lambda,写成 .delimited(config -> config.names(...)),比记一长串链式方法省事。.strict(true) 是我故意开的:文件不存在直接失败。对账这种事宁可失败,也不能静默跑一个空批出来。
参数里只放文件名还有一个好处:JobInstance 的唯一性就是 job 名加全部参数,文件名当参数,同一个文件天然只有一个实例,幂等判定白送。要是把时间戳掺进参数里,每次触发都是新实例,幂等防线立刻失效。参数不是越多越好,够用就好。
writer,一个 SQL 搞定
| |
.beanMapped() 让 SQL 里的命名参数直接对上 OrderRow 的属性名,一行手写映射都不用。.assertUpdates(true) 让每条写库的影响行数必须是 1,否则抛异常。批处理最怕的是「以为写进去了,库里没有」,这个开关把静默失败变成显式崩溃,稳稳的赚。SQL 里那个 NOW() 是入库时间,回头排查时拿它跟元数据表里的执行时间对轴,一眼就能看出哪一批是谁。

把整条链路摆在一起看:reader 按 chunk 拉数据,processor 校验,writer 入库,每 4 条一个事务提交一次;坏行在容错层被 skip 接住落进台账,框架自己往 BATCH_* 五张元数据表里记每一笔执行记录。业务数据走上面的管道,执行记录走下面的表,两条线互不干扰,缺一条就不叫批处理。你写的代码只长在上面那条线,下面那条线全是框架在记。
容错跳过的完整去向

一条坏行的旅程是这样的:processor 抛出 ValidationException,容错层拿 SkipPolicy 问一句「该跳吗」,异常类型在 skip 清单里、计数没到 skipLimit,答案是可以;这一条被记进 skip 统计,chunk 里剩下的行照常写库,坏行本体转交 skipListener 落台账。整个过程业务代码无感,但台账里有名有姓。我测试时故意喂了一条金额是字母的行,跑完翻台账,它和另外两条坏行并排躺着,行内容加跳过原因,一条不少。
要是异常不在清单里,或者跳过数已经到上限,答案就是不行,整个 chunk 回滚,job 以失败告终。这个兜底很重要:skip 是给「已知的坏」准备的,出了清单范围的意外,框架宁可整块失败也不会替你悄悄扔数据。
触发,JobOperator 一个入口
启动任务就一个门:
| |
参数只用了一个 fileName,字符串类型。JobParametersBuilder 还支持日期、长整型这些类型,参数越丰富,唯一性判定就越精确。同步执行,方法返回的时候 job 已经跑完,execution 里就是终态。HTTP 层包一层就是完整的触发接口:控制器把 fileName 接下来转手给 jobOperator.start(),响应 201,Location 头指向这次执行的查询地址,客户端拿着它去查执行详情。幂等的活框架已经干完了,同一个 fileName 参数第二次触发会抛 JobInstanceAlreadyCompleteException,我把它翻成 409:
| |

这个 409 有明确的语义支撑:一个 JobInstance 由 job 名加参数唯一确定,跑完就是终态,想重跑就换参数。BATCH_JOB_INSTANCE 表里那一行记录就是「这个文件已经对过账」的铁证,比任何业务代码里的 if 都可靠。业务代码的 if 会被人改掉,元数据表里那行记录不会。
完整案例,跑起来看
对账文件 12 行,我故意埋了 3 条坏行:一条负金额,一条状态是白名单外的 CANCELLED,一条金额是字母。
| |
三条坏行各踩一条校验规则,正好把 skip 链路的三种原因都喂了一遍。位置也不是随便挑的:按文件顺序,SO10004 落在第一个 chunk,SO10006 落在第二个,SO10010 落在第三个,三条坏行分散在三个事务块里,好行的提交互不受牵连。
启动应用,触发一次对账:
| |
返回 201,Location 指向这次执行的查询地址。跑完看台账:

12 读、9 写、3 跳过,job 状态 COMPLETED。跳过的三条都有明细,行内容和跳过原因一一对应:SO10004 是负金额,SO10006 是状态不在白名单,SO10010 是金额不是数字。这个结果跟我在文件里埋的坏行完全对得上,三个数加一个状态,把这次执行的骨架说完了。
同一个请求我再发一次:

409,application/problem+json,type 是 urn:problem-type:business-rule-violation,跟系列前几篇的错误响应是同一套骨架。
最硬的证据在数据库里。BATCH_STEP_EXECUTION 表里躺着框架自己记的账:
| |
chunk 尺寸 4,12 行数据 3 次提交,读写跳过三个数跟台账严丝合缝。这张表是框架写的,应用代码没有碰它,我特意全局搜了一遍工程,没有任何一处 SQL 指向 BATCH_ 开头的表。job 挂在半路,这张表知道它死在哪一个 chunk,重启时从哪里继续。这就是前面说的「跑挂了要能接着跑」的物质基础。
顺带翻了一眼另外两张表:BATCH_JOB_INSTANCE 里这个文件一行,BATCH_JOB_EXECUTION 里这次触发一行。表不多,每张只回答一个问题:实例是谁、执行得怎么样、step 干了多少活。查问题的时候顺着外键从 instance 走到 step,一次执行的来龙去脉几分钟就能拼出来。
想自己复现的话,工程 README 里写了启动与触发步骤,照着来一遍,台账、409、元数据表三样证据就都是你自己的了。
这一篇的测试怎么写
五个用例我全连了真实 MySQL:
| |
每个用例跑之前,@BeforeEach 先把元数据表里的执行记录和业务表里的对账结果清掉,不然上一轮留下的 JobInstance 会让同参数的触发直接 409。清理走的是框架给的 removeJobExecutions(),不是手写 DELETE,元数据表之间有外键,手工删顺序错了一身麻烦。
@SpringBatchTest 提供的测试工具类是 JobOperatorTestUtils,启动方法 startJob(),跟生产代码用 JobOperator 的姿势一致。测试文件也走 classpath 读取,跟生产同一条路径,测出来的就是生产行为。写测试就是在预演生产的启停方式,生产代码注入什么,测试里就操练什么。
五个用例的分工是这样的:第一个跑完整链路,读、校验、写、落台账一条龙,断言终态 COMPLETED。第二个喂坏文件,断言 skip 计数是 3 而且状态照样 COMPLETED,这是容错语义的核心:跳过不是失败,容错跑完就是成功。第三个用例验证同参数二次执行抛 JobInstanceAlreadyCompleteException,幂等防线钉死在测试里。第四个用例走 MockMvc 打 HTTP 面,201、409、404 一个个过,把触发接口的三种出路全盖住。第五个用例只有两行,断言 JobRepository instanceof JobExplorer,把接口合并这件事钉在测试里,将来升级批次要是有变化,测试第一个知道。
避坑指南
坑一,initialize-schema 写错前缀,静默不生效。 正确位置是 spring.batch.jdbc.initialize-schema。写到顶层 batch.jdbc 下,应用正常启动,第一次跑批直接 BadSqlGrammar: BATCH_JOB_INSTANCE does not exist。错前缀在 Boot 里常见,这一处错得特别安静,因为配置类照样装配,只是建表那步被跳过了。前缀拿不准就 javap 一下 BatchJdbcProperties,注解里的字符串就是答案。
坑二,import 抄错包名,编译器第一个知道。 常用类的包位置我抄在这里:Job 和 JobExecution 在 core.job,JobParameters 在 core.job.parameters,StepExecution 在 core.step,item 全家在 infrastructure.item。资料里的 import 与这份清单对不上时,以清单为准,别硬猜。
坑三,@StepScope 不加,SpEL 就是个死字符串。 #{jobParameters['fileName']} 只有在 step 作用域的 bean 里才会被解析,普通单例 bean 上写这个,启动时 reader 拿到的是字面量,文件找不到直接失败。反过来,加了 @StepScope 的 reader 别在启动时就去访问它,参数还没来。
坑四,嵌入式数据库的默认值会骗人。 本地用 H2 跑得好好的,元数据表自动建,一到测试环境的 MySQL 就报表不存在。Boot 对嵌入式库的默认初始化在换库时全是坑,凡是非嵌入式数据源,建表的事都自己声明一遍。
坑五,同参数 409 是语义,是幂等防线。 测试环境连着生产库调试的时候,同一个文件反复触发会一直 409,容易误判成 bug。处理办法只有两个,换参数(加一个 runSeq 之类的参数位),或者清 BATCH_JOB_INSTANCE 及其关联表。清表是危险动作,BATCH_* 五张表有外键关联,顺序删错会把元数据表搞脏,测试代码里用框架给的 JobRepositoryTestUtils.removeJobExecutions() 最稳。
坑六,坏行跳过要有台账,否则容错变成吞错。 .skip() 配好了,坏行不挡路,但它也消失了,框架不给默认的记录。skipListener 不是可选项,跳过了什么、为什么跳,必须落在一个能查到的地方。台账可以先用内存 Map 加一张数据库表,演示够了;生产上把坏行单独落一张表,是批处理上线前的最后一道必答题。
坑七,batch.job.enabled 忘了关,应用一启动就跑 job。 Boot 默认应用启动时自动执行容器里的 job,这篇的 reader 带 .strict(true),参数里没有 fileName,文件找不到当场失败,启动日志里一个资源异常,跟应用本身没半点关系。HTTP 触发的应用,这个开关必须显式关掉。
坑八,参数里掺时间戳,幂等防线就废了。 JobInstance 由 job 名加全部参数共同确定,参数里加一个 new Date(),每次触发都是新实例,同参数 409 永远打不出来。想区分「重跑同一个文件」和「跑新的文件」,该换的是 fileName,不是往参数里塞时间。
小结
选型建议一句话能说清:轻量场景用默认的 ResourcelessJobRepository 起跑,一个数据库都不用配;要执行历史、重跑幂等和断点续跑,就上 starter-batch-jdbc,元数据表的事交给框架。入口统一收在 JobOperator,chunk 用 ChunkOrientedStepBuilder 搭,测试配 JobOperatorTestUtils,这套组合我整篇验证下来是顺的,照抄不会踩到结构性问题。
没解决的事也有两件。一是台账目前还是演示级的内存 Map 加一张表,生产上要把坏行单独落表,配上查询和补录的口子。二是这篇只跑了单进程串行 chunk,远程分区和并行 step 能把吞吐再抬一个量级,等我真的有超大文件要跑,再回来补测一篇。
参考链接
- Spring Batch 6.0 What’s New:本文 Batch 6 编程模型与容错配置的官方说明
- Spring Batch Reference:JobRepository 与元数据表结构的权威文档
- Spring Boot 4.1 Reference:batch 章节的属性清单,
spring.batch.jdbc.*前缀以此为准 - spring-boot-starter-batch-jdbc 4.1.1:本文 starter 三层结构的 POM 实证来源