基于 Spring Batch 的海量数据迁移与批处理架构:分片、容错与断点续跑

做数据迁移和批处理的时候,踩过坑的团队基本都经历过从"Crontab + 脚本一把梭"到"引入专业框架"的阵痛。数据量小的时候怎么折腾都行,一旦订单量破千万、日志文件上 GB,内存溢出、中断后不知道跑哪儿了、一条脏数据卡死整个任务这些问题就会集中爆发。Spring Batch 在 Spring 生态里算是个"重武器",它的分层抽象、Chunk 事务边界和内置的状态持久化,确实能把这些脏活累活规范化。下面直接进正题,聊聊在生产环境里怎么用 Spring Batch 搭一套能扛事、好排查的批处理管线。


1. 为什么传统脚本扛不住海量数据

早年团队喜欢用 Linux Crontab 调个 Java 或 Shell 脚本,数据量少的时候跑得挺欢,但规模一上来,底层缺陷根本藏不住:

  • OOM 是常态 :很多人图省事,直接 SELECT * 或者 File.readAllBytes() 把数据全塞进内存。JVM 堆一打满,应用直接 Crash,连日志都来不及吐。
  • 状态黑盒:脚本中途挂了,根本不知道处理到第几条。哪些入库了?哪些还在半事务?只能靠人工翻日志或查业务表,恢复成本极高。
  • 容错全靠硬编码:遇到格式错误或唯一键冲突,整个任务直接中断。重试逻辑写死在循环里,没有退避策略,瞬间打满数据库连接池。
  • 扩容困难:单节点串行跑,遇到几十 GB 的文件或亿级表,耗时直接拉胯。想水平扩展得自己写分片逻辑,容易引入数据倾斜或重复消费。

批处理的核心诉求其实就四点:流式读写保内存、分片并行提吞吐、Chunk 切事务边界、元数据落库保断点续传。Spring Batch 的设计正好踩在这些点上。


2. 核心模型:Job / Step / Chunk 到底在干什么

Spring Batch 把批处理拆得很干净,Job → Step → Chunk 是它的骨架,Reader → Processor → Writer 是数据流转的血脉。

2.1 分层职责

  • Job:顶层容器,代表一次完整的业务流。里面可以串多个 Step,定义执行顺序和重启策略。
  • Step:实际干活的单元。事务控制、容错策略、监听器都是挂在 Step 上的。
  • Chunk :这才是 Spring Batch 的灵魂。它不是指数据块大小,而是事务提交边界 。比如配了 chunk(1000),框架会读 1000 条 → 走 Processor → 交给 Writer → 统一提交事务 → 记录进度。这种"微批提交"直接砍掉了长事务带来的锁竞争和 Undo 日志膨胀问题。

2.2 数据流转管道

  • ItemReader :只负责读。每次 read() 吐一条记录,底层必须是无状态或游标驱动的。千万别自己搞全量查询塞 List,框架自带的 Reader 都是按需拉取的,内存占用恒定。
  • ItemProcessor :做转换、过滤、校验。返回 null 就自动丢弃该条,不往下走。这里尽量保持轻量,别塞重型计算或阻塞 I/O,否则会把整个 Step 拖慢。
  • ItemWriter:负责写。一次接收一个 List(长度就是 Chunk Size),批量刷到目标端。Writer 自己不碰事务,全由 Step 代理提交。

这套模型之所以稳,是因为内存里永远只扛着固定数量的对象。处理完一批就清掉,O 级别的内存开销,彻底跟 OOM 绝缘。


3. 生产级配置实战

现在基本都用 Spring Boot Starter 集成。下面直接上分片、大文件流读和 JDBC 批量写入的配置,附带线上调过参数。

3.1 分片并行(Partitioner)

单表破亿或者文件超 50GB,单线程肯定不够看。Spring Batch 的 PartitionStep 可以把数据切块,分给多个 Worker 跑。

java 复制代码
@Bean
public Step partitionedStep(PartitionHandler partitionHandler) {
    return stepBuilderFactory.get("partitionedStep")
        .partitioner("workerStep", rangePartitioner())
        .partitionHandler(partitionHandler)
        .build();
}

@Bean
public Partitioner rangePartitioner() {
    return gridSize -> {
        // 实际项目里建议从 DB 查 min/max ID,这里简化示意
        long minId = 1L;
        long maxId = 10000000L;
        long range = (maxId - minId + 1) / gridSize;
        
        Map<String, ExecutionContext> map = new HashMap<>();
        for (int i = 0; i < gridSize; i++) {
            ExecutionContext ctx = new ExecutionContext();
            ctx.putLong("startId", minId + i * range);
            ctx.putLong("endId", (i == gridSize - 1) ? maxId : minId + (i + 1) * range - 1);
            map.put("partition_" + i, ctx);
        }
        return map;
    };
}

配合 TaskExecutorPartitionHandler 和线程池,就能在单机内多线程跑分片。如果节点多,结合 Spring Cloud Data Flow 或 K8s Job,把 ExecutionContext 序列化下发,就能实现跨节点并行。

3.2 大文件流式读取

CSV 或文本文件解析,必须走流。FlatFileItemReader 底层就是 BufferedReader,每次只读一行,内存几乎不涨。

java 复制代码
@Bean
public FlatFileItemReader<UserRecord> flatFileItemReader() {
    FlatFileItemReader<UserRecord> reader = new FlatFileItemReader<>();
    reader.setResource(new FileSystemResource("/data/export_2023.csv"));
    reader.setLinesToSkip(1); // 跳过表头
    
    DefaultLineMapper<UserRecord> lineMapper = new DefaultLineMapper<>();
    lineMapper.setLineTokenizer(new DelimitedLineTokenizer(","));
    
    BeanWrapperFieldSetMapper<UserRecord> fieldMapper = new BeanWrapperFieldSetMapper<>();
    fieldMapper.setTargetType(UserRecord.class);
    lineMapper.setFieldSetMapper(fieldMapper);
    
    reader.setLineMapper(lineMapper);
    reader.setStrict(false); // 文件不存在时不直接抛异常,方便幂等重试
    return reader;
}

遇到编码乱码或者自定义分隔符,自己实现个 LineTokenizer 就行。注意大文件务必保证 Reader 和 Writer 的线程安全,Spring Batch 的 Reader 默认是单线程安全的,分片模式下每个分片会拿到独立的 Reader 实例。

3.3 JDBC 批量写入调优

JdbcBatchItemWriter 的性能卡点通常在网络往返和驱动层。配置本身不复杂,但参数不对吞吐差好几倍。

java 复制代码
@Bean
public JdbcBatchItemWriter<UserRecord> jdbcBatchWriter(DataSource dataSource) {
    JdbcBatchItemWriter<UserRecord> writer = new JdbcBatchItemWriter<>();
    writer.setDataSource(dataSource);
    writer.setSql("INSERT INTO users (id, name, age, created_at) VALUES (:id, :name, :age, :created_at)");
    writer.setItemSqlParameterSourceProvider(new BeanPropertySqlParameterSourceProvider());
    writer.setAssertUpdates(true); // 校验影响行数,防止静默失败
    return writer;
}

线上实测有效的几个点:

  1. Batch Size 对齐 :Writer 内部调用 addBatch() 的次数默认跟 Chunk Size 一致。建议 Chunk 设在 500~2000 之间。设太大 JDBC 驱动层的内部缓冲区会撑爆,GC 频繁。
  2. 关闭主键回填 :除非业务强依赖自增 ID 回写,否则别用 GeneratedKeyHolder。关掉 RETURN_GENERATED_KEYS,MySQL 场景下能稳定提升 30% 以上的吞吐。
  3. 连接池与 URL 参数 :HikariCP 的 maximumPoolSize 得跟分片线程数对齐,别省连接。MySQL JDBC URL 必须带 rewriteBatchedStatements=true,不然驱动会把批量 SQL 拆成单条发,白白浪费带宽。

4. 容错与断点续传

生产环境的数据从来都不干净,框架得能优雅兜底。

4.1 Skip 与 Retry 的配置逻辑

  • Retry:对付瞬时故障,比如网络抖动、DB 锁等待、下游服务偶尔超时。配指数退避,别死循环重试。
  • Skip:对付脏数据,比如格式解析失败、违反唯一约束。记个日志或扔死信队列,任务继续往前跑。
java 复制代码
@Bean
public Step faultTolerantStep(ItemReader<User> reader, ItemWriter<User> writer, PlatformTransactionManager tm) {
    return stepBuilderFactory.get("faultTolerantStep")
        .<User, User>chunk(1000, tm)
        .reader(reader)
        .writer(writer)
        .faultTolerant()
        .retryLimit(3)
        .retry(DeadlockLoserDataAccessException.class, ResourceAccessException.class)
        .skipLimit(50)
        .skip(DataIntegrityViolationException.class, IllegalArgumentException.class)
        .listener(new CustomSkipListener()) // 把跳过的记录落地到死信表,事后人工核对
        .build();
}

注意:skipLimitretryLimit 是并存的。框架会先重试,达到上限后再走 Skip。别把 Skip 策略写得太宽泛,否则脏数据全漏过去,下游对账能哭死。

4.2 事务边界与避坑

Spring Batch 的事务由 Step 全权代理。Chunk 提交前,Reader 和 Writer 的操作在同一个事务里。中途报错,整个 Chunk 回滚,元数据表不会更新,完美保证原子性。

血泪经验 :绝对不要在 Processor 里手动 @Transactional 或者调 DataSource 开新事务。这会打断框架的代理链,导致 JobRepository 里的进度更新失败,断点续传直接失效。Processor 只负责纯计算或轻量校验。

4.3 断点续传的正确姿势

Spring Batch 把执行状态存在 6 张 BATCH_* 表里。重启时,框架去表里捞上次成功的 Chunk 偏移量,接着读。

这里有个很多人搞错的细节:断点续传依赖相同的 JobParametersJobInstance 的标识是 JobName + JobParameters。如果你每次启动都加个时间戳:

java 复制代码
JobParameters params = new JobParametersBuilder()
    .addLong("triggerTime", System.currentTimeMillis()) // ❌ 每次参数不同,框架会当成新任务,无法续跑
    .addString("source", "order_center")
    .toJobParameters();

这样永远跑的是新实例。想续传,要么传一模一样的参数,要么通过 JobExplorer 查出上次挂掉的 JobExecution,调用 jobLauncher.restart(jobExecution)。如果确实需要每次生成新实例但又想支持手动回滚,建议在业务表层面做幂等(INSERT IGNOREON DUPLICATE KEY UPDATE),别死磕框架状态。


5. 线上调优与可观测性

架构搭起来只是第一步,线上稳不稳,全看调优和监控。

5.1 内存与游标选择

分页读取(JpaPagingItemReader)在深分页时 LIMIT offset, size 会越拖越慢,而且如果排序键不唯一,容易漏数据或重复。很多人想换游标(JdbcCursorItemReader),但游标会一直占用数据库连接,Step 跑半小时,连接就占半小时,连接池很容易被打满。

我的建议是:数据量在千万级以内,用分页但务必加唯一排序键(比如主键 ID);超大规模迁移优先用游标,但配合 FetchSize 控制(MySQL JDBC 默认是 Integer.MIN_VALUE 流式拉取,别改),并且单独给 Reader 配个连接池,别跟 Writer 抢连接。

5.2 长事务与批大小动态化

别把 chunk 大小写死。业务高峰期数据库压力大,Chunk 设 500 能减少锁持有时间;夜间低峰期拉到 2000,减少事务提交开销。可以通过 StepListenerSupport 结合 Prometheus 指标动态调整,或者干脆用两个不同参数的 Step 错峰执行。

写入前如果表里有一堆非唯一索引,归档场景下可以临时 ALTER TABLE ... DISABLE KEYS,写完再 ENABLE KEYS,重建索引比逐行维护快得多。当然,线上核心交易表别这么玩,只适用于日志/流水类归档表。

5.3 监控大盘怎么接

Spring Boot Actuator 原生暴露了 Batch 的 Micrometer 指标,直接接 Grafana 就行。

yaml 复制代码
management:
  endpoints:
    web:
      exposure:
        include: health,metrics
  metrics:
    tags:
      application: data-sync-service

重点盯这几个指标:

  • spring.batch.job.execution.count:任务触发频次
  • spring.batch.step.read.count / write.count:读写量对齐,排查数据丢失
  • spring.batch.step.skip.count:跳过率突增通常是上游数据源出了问题
  • 耗时分布:通过自定义 Timer 记录 Processor 阶段耗时,80% 的瓶颈都在数据转换逻辑太重。

配个告警规则,Skip 率超过 5% 或者 Job 状态变 FAILED,直接推钉钉/企微。别等对账发现少了数据才去查日志。


6. 选型建议与调度协同

Spring Batch 不是万能药,得看场景用。

如果是夜间 ETL、跨库同步、大文件清洗这类离线任务,Spring Batch 的事务控制和状态机机制非常顺手。但如果是秒级高频微批,或者需要滑动窗口聚合的实时流,硬塞 Spring Batch 只会适得其反。它的元数据落库和 Chunk 状态机本身就有开销,延迟下不去。这种场景直接上 Kafka Streams 或 Flink 更合适,轻量级任务用定时任务+消息队列就能搞定。

生产环境的标准打法是"调度层 + 执行层"解耦

调度层交给 XXL-JOB、Airflow 或 DolphinScheduler。它们负责任务依赖编排、分片参数下发、全局失败重试和告警。执行层用 Spring Batch 接收调度器传来的 shardIndexshardTotal,通过 Partitioner 切分数据范围,独立跑流式处理,自己管 Chunk 事务和 JobRepository 状态。调度器看宏观进度,Batch 管微观数据流转,两边通过 Webhook 或 MQ 对齐状态。这套组合拳打下来,数据管道基本能做到"调度可管、执行可控、断点可续、异常可溯"。

框架只是工具,真正决定稳定性的是对事务边界、数据流向和失败场景的预判。把 Chunk 粒度调对,把 Skip 策略写准,把调度与执行拆干净,批处理就不再是定时炸弹。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
Javatutouhouduan1 小时前
Java初学者如何高效学习JVM?
java·jvm·java虚拟机·java面试·后端开发·java程序员·java八股文
ZJU_统一阿萨姆2 小时前
【算子开发】全局内存访问与合并访存
java·服务器·网络·人工智能·语言模型
heimeiyingwang2 小时前
【架构实战】Kubernetes网络模型深度解析:从Pod通信到Ingress网关实战
开发语言·架构·php
7177772 小时前
不止工具集成:基于 Gitee 软件工厂构建 DevSecOps 研发治理底座
java·服务器·gitee
gis开发之家2 小时前
Spring Boot 4 深度解析——参数接收大全:@RequestParam、@PathVariable、@RequestBody
java·spring boot·后端·spring
可乐ea2 小时前
Anthropic 的 CI/CD 值班智能体:Claude Tag 当一线响应者的架构拆解与踩坑复盘
ci/cd·架构·claude·devops·ai智能体·mcp
(轻舟已过万重山)3 小时前
D1 · 融合蓝图:Spring AI + 虚拟线程 + 服务网格——现代后端统一底座
java·人工智能·spring
老郑聊AI业财智造3 小时前
DeepSeek技术架构与源码分析
人工智能·语言模型·架构·系统架构·软件工程
过江龙8473 小时前
Java架构师的AI转型之路(下):模型层与平台化架构
架构