1. 当200万条数据把JVM内存撑爆之后,我决定认真聊一聊Spring Batch
先讲一段真实的经历。之前在公司做一个月度结算报表的定时任务,逻辑很简单:从订单表查出全量订单,逐条计算佣金,再写回结算表。第一个版本我图省事,直接在Service里写了for循环,一次性查出所有数据放内存里处理。上线第一个月就出事了——200万条订单,每条附带十几个关联字段,查询出来直接堆了快3个G。JVM频繁Full GC,最后直接在半夜把线上服务搞挂了。
那之后我老老实实去研究批处理框架,才真正把Spring Batch体系吃透。这个框架解决的根本问题,其实一句话就能说清楚:当数据量大到没法一次性塞进内存时,如何用分片加事务的方式,稳定、可恢复、可监控地把数据处理完。
如果你是这么几种情况,这篇文章就是写给你的:手里有个跑批任务但还在用for循环硬扛;听说过Spring Batch但不知道它跟普通循环有什么本质区别;或者已经在用Spring Batch,但搞不懂chunk、commit-interval、skip策略这些参数到底怎么配才合理,为什么数据量一上来就频繁报警。这篇会从原理讲到一次完整的实战落地,再把我踩过的坑全部倒出来。
2. 为什么普通for循环搞不定大批量处理,差异到底在哪里
很多人一开始跟我一样有个疑惑:我写个循环,分批查询,分批更新,不也能处理大数据量吗?为什么非要引入一个框架?这个问题的答案,藏在四个普通循环很难做好的点上。
2.1 事务边界:循环里最容易犯的隐蔽错误
假设你写了一个循环,每处理1000条提交一次事务,看起来没什么问题。但你考虑过失败回滚的粒度吗?如果第888条数据因为格式问题抛异常,你已经提交了前面的887条——这就是部分成功。如果是财务结算,这种状态就是致命的:系统里一半的数据显示处理完成,另一半显示未处理,你得写补偿脚本去对齐两边。
Spring Batch对事务边界给出的答案是chunk模型:一个chunk内的处理逻辑包在同一个事务里,要么全部成功,要么全部回滚,绝不存在中间状态。chunk这个概念后面会详细说,这里只要记住,框架把事情做对是设计出来的,而不是靠程序员小心谨慎维护出来的。
2.2 失败恢复:跑批挂了之后怎么办
跑批任务的执行时间通常很长,动辄半小时几小时。如果凌晨三点挂了,第二天上班才发现,怎么知道挂了?挂在哪一步?已经处理完的数据要不要重新跑?普通循环代码里,这些信息全靠你自己打日志去猜。
Spring Batch里所有这些信息都在JobRepository里,框架自动记录:哪个Job在什么时间启动,当前执行到哪一步,成功处理了多少条,失败了多少条。下次启动时,可以指定从上次失败的Step继续跑,而不是从头再来。
2.3 性能数据:你试过和Spring Batch的吞吐量对比吗
这不是随口一说,我做个了简单的对比测试。同样的机器,处理100万条订单数据(每条约0.5KB,附带计算逻辑),for循环单线程版本跑了18分36秒,Spring Batch默认配置跑了一版,12分47秒。差距主要来自两点:一是Spring Batch默认使用批量预编译语句执行写入,二是reader/writer之间的chunk处理能自动合并单次I/O的条数。
当然这个数据不是绝对的,只是说明一个问题:框架不仅仅是把你的代码换了个写法,它是从I/O层面重新设计了数据处理方式。
2.4 可观测性:出了问题你能定位到哪里
普通循环挂了,你看日志输出到第几行,然后去数文件里的行号。Spring Batch有完整的执行上下文和监听器机制,每个Step执行完会记录readCount、processCount、writeCount、skipCount,出了异常直接能看到卡在哪一步,跳过了多少条数据。处理千万级数据时,这个能力能救命的。
3. 核心概念拆解:Job、Step、Chunk和Listner谁负责什么
Spring Batch的术语体系不算复杂,但概念之间的关系如果没人点透,初学者往往会卡住。我用一个做饭的类比来讲:Job是整个宴会,Step是宴会上的一道菜,Chunk是切菜炒菜装盘的一整套工序,Listener是你在每个环节安排的服务员。
3.1 Job:一次完整的批处理任务
Job是你要执行的整个批处理流程,它可以由一个或多个Step组成。比如一个数据迁移任务,可能需要三个Step:从旧库读取并清洗数据(Step1),把清洗后的数据转换格式(Step2),写入新库(Step3)。Job负责把这些Step串起来,并决定它们的执行顺序。
Job有几个关键特性需要理解:
- 一个Job对应一次独立的业务场景。比如每天凌晨的订单汇总是一个Job,每周的报表生成是另一个Job,不要混在一起。
- Job有一个全局的JobParameters机制。可以在启动时传入参数,比如时间、批次号。这些参数会影响JobInstance的识别——同一个Job,不同参数,会被认为是两次不同的执行。
3.2 Step:Job里的最小执行单元
Step是Job里的执行节点。Spring Batch有两种Step类型:
- Chunk-oriented Step(块处理步骤):这是90%以上的场景会用到的类型。每个Step内部循环执行“读一条处理一条,攒够一定数量再统一写”的流程。
- Tasklet Step(任务型步骤):适合那些不需要分块的场景,比如清理一个临时目录、启动一个外部系统、发送一个通知。Tasklet里你只需要实现一个
execute方法,返回RepeatStatus.FINISHED就算完成。
实际项目中经常是两者搭配使用:用Tasklet做准备工作(比如创建导出目录、清空临时表),用Chunk Step处理核心数据。
3.3 Chunk:事务的边界所在
Chunk是整篇文章中最核心的概念,理解了它,Spring Batch就懂了七成。
Chunk的意思是“块”。处理流程是这样的:
ItemReader读取一条数据;ItemProcessor处理这条数据(可以理解为业务计算);- 循环这个过程,直到读够一定数量(这个数量叫
commit-interval,默认是1000,后面调优会讲); - 攒够一“块”后,一次性把这批数据交给
ItemWriter写入目标存储; - 每完成一个chunk,提交一次事务。
这个机制解决了两个问题:一是内存可控。不管源头有几千万条数据,每次内存里最多只放一个chunk的数据,处理完就写掉,释放内存。二是原子性可控。一个chunk内部如果某一条数据处理失败,整个chunk回滚,不会出现写了一半的情况。代价是,如果每1000条一个chunk,失败时会回滚这1000条全部,重跑时这1000条会重新处理。
3.4 Listener:在流程的关键节点插入自定义逻辑
Listener是Spring Batch提供的一种扩展机制,让你能在特定时机插入自定义代码。常用的几个接口:
JobExecutionListener:Job启动前和结束后可以执行逻辑,比如Job开始时发个通知,Job结束后写个统计表。StepExecutionListener:Step开始前和结束后执行逻辑,可以在这里记录Step的开始结束时间。ItemReadListener/ItemProcessListener/ItemWriteListener:在读写处理的每个环节触发,一般用于实时输出进度、记录失败数据。
我建议至少要有两个Listener:一个是Step级监听器,记录每个Step的处理耗时和条数;一个是ItemProcess监听器,捕获处理失败但不会中断Job的数据,把它们单独存入一张错误表。至于为什么不用默认的skip机制做这件事,后面踩坑篇会重点聊。
4. 从零搭一个真实项目:订单月度汇总批处理
理论讲完,上实战。这里我带着你从依赖开始,一次跑通。项目用Spring Boot 2.7 + Spring Batch 4.3 + MyBatis-Plus + H2数据库(跑通后换成真实数据库完全一样),处理逻辑是:从订单表读取上一个月的订单记录,按用户ID汇总订单金额,写入月度汇总表。
4.1 初始化依赖和配置
创建一个Spring Boot工程,pom.xml里引入:
<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.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>Spring Batch需要一张元数据表来存储Job和Step的执行记录,框架提供了一套DDL脚本,在Spring Boot里只要配置好数据源,启动时会自动执行。
这里有个容易忽略的细节:Spring Batch元数据表必须和你业务数据的存储在一起。如果JobRepository用的数据源跟业务库不一致,分布式事务会非常麻烦。生产环境里常见做法是,在同一个物理库里,业务表一套,BATCH_*开头的元数据表一套,各用各的schema。
配置文件application.yml核心部分:
spring: datasource: url: jdbc:h2:file:./data/batch-demo;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: batch: job: enabled: true initialize-schema: ALWAYS注意spring.batch.job.enabled=true这个配置。它表示应用启动时就自动执行定义的Job。开发调试时挺方便,但生产环境一般不用这个,而是通过JobLauncher手动触发(后面会讲),因为生产环境经常需要控制Job的执行时机,有时是定时任务调用,有时是手动触发。
4.2 定义实体类和表结构
订单表t_order:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, order_amount DECIMAL(10,2) NOT NULL, order_time TIMESTAMP NOT NULL );月度汇总表t_user_monthly_summary:
CREATE TABLE t_user_monthly_summary ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, stat_month VARCHAR(7) NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_month (user_id, stat_month) );对应的实体类分别是OrderEntity和UserMonthlySummaryEntity,字段一一对应,这里不重复贴了。
4.3 实现Reader、Processor、Writer三个核心接口
Reader:Spring Batch提供的JdbcPagingItemReader是个很强大的工具,它通过SQL分页查询,每次只从数据库取一页数据,不会一次性把全表加载进内存。
@Bean public JdbcPagingItemReader<OrderEntity> orderReader(DataSource dataSource) { JdbcPagingItemReader<OrderEntity> reader = new JdbcPagingItemReader<>(); reader.setDataSource(dataSource); reader.setFetchSize(1000); reader.setPageSize(1000); reader.setRowMapper((rs, rowNum) -> { OrderEntity order = new OrderEntity(); order.setId(rs.getLong("id")); order.setUserId(rs.getLong("user_id")); order.setOrderAmount(rs.getBigDecimal("order_amount")); order.setOrderTime(rs.getTimestamp("order_time").toLocalDateTime()); return order; }); // 构造分页查询SQL。注意:必须要有排序 Map<String, Object> parameterValues = new HashMap<>(); parameterValues.put("startTime", LocalDateTime.now().minusMonths(1).withDayOfMonth(1).toLocalDate().atStartOfDay()); parameterValues.put("endTime", LocalDateTime.now().withDayOfMonth(1).toLocalDate().atStartOfDay()); String sql = "SELECT id, user_id, order_amount, order_time FROM t_order " + "WHERE order_time >= :startTime AND order_time < :endTime " + "ORDER BY id"; reader.setSql(sql); reader.setParameterValues(parameterValues); return reader; }留意两个关键点。第一,分页查询必带ORDER BY,否则MySQL和Oracle很容易翻页翻出问题,特别是数据量大了以后可能出现重读或漏读。第二,理想情况下ORDER BY字段最好是主键或唯一索引,保证排序稳定。
另外,setFetchSize(1000)和setPageSize(1000)是两个不同的东西。前者是JDBC驱动每次从数据库拉取的游标行数,后者是Spring Batch翻页时每页的大小。两个配合使用,可以让数据源源不断地从数据库流式读取,而不是一次性全塞内存。
Processor:这个业务里Processor做两件事:算出月份,做用户维度聚合。
@Component public class OrderSummaryProcessor implements ItemProcessor<OrderEntity, UserMonthlySummaryEntity> { @Override public UserMonthlySummaryEntity process(OrderEntity order) throws Exception { UserMonthlySummaryEntity summary = new UserMonthlySummaryEntity(); summary.setUserId(order.getUserId()); summary.setTotalAmount(order.getOrderAmount()); String month = order.getOrderTime().format(DateTimeFormatter.ofPattern("yyyy-MM")); summary.setStatMonth(month); return summary; } }注意Processor的输入和输出类型可以不一样——这正是它存在的意义:把读到的原始数据,转换成你最终想写入目标存储的结构。
Writer:由于按月统计上,同一个用户可能有多个订单,这里Writer就不能简单insert,而是用upsert,存在则累加金额。我直接用JdbcBatchItemWriter配合SQL实现:
@Bean public JdbcBatchItemWriter<UserMonthlySummaryEntity> summaryWriter(DataSource dataSource) { JdbcBatchItemWriter<UserMonthlySummaryEntity> writer = new JdbcBatchItemWriter<>(); writer.setDataSource(dataSource); writer.setSql( "INSERT INTO t_user_monthly_summary (user_id, total_amount, stat_month, create_time) " + "VALUES (:userId, :totalAmount, :statMonth, NOW()) " + "ON DUPLICATE KEY UPDATE total_amount = total_amount + VALUES(total_amount)" ); writer.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>()); writer.afterPropertiesSet(); return writer; }这里演示的是MySQL的写法,如果用PostgreSQL得换成ON CONFLICT语法。另外JdbcBatchItemWriter还有一个很关键的配置项assertUpdates,默认是true,如果一条批量SQL实际影响行数是0,会抛异常。批量写入场景里我建议把它设为false,否则可能出现一些很打击人的误报。
4.4 装配Job和Step
@Configuration @EnableBatchProcessing public class BatchConfig { @Autowired private JobBuilderFactory jobBuilderFactory; @Autowired private StepBuilderFactory stepBuilderFactory; @Bean public Job orderSummaryJob(Step summaryStep, JobExecutionListener jobListener) { return jobBuilderFactory.get("orderSummaryJob") .start(summaryStep) .listener(jobListener) .build(); } @Bean public Step summaryStep(ItemReader<OrderEntity> reader, ItemProcessor<OrderEntity, UserMonthlySummaryEntity> processor, ItemWriter<UserMonthlySummaryEntity> writer) { return stepBuilderFactory.get("summaryStep") .<OrderEntity, UserMonthlySummaryEntity>chunk(1000) .reader(reader) .processor(processor) .writer(writer) .build(); } }chunk(1000)前面提过,表示每攒1000条数据处理一次,也是每个事务的边界。这个参数后续调优就是改这里。
4.5 手动触发Job的两种常用姿势
生产环境中,我推荐不要用spring.batch.job.enabled=true自动启动,而是通过代码控制。最简单的方式是用CommandLineRunner:
@Component public class JobRunner implements CommandLineRunner { @Autowired private JobLauncher jobLauncher; @Autowired private Job orderSummaryJob; @Override public void run(String... args) throws Exception { JobParameters params = new JobParametersBuilder() .addString("execTime", LocalDateTime.now().toString()) .toJobParameters(); jobLauncher.run(orderSummaryJob, params); } }另一种常见方式是用REST接口触发,适合那种需要运维在页面上点按钮重启跑批的场景:
@RestController @RequestMapping("/batch") public class BatchController { @PostMapping("/run") public String runJob(@RequestParam("jobName") String jobName) throws Exception { Job job = jobRegistry.getJob(jobName); JobParameters params = new JobParametersBuilder() .addString("triggerTime", LocalDateTime.now().toString()) .toJobParameters(); JobExecution execution = jobLauncher.run(job, params); return "Job started, execution id: " + execution.getId(); } }注意一个坑:Spring Batch默认要求同一个Job的JobParameters不能完全重复。如果你第二次用一模一样的参数启动同一个Job,会直接抛出JobInstanceAlreadyCompleteException。所以每次运行时至少需要一个随机的参数(比如时间戳)作为区分。这个机制本身是为了防止意外重跑,但很多人第一次用不熟悉会被它坑一下。
5. 大数据量场景下的调优实战:吞吐量、内存和I/O的平衡
框架跑通只是第一步。真实生产中,几百万上千万的数据量会把很多“看着正常”的配置打回原形。这一节我把数据量上去之后最影响性能的几个参数逐个拆开讲,并给出一个我实测过的调优案例。
5.1 commit-interval到底设多少合适
chunk(1000)的1000,在真正的千万级数据下未必是最优解。这个值直接影响两点:事务的粒度和批写入的单批条数。
- 设得太小(比如50),事务提交频繁,每条I/O的批量优势发挥不出来,整体吞吐量会明显下降,而且数据库的提交日志会很多。
- 设得太大(比如50000),单批数据在内存中的开销很高,几个大字段列加一加起来可能几十MB,一旦这一批里有异常要回滚,代价太大。
我做过一组对比测试(数据源:MySQL 8.0,单表1200万行,裸批写入无业务计算),不同commit-interval对应的耗时结果:
| commit-interval | 耗时(分钟) | 峰值内存(MB) | 失败回滚代价 |
|---|---|---|---|
| 200 | 17.8 | 240 | 低 |
| 1000 | 13.2 | 290 | 中 |
| 5000 | 11.9 | 450 | 中高 |
| 10000 | 11.5 | 620 | 高 |
从这组数据能得出两个结论:commit-interval在一个范围内(1000到5000)对耗时影响不算特别大,但内存和回滚代价的增长是线性的。所以我的经验值是:内容简单的数据用5000,字段多或者写库逻辑复杂用1000~2000。不需要追求极限,稳定才是跑批第一原则。
5.2 Reader的fetchSize和游标读取:关键I/O优化
JdbcPagingItemReader内部靠分页查询实现流式读取,每次先执行一个带LIMIT的SQL,查出一页,然后通过RowMapper转成对象。这里两个参数值得细说:
fetchSize:这个是JDBC驱动层面的。MySQL里设一个合理值(比如1000),驱动会一次从数据库拉取1000行到客户端,减少网络往返次数。默认值是0,意思是驱动自己决定,在MySQL里结果集小无所谓,数据量大时差距还是很明显的。pageSize:Spring Batch每页读取的记录数,每一个page结束时,Reader会检查是否还有更多数据。这里的副作用是,每条数据经过processor之后会被放进当前chunk的list里,所以pageSize最好和commit-interval保持一致或者小于它,否则会出现一个chunk里面包含多个page的数据。
还有一个性能杀手是RowMapper里的复杂转换逻辑。如果你在RowMapper里做大量字符串处理、正则匹配、日期格式化,这个开销会乘以总行数,在千万级数据下非常可观。建议RowMapper里只做字段映射,复杂的业务计算移到Processor里,至少在代码结构上能保持清晰。
5.3 Writer的批量提交模式:Batch写入 vs 逐条写入
JdbcBatchItemWriter默认就是批量模式,它把一批数据用PreparedStatement批量执行。但很多人不知道的是,JdbcBatchItemWriter还有一种更高效的方式——用NamedParameterJdbcTemplate的批量API。
writer.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>());这个配置是默认的逐条参数绑定。数据量大的时候,你可以在Writer的write方法里自己接管,用NamedParameterJdbcTemplate.batchUpdate()一次性提交整个chunk:
@Component public class SummaryBatchWriter implements ItemWriter<UserMonthlySummaryEntity> { private final NamedParameterJdbcTemplate jdbcTemplate; public SummaryBatchWriter(DataSource dataSource) { this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource); } @Override public void write(List<? extends UserMonthlySummaryEntity> items) { String sql = "INSERT INTO t_user_monthly_summary ... ON DUPLICATE KEY UPDATE ..."; SqlParameterSource[] batch = new SqlParameterSource[items.size()]; for (int i = 0; i < items.size(); i++) { batch[i] = new BeanPropertySqlParameterSource(items.get(i)); } jdbcTemplate.batchUpdate(sql, batch); } }实测在MySQL下,这种方式的批量写入速度比默认逐条模式要快不少,尤其是在字段多、单条SQL复杂时。不过要注意,批量写入遇到某一条数据违反了约束(比如重复主键),默认可能会中断整批,需要配合skip策略来处理。
5.4 并行Step和多线程处理:什么时候真正值得用
Spring Cloud Batch还支持把一个Step拆成多个分区(Partition),每个分区独立线程池处理一部分数据。这个功能能让吞吐量有质的飞跃,但必须谨慎——因为多线程并行意味着数据库连接数、事务并发、锁竞争都会放大。
我的建议是:单线程跑批如果能在业务允许的时间窗口内完成,就不要上并行。并行处理带来的好处是缩短耗时,代价是复杂度和故障排查难度大增。如果你的数据量确实大到单线程无法接受,可以做分区,但先要确认数据库能扛得住并发连接,同时要小心写入目标是同一张表时的锁冲突。我之前把并行数调到8,结果写入表行锁频繁,反而比单线程更慢,最后老老实实改回4。
5.5 一个真实的性能调优案例
说个真实项目。一个对账系统,每天凌晨要处理大约800万条支付流水,和第三方支付渠道的账单做对比。改造成Spring Batch之前,原系统是纯SQL存储过程,跑一次要2小时40分;改造成Batch后第一版自动任务跑了55分钟,已经是明显提升。后来做了三处优化:把commit-interval从1000调到5000、把原有的RowMapper里的日期格式化改到Processor、把Writer改成批量模式,最终稳定在31分钟左右。第二次优化几乎没改业务代码,纯粹是调整框架参数和I/O方式,说明Spring Batch的调优空间确实很大。
6. 跑批失败后的处理:重启、跳过和事务回滚机制
线上跑批最怕的就是半夜挂了没人知道。Spring Batch给了三层防护机制:跳过(skip)、重启(restart)、重试(retry)。用好了这三层,跑批故障的恢复能力会强非常多。
6.1 跳过策略:个别脏数据不能拖垮整个Job
业务数据总会有一些“脏数据”:手机号格式不对、金额为负、关联用户不存在……如果一条脏数据就让整个Job回滚重跑,会造成大量无效计算。Spring Batch的skip机制就是干这个的。
@Bean public Step summaryStep(...) { return stepBuilderFactory.get("summaryStep") .<OrderEntity, UserMonthlySummaryEntity>chunk(1000) .reader(reader) .processor(processor) .writer(writer) .faultTolerant() .skip(Exception.class) .skipLimit(100) .noSkip(DataIntegrityViolationException.class) // 数据库约束异常不跳过 .build(); }这段配置表示:处理过程中任何异常,最多跳过100条,超过100条则Job失败。同时指定DataIntegrityViolationException不跳过——因为这类异常通常是数据层面的硬伤(比如主键冲突、非空约束),跳过会丢失数据,不如直接让Job失败、人工介入。
跳过策略最关键的一点是,被跳过的数据需要记录在哪里。框架默认只记在Step的skipCount里,日志里能看到,但事后想排查具体是哪几条很麻烦。我的做法是加一个ItemProcessListener,在onProcessError回调里把失败的数据原样保存到一张batch_error_record表:
@Component public class ProcessErrorRecordListener implements ItemProcessListener<OrderEntity, UserMonthlySummaryEntity> { private final ErrorRecordMapper errorRecordMapper; @Override public void onProcessError(OrderEntity item, Exception e) { errorRecordMapper.insert(item, e.getMessage()); } }这样跑批结束后,直接查错误表就能看到所有被跳过的数据和原因,方便人工补数。
6.2 重启机制:从头跑还是从失败点接着跑
Spring Batch默认支持失败后重启。假设Job跑到Step2失败了,JobExecutionContext里记录的Step1已经执行完成,重启时Step1不会重新执行,直接从Step2开始。
这里有个隐蔽的问题:如果Step1里面做了些非幂等的操作(比如清空临时表),重启时不执行Step1,可能会导致后续Step拿到的数据不对。解决方式是给Job加参数,比如在JobParameters里带一个“重跑序号”,每次手动触发失败Job时换一个批次号,让所有Step都重新走一遍;或者给Step的reader加一个条件判断,如果当前是重启模式,从增量位置继续读。
框架还有一个allowStartIfComplete配置,默认false,表示如果一个Step已经执行成功,重启时不会重新执行。如果你明确要全部重跑,可以在jobBuilderFactory.get(...).start(step).preventRestart()或者step上设置allowStartIfComplete(true)。
6.3 重试:应对临时性故障
retry和skip的区别是:retry是针对那些“这次失败下次可能成功”的场景,比如网络抖动、数据库连接池暂时满。Spring Batch的retry机制会自动重试N次,超过次数才走skip逻辑。
.retry(TransientDataAccessException.class) .retryLimit(3)这种临时性异常如果重试了3次还失败,那条数据会被跳过(如果配置了skip),或者让整个Job失败。生产环境里网络抖动出现的频率其实不低,配置retry能显著降低失败率。
7. 几个我踩过的坑以及排查思路
最后一章分享一下实战中遇到的几个印象深刻的坑。这些问题在官方文档里都有提及,但没人提醒的话,踩到的概率非常高。
7.1 H2数据库居然不是所有方言都兼容
我一开始拿H2当开发库,写完Job在本地跑得好好的,部署到测试环境换成MySQL,结果部分SQL报语法错误。排查了半天,发现是分页SQL的写法问题——H2和MySQL的分页语法在一些Edge Case下行为不同。从那以后我养成了习惯:开发环境尽量用和测试、生产一致的内存库,甚至直接连一个真实的MySQL实例。用H2开发的话,数据库方言差异踩坑是迟早的事。
7.2 JdbcPagingItemReader的SQL里不能随便ORDER BY非唯一字段
分页reader要求SQL排序稳定,否则翻页时可能出现重复读取或漏读。用一个非唯一字段排序会导致分页错乱。比如按order_time排序,但表中很多订单是同一秒创建的,就会导致记录在翻页边界上时被重复或遗漏。解决办法是ORDER BY多个字段,最后加一个绝对唯一的字段兜底,比如ORDER BY order_time, id。如果表有自增主键,直接用主键排序是最省心的。
7.3 事务回滚不代表数据一定“回到原样”
chunk模型的事务边界是Spring Batch的核心保障,但有一个容易被忽略的点:如果Writer里用了某些没有完全纳入Spring事务的组件(比如直接操作Redis、MQ),这些外部系统的写入不会跟着数据库事务一起回滚。数据库回滚了,Redis里已经写入的数据还在,这会造成数据不一致。
解决方案有两种:一种是外部系统的写操作放在Step末尾的Listener里,而不是Writer里;另一种是让这些外部操作天然幂等(比如Redis存的是最终结果,重新执行时覆盖写即可),这样即使回滚后再次处理,也不会产生错误结果。
7.4 JobParameters时间戳参数导致Job永远重启
之前分享的代码里,每次手动触发Job时都带了execTime时间戳参数。这本身没问题,但有个隐患:如果运维或定时任务每次拿当前时间作为execTime,那么每次执行都会被Spring Batch判定为“不同的Job实例”,这样元数据表里会积累大量历史JobInstance记录,时间长了膨胀得很快。而且如果某次失败后,你想用“同一批次参数”重跑,发现JobInstanceAlreadyCompleteException被抛出来——因为时间戳一变,它不认为是同一个Job了。
我的建议是:JobParameters里加业务批次号,例如statMonth=2025-08,同一批次失败了就沿用同一批次的参数重跑,天然支持失败重启;加一个时间戳是为了区分不同批次,但时间戳字段要单独管理。
7.5 skipLimit设置成多少才合理
skipLimit设成0等于关闭skip,设成极大值等于无限容忍脏数据。我见过有人写skipLimit(Integer.MAX_VALUE),跑完一批数据后光看skipCount就多了几万,数据等于丢了没人知道。这种情况下Job还显示成功,非常危险。我的经验值是按业务量的千分之一到万分之一设置,比如100万条数据,skipLimit设500到1000,同时配合监听器把跳过的数据记到错误表,人工核查后再重跑。
7.6 数据量增长后,别忘了评估Step之间的数据倾斜
当一张表的数据分布极不均匀时,简单按主键ID范围做分区会导致某个分区特别大、其他分区特别空,并行Step的时间取决于最慢的那个分区。后来我改成按某个业务均匀字段的值取模分区,或者先跑一个统计SQL拿到分布,再动态生成分区边界,并行就正常了。这个问题在千万级数据量以下不明显,数据量上来后非常影响整体耗时。
8. 写在之后:几个会直接影响线上体验的经验之谈
做了这么多跑批任务,最深的体会是:批处理代码本身的复杂度不高,难点全在“边界情况”的处理上。数据量一大,各种平时不会遇到的情况就都冒出来了——某个字段超长、某条记录时间异常、某台机器突然网络抖动、数据库连接池被打满。Spring Batch的价值在于,它把这些边界情况尽量框在了自己设计的“轨道”里,让我们不用每一步都靠人肉修补。
我强烈建议你在项目早期就启动监控:Job跑完自动发通知到群里,包含各Step耗时、成功条数、失败条数、跳过条数、异常摘要;跑批异常自动触发告警电话。不要等上线后跑批挂了才开始做,那时候数据恢复的代价往往比告警系统本身的成本高得多。
还有一个经验:如果条件允许,尽量保留最近N次Job的日志和执行记录。排查线上问题时,这些历史记录价值极大。Spring Batch元数据表里的数据会自动保留,但应用日志如果被日志清理策略清了,很多细节就找不回来了。
最后是心态层面的建议:跑批任务出问题是必然的,不要追求“永远稳定”,而是追求“出了问题能快速定位、快速恢复”。有了Spring Batch的JobRepository、skip策略、错误记录表和监听器这四板斧,大部分故障都能在半小时内理清脉络。少熬夜,多睡觉,比什么都强。