
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 StepJob里的最小执行单元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提供的一种扩展机制让你能在特定时机插入自定义代码。常用的几个接口JobExecutionListenerJob启动前和结束后可以执行逻辑比如Job开始时发个通知Job结束后写个统计表。StepExecutionListenerStep开始前和结束后执行逻辑可以在这里记录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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-batch/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependencySpring 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_EXITFALSE driver-class-name: org.h2.Driver username: sa password: batch: job: enabled: true initialize-schema: ALWAYS注意spring.batch.job.enabledtrue这个配置。它表示应用启动时就自动执行定义的Job。开发调试时挺方便但生产环境一般不用这个而是通过JobLauncher手动触发后面会讲因为生产环境经常需要控制Job的执行时机有时是定时任务调用有时是手动触发。4.2 定义实体类和表结构订单表t_orderCREATE 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_summaryCREATE 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三个核心接口ReaderSpring Batch提供的JdbcPagingItemReader是个很强大的工具它通过SQL分页查询每次只从数据库取一页数据不会一次性把全表加载进内存。Bean public JdbcPagingItemReaderOrderEntity orderReader(DataSource dataSource) { JdbcPagingItemReaderOrderEntity 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。注意必须要有排序 MapString, 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 ItemProcessorOrderEntity, 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 JdbcBatchItemWriterUserMonthlySummaryEntity summaryWriter(DataSource dataSource) { JdbcBatchItemWriterUserMonthlySummaryEntity 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和StepConfiguration 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(ItemReaderOrderEntity reader, ItemProcessorOrderEntity, UserMonthlySummaryEntity processor, ItemWriterUserMonthlySummaryEntity writer) { return stepBuilderFactory.get(summaryStep) .OrderEntity, UserMonthlySummaryEntitychunk(1000) .reader(reader) .processor(processor) .writer(writer) .build(); } }chunk(1000)前面提过表示每攒1000条数据处理一次也是每个事务的边界。这个参数后续调优就是改这里。4.5 手动触发Job的两种常用姿势生产环境中我推荐不要用spring.batch.job.enabledtrue自动启动而是通过代码控制。最简单的方式是用CommandLineRunnerComponent 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失败回滚代价20017.8240低100013.2290中500011.9450中高1000011.5620高从这组数据能得出两个结论commit-interval在一个范围内1000到5000对耗时影响不算特别大但内存和回滚代价的增长是线性的。所以我的经验值是内容简单的数据用5000字段多或者写库逻辑复杂用1000~2000。不需要追求极限稳定才是跑批第一原则。5.2 Reader的fetchSize和游标读取关键I/O优化JdbcPagingItemReader内部靠分页查询实现流式读取每次先执行一个带LIMIT的SQL查出一页然后通过RowMapper转成对象。这里两个参数值得细说fetchSize这个是JDBC驱动层面的。MySQL里设一个合理值比如1000驱动会一次从数据库拉取1000行到客户端减少网络往返次数。默认值是0意思是驱动自己决定在MySQL里结果集小无所谓数据量大时差距还是很明显的。pageSizeSpring 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()一次性提交整个chunkComponent public class SummaryBatchWriter implements ItemWriterUserMonthlySummaryEntity { 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, UserMonthlySummaryEntitychunk(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 ItemProcessListenerOrderEntity, 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里加业务批次号例如statMonth2025-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策略、错误记录表和监听器这四板斧大部分故障都能在半小时内理清脉络。少熬夜多睡觉比什么都强。