Spring Batch企业级数据迁移实战:从Job设计到千万级性能调优
电商库存同步跑批超时、财务对账脚本半夜跑挂、几百张表的数据迁移没人敢接手——这些场景在Java后端团队里太常见了。最近我们刚好做了一个物料主数据的全量迁移项目数据库涉及六个业务系统单表数据量破千万关联关系复杂到光看ER图就头疼。最后落地选型用的是Java原生搭建Spring Batch没用可视化ETL工具也没上分布式调度平台。这篇文章我把整个项目的技术决策和落地过程完整写出来从Job与Step的结构设计、读写组件的选型依据、字段映射的转换方案到大批量迁移时的内存瓶颈、脏数据的跳过与恢复机制以及生产环境里遇到的几个棘手的坑。如果你是做Java后端、正打算用Spring Batch做数据迁移或者已经在项目里用到批处理但总是踩坑这篇应该能帮你省下不少弯路。1. 数据迁移项目的技术选型为什么锁定Spring Batch1.1 迁移场景的痛点与需求拆解先说我们当时面临的实际情况物料主数据分布在六个异构系统里有的存在MySQL有的在Oracle还有一张历史表在另一个服务里只有CSV快照。迁移的目标是把所有数据汇聚到一套新的主数据管理平台字段要做统一映射脏数据要清洗业务主键要重新分配同时还要保证目标库在迁移过程中持续对业务可用。这件事如果用纯手工脚本做等于把一整座桥的承重压在几根竹竿上。我们在方案评审阶段讨论过三条路第一条是买商业ETL工具功能确实全面但许可证成本按节点数算直接超预算第二条是自研数据同步脚本用JDBC逐表读取再逐表写入开发快但每个表都要写一套逻辑且没有事务边界、没有断点恢复一旦跑挂只能从头再来第三条就是Spring Batch一套成熟的批处理框架分片、事务、重试、跳过、监听器全部内置我们要做的只是实现读、处理、写三个核心接口然后把Step和Job配置文件写对。最终选第三条从结果复盘来看是正确决策。1.2 Spring Batch在数据迁移中的天然优势Spring Batch被设计出来就是为了解决批处理问题它的核心思想是Chunk-oriented processing——按块读取、按块处理、按块写入而不是一行一行地处理。这意味着它天然具备以下能力事务边界清晰每个Chunk是一个事务提交频率可以配置崩溃时最多丢失一个Chunk的数据配合重启机制可以从上次断点继续。读-处理-写解耦ItemReader负责读ItemProcessor负责清洗和转换ItemWriter负责写每个环节可以独立测试。内置跳过与重试可以精确到遇到某类异常跳过该条记录其他异常重试三次不需要自己维护临时状态。状态持久化Spring Batch把Job实例、Job执行状态、Step执行状态、当前处理位置全部存在元数据表里重启时能精确恢复。这对数据迁移场景来说非常关键。说白了迁移任务不是一个能不能跑通的问题而是跑到一半挂了怎么办的问题。Spring Batch给了一个标准答案框架管状态业务管逻辑。补充一下我们不打算用Spring Boot的自动配置而是全部用Java Config显式声明Bean。原因是项目本来就是老Spring工程引Boot改造代价大而且纯Java配置能更直观地看到每一步装配逻辑。下面代码里也统一用原生Java配置写。2. Job、Step、Chunk用原生Spring Batch组件搭起迁移骨架2.1 整体Job结构设计Spring Batch里一个完整的数据迁移任务最外层叫Job一个Job包含若干Step每个Step做一件具体的事。我们迁移物料主数据拆分成这样的StepStep1从六个源系统抽取物料基础信息清洗后写入临时表的中间结果集。Step2从中间结果集读取物料数据关联维度信息转换字段映射后写入目标表主表。Step3处理层级关系数据物料BOM先读主表确认外键存在再写入关联表。Step4全量迁移完成后的校验Step比对源库和目标库的记录数、关键字段的哈希值。每个Step之间用FlowBuilder编排可以配置条件跳转、失败停止或者失败继续执行下一段。我们这套设计用Java Config实现的核心骨架如下Configuration public class MaterialMigrationJobConfig { Bean public Job materialMigrationJob( JobRepository jobRepository, Step materialExtractStep, Step materialTransformStep, Step bomRelationStep, Step verifyStep) { return new JobBuilder(materialMigrationJob, jobRepository) .start(materialExtractStep) .next(materialTransformStep) .next(bomRelationStep) .next(verifyStep) .build(); } }这个结构看起来简单但背后有几个考量点每个Step使用独立的事务传播策略不同表的数据写入互不影响如果某个Step中途失败前面的Step结果还在不需要全量重跑。所以Job的设计原则是——Step的粒度要根据是否可以单独重跑来划分而不是按业务模块机械拆分。2.2 Chunk模型与事务边界接着讲Chunk模型。我们用JdbcPagingItemReader从源库分页读取数据每页默认500条每积累到一个Chunk1000条就执行一次ItemWriter批量写入目标库并提交事务。Bean public Step materialTransformStep( JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReaderMaterialRaw materialReader, ItemProcessorMaterialRaw, MaterialTarget materialProcessor, ItemWriterMaterialTarget materialWriter) { return new StepBuilder(materialTransformStep, jobRepository) .MaterialRaw, MaterialTargetchunk(1000, transactionManager) .reader(materialReader) .processor(materialProcessor) .writer(materialWriter) .listener(materialStepListener()) .build(); }这里需要注意chunk(1000)这个值的选择。我们的源库表没有大字段单条物料记录序列化后大概2KB1000条就是2MB事务提交时内存压力可控。如果迁移的表包含CLOB/BLOB、或者单条记录特别大建议把Chunk值调小到200~500否则一个事务内的数据量太大目标库的redo/undo日志会非常夸张而且一旦失败回滚的成本也高。反过来如果目标库是大批量写入性能很差的普通配置Chunk值太大会导致锁竞争明显这个后面调优章节会详细说。一个小提醒这里的transactionManager必须能与目标数据源对应。迁移涉及源库和目标的跨库事务无法做到全局事务所以Spring Batch的数据库事务只覆盖目标库的写入环节。框架不做跨库分布式事务这点业务上要先接受——迁移数据的不一致通过后面的校验Step来兜底而不是靠事务硬撑。3. 读取与写入的工程实现从API选型到字段映射3.1 为什么选择JdbcPagingItemReader而不是CursorSpring Batch自带的两种JDBC读取方式我们对比过JdbcCursorItemReader和JdbcPagingItemReader。Cursor方式维护一个数据库游标按行不断拉取数据。它的问题在于——驱动需要和数据库维持一个长连接会话源库如果在迁移过程中发生数据库迁移、主从切换游标会直接失效。而且它对内存不友好连接保持时间也长。Paging方式每次只查一页数据拿到后直接交给Processor处理处理完再取下一页事务和连接的生命周期更短。虽然Paging不能基于游标保持一致性能但对于千万级数据迁移来说稳定性优先于性能。我们的物料抽取读写器代码如下Bean public JdbcPagingItemReaderMaterialRaw materialReader( DataSource sourceDataSource, PagingQueryProvider queryProvider) { return new JdbcPagingItemReaderBuilderMaterialRaw() .name(materialReader) .dataSource(sourceDataSource) .queryProvider(queryProvider) .pageSize(500) .rowMapper((rs, rowNum) - { MaterialRaw raw new MaterialRaw(); raw.setId(rs.getLong(ID)); raw.setMaterialCode(rs.getString(MATERIAL_CODE)); raw.setMaterialName(rs.getString(MATERIAL_NAME)); raw.setCategoryId(rs.getLong(CATEGORY_ID)); raw.setSpecification(rs.getString(SPEC)); raw.setStatus(rs.getString(STATUS)); return raw; }) .build(); } Bean public PagingQueryProvider materialQueryProvider(DataSource sourceDataSource) { SqlPagingQueryProviderFactoryBean provider new SqlPagingQueryProviderFactoryBean(); provider.setDataSource(sourceDataSource); provider.setSelectClause(SELECT ID, MATERIAL_CODE, MATERIAL_NAME, CATEGORY_ID, SPEC, STATUS); provider.setFromClause(FROM MATERIAL_MASTER_SOURCE); provider.setSortKey(ID); return provider.getObject(); }一个很关键的设置是setSortKey(ID)。分页查询必须有排序字段才能保证翻页稳定。如果没有排序MySQL的LIMIT分页在数据发生变化时会出现重复或跳跃记录。注意这里排序字段最好唯一或接近唯一比如联合主键或自增主键。我们一开始用CREATE_TIME排序结果源库里同时段插入的数据好几万条时间戳相同导致排序列不稳定翻页数据出现串行。3.2 字段映射与清洗逻辑的落点字段映射的逻辑我放在ItemProcessor里没有放在SQL里。有两个原因一是不同源库的字段值格式不同比如状态字段A系统存01表示启用B系统存ACTIVE表示启用这需要代码做规则匹配在SQL里写会非常丑陋且难维护二是Processor里可以直接抛出跳过异常或返回null跳过记录细粒度的数据过滤用代码控制比SQL方便得多。Component public class MaterialTransformProcessor implements ItemProcessorMaterialRaw, MaterialTarget { private static final MapString, String STATUS_MAPPING Map.of( 01, ACTIVE, 02, INACTIVE, ACTIVE, ACTIVE, INACTIVE, INACTIVE ); Override public MaterialTarget process(MaterialRaw item) { if (item.getMaterialCode() null || item.getMaterialCode().isBlank()) { throw new MissingMaterialCodeException(物料编码为空); } MaterialTarget target new MaterialTarget(); target.setTargetCode(item.getMaterialCode().trim()); target.setTargetName(item.getMaterialName() null ? : item.getMaterialName().trim()); target.setCategoryId(item.getCategoryId()); target.setSpec(item.getSpecification()); target.setStatus(STATUS_MAPPING.getOrDefault(item.getStatus(), UNKNOWN)); return target; } }注意到如果物料编码为空直接抛MissingMaterialCodeException。这个异常是自定义的配置里让框架遇到这个异常时跳过该条记录不重试也不影响整个Job运行。为什么跳过而不直接失败因为这些脏数据是历史问题数量占比很小如果一条脏数据让上千万条的迁移任务整体失败那是拿高成本为低质量历史数据买单。脏数据要记录但不应该阻塞主流程。3.3 批量写入的两种选择Writer我们一开始用的JdbcBatchItemWriter把所有数据攒成一个Chunk后用PreparedStatement的addBatch()批量执行Bean public JdbcBatchItemWriterMaterialTarget materialWriter(DataSource targetDataSource) { return new JdbcBatchItemWriterBuilderMaterialTarget() .dataSource(targetDataSource) .sql(INSERT INTO MATERIAL_TARGET (TARGET_CODE, TARGET_NAME, CATEGORY_ID, SPEC, STATUS) VALUES (:targetCode, :targetName, :categoryId, :spec, :status)) .beanMapped() .build(); }beanMapped()会把实体字段名和SQL参数名自动映射省去手动设置ItemSqlParameterSourceProvider。但后来数据量大了之后我发现JdbcBatchItemWriter有个明显的调优点它的批量提交默认是逐条addBatch攒够了再执行如果目标库的批处理配置有问题比如MySQL的rewriteBatchedStatementstrue没开性能会差到让人怀疑人生。所以我们的下一步优化是自定义了一个JdbcBatchItemWriter直接内嵌JdbcTemplate.batchUpdate()方法手动拼SQL VALUES塞参数数组。这么做的收益是能精确控制batchSize比如5000条一次submit还能在写入前对数据做进一步的内存预编译。这个后面性能篇会展开讲。4. 大数据量下的性能调优与内存控制4.1 分页大小、Chunk大小与提交频率的取舍这是我们在压测阶段花时间最多的地方。分页大小pageSize和Chunk大小chunk两者很容易混淆pageSizeItemReader每次从数据库查询的行数。影响的是查询次数和单次查询的返回大小。chunkSpring Batch每次事务提交处理的条目数。影响的是事务的频率和Writer单次处理的条数。我们最开始设置pageSize500、chunk1000结果发现一个问题JdbcPagingItemReader每次查询500条但这500条会缓存在reader内部Chunk处理器每满1000条才提交一次事务。也就是说不一定在一个page里刚好凑齐1000条有时候要跨页取数这时候内存里会同时存在两个分页的数据内存占用会偏大。后来我们把pageSize和chunk保持一致都设为1000事务边界和读取边界对齐后逻辑链路更清晰GC压力也小了一些。这里给一个参考策略表数据特征推荐pageSize推荐chunk备注窄表、单条1KB500~10001000~2000内存占用低事务次数少宽表、单条1KB~5KB300~500500~1000避免大事务回滚成本高含CLOB/BLOB50~20050~100避免大对象长期驻留内存目标库写入慢300~500200~500减少单次事务的锁持有时间4.2 避免迁移过程中OOM的实战经验千万级数据迁移最怕跑着跑着堆内存爆掉。Spring Batch本身是流式处理模型正常情况下内存不会持续增长但你如果在哪里不经意的collect了一个List就可能翻车。我们遇到过两个具体的OOM触发点第一个是Processor里缓存数据。当时为了做字段映射我在Processor里放了一个全局的Map做维度缓存启动时把几百个维度都加载进去。听上去几百条不多但每个维度对象因为级联加载还携带了一大堆子对象GC回收不掉跑了两个小时直接Full GC频繁。解决方法是瘦身缓存对象只保留必要的字段并把缓存方式改成按需加载用到哪条查哪条避免启动时一次性装载全部维度。第二个是Writer批量提交时直接List.addAll。自定义Writer里为了凑批量每次把数据先暂存等到5000条再批量写。结果源库高峰期读得快Writer积压了一大堆没提交的数据在内存里间接造成堆上涨。后来对Writer里的暂存List做了上限控制超过上限就触发一次提交宁可多提交几次也不要无限积压。举一个我们写过的简化版批量Writer关键就是控制每次提交的上限public class LimitedBatchWriter implements ItemWriterMaterialTarget { private static final int MAX_BUFFER_SIZE 5000; private final ListMaterialTarget buffer new ArrayList(); private final NamedParameterJdbcTemplate jdbcTemplate; Override public void write(List? extends MaterialTarget items) { buffer.addAll(items); if (buffer.size() MAX_BUFFER_SIZE) { flush(); } } private void flush() { String sql INSERT INTO MATERIAL_TARGET (...) VALUES (:targetCode, :targetName, ...); SqlParameterSource[] batch new SqlParameterSource[buffer.size()]; for (int i 0; i buffer.size(); i) { batch[i] new BeanPropertySqlParameterSource(buffer.get(i)); } jdbcTemplate.batchUpdate(sql, batch); buffer.clear(); } }这里要注意write方法在Spring Batch里每个Chunk结束调用传入的List就是这一个Chunk的数据不是全量数据。所以上面自定义buffer其实只在Chunk之间做粘合我当时是为了解决目标库批量写入太慢的情况——攒够5000再提交减少事务次数。但如果你只需要单Chunk提交完全不需要buffer。4.3 连接池和批处理参数的调优迁移任务对连接池的要求和其他在线业务不同它短时间内的并发不高但单连接的吞吐量要大。我们给源库和目标库分别建了两个独立的DataSource防止连接池互相争抢。连接池参数参考maximum-pool-size设置在10~20即可避免把源库连接打满影响在线业务。目标库写入侧可以稍微调高到20~30因为批量提交比较占用连接。但实际操作中要注意目标库的max_allowed_packet批量插入时如果单次提交的数据量超过这个上限会被数据库直接断开连接。另一个非常关键但经常被忽略的参数是MySQL连接串里的rewriteBatchedStatementstrue。不开启这个参数时JDBC的addBatch()会被MySQL驱动拆成单条SQL顺序执行批量性能直接打五折以上。开启后驱动会把多条INSERT合并成一条多VALUES语句提交我们实测在相同数据量下写入耗时下降了接近60%。5. 重试、跳过与恢复企业级迁移的可靠性设计5.1 脏数据跳过机制的正确姿势前面提到在Processor里抛异常来跳过脏数据但是Spring Batch怎么知道这个异常是跳过而不是失败关键在Step配置里用faultTolerant()开启容错并指定可跳过的异常列表Bean public Step materialTransformStep( JobRepository jobRepository, PlatformTransactionManager transactionManager, ItemReaderMaterialRaw materialReader, ItemProcessorMaterialRaw, MaterialTarget materialProcessor, ItemWriterMaterialTarget materialWriter) { return new StepBuilder(materialTransformStep, jobRepository) .MaterialRaw, MaterialTargetchunk(1000, transactionManager) .reader(materialReader) .processor(materialProcessor) .writer(materialWriter) .faultTolerant() .skip(MissingMaterialCodeException.class) .skipLimit(500) .listener(materialStepListener()) .build(); }skipLimit(500)意思是这个Step最多允许跳过500条记录如果跳过总数超过500Step直接失败。这个限制很重要——脏数据如果多到一定程度说明上游数据质量过差多半是源库抽取逻辑出了问题这时候继续跑只会产生更多看起来成功但实际不完整的数据。限量失败机制等于给数据质量设了一个底线。但注意不要盲目扩大skip范围。我们只允许跳过业务级的数据异常不允许跳过数据库连接异常、主键冲突异常。数据库层面的问题一旦发生重试和跳过没有任何意义必须让Job失败并走恢复流程。5.2 断点续跑与JobRepository元数据Spring Batch能够断点续跑依赖的是JobRepository它在数据库里维护BATCH_JOB_INSTANCE、BATCH_JOB_EXECUTION、BATCH_STEP_EXECUTION、BATCH_JOB_EXECUTION_CONTEXT等表。配置时只要指定一个数据源Bean public JobRepository jobRepository(DataSource batchDataSource, PlatformTransactionManager batchTransactionManager) throws Exception { JobRepositoryFactoryBean factory new JobRepositoryFactoryBean(); factory.setDataSource(batchDataSource); factory.setTransactionManager(batchTransactionManager); factory.setDatabaseType(DatabaseType.MYSQL); return factory.getObject(); }这里要注意batchDataSource和业务数据源分开。我当时图省事把JobRepository和业务写库共享了一个数据源在线上跑批量任务时JobRepository每次提交状态的SQL和业务写入SQL混在同一个连接池结果状态表和业务表在同一事务里一旦业务写库回滚Job状态也会跟着回滚搞得状态记录一直不更新。后来拆了独立数据源问题立刻消失。断点续跑的本质是Spring Batch在执行每个Chunk之前把读取位置记录在JobExecutionContext里。重启Job时如果是同一个JobInstance框架会尝试从上次失败位置继续。但前提是重启时用的任务参数要和第一次一致。如果参数变了框架会视为一个新的JobInstance从零开始。这个坑非常经典我曾因为往启动命令里加了一个时间戳参数导致重跑时JobInstance永远不一样旧任务状态全部丢失。5.3 重试机制的实际收益与局限重试针对的是临时性故障。比如目标库写入时死锁、网络抖动重试几次可能就成功了。配置重试很简单.retry(DeadlockLoserDataAccessException.class) .retryLimit(3)外加RetryTemplate可以设置退避策略Bean public RetryTemplate retryTemplate() { RetryTemplate template new RetryTemplate(); ExponentialBackOffPolicy backOffPolicy new ExponentialBackOffPolicy(); backOffPolicy.setInitialInterval(1000); backOffPolicy.setMultiplier(2.0); backOffPolicy.setMaxInterval(30000); template.setBackOffPolicy(backOffPolicy); return template; }但重试不是万能的。它解决不了数据本身的问题也解决不了目标库表结构约束不匹配这类稳定错误。我们在这上面吃过亏——当时把源库的两列拼成一列的转换逻辑写错了部分数据结果超过目标字段长度每次写入都报DataTruncation。由于设了重试3次结果一条脏数据害得整个Chunk被反复占用拖慢整体进度。后来我们把数据转换错误类全部排除在重试列表之外只对连接类和锁类异常重试。5.4 失败后的数据补偿策略即使在迁移Step结束后做了校验仍然可能出现源库写入了一部分、目标库没有同步的情况。我们设计了一套基于批次号的补偿流程迁移启动时生成一个批次号源表和目标表都有BATCH_ID字段。校验Step生成差异报告补偿Job读取差异报告重新抽取对应记录。这个设计的数据回溯性特别好。运维如果发现某条数据在目标库查询异常只要通过BATCH_ID就能定位到它来自哪一批、对应的是源库哪一行。不要觉得校验是多余的Step它对数据迁移的可信度至关重要尤其是涉及多个系统的主数据迁移讲不清楚数据从哪来业务部门就不敢切流量。6. 生产环境踩坑实录从开发到上线的完整排查链路6.1 坑一分页查询排序字段不唯一导致数据重复当时在Step1抽取物料基础信息用CREATE_TIME作为排序键结果源库某些时段在同一秒插入了上百条物料记录排序不稳定翻页时出现同一行数据被多次读取的情况。表现出来最直观的现象是——迁移完成后源库表记录总数是1000万目标库里出现了1005万。排查过程是这样先看Job成功状态Spring Batch显示Step执行成功没有跳过记录。定位到是数据重复后直接在源库执行分页翻页SQL手动翻了几十页发现第2页和第3页有重复记录。对照SQL确认ORDER BY CREATE_TIME存在大量相同值而且没有唯一性作为第二排序条件。修复方案把分页排序键改成ID并把pageSize适当调小减少单次翻页跨度。这类问题在测试环境很难发现必须通过全量数据比对才能暴露。所以我们的经验是任何分页读取任务的排序字段必须有唯一性兜底没有唯一键的表要选择联合排序字段比如(CREATE_TIME, ID)。6.2 坑二JobRepository与业务数据源共用导致状态回滚前面提到过这个问题再展开说说现象。当时线上跑在线交易表的批量归档用的是只读从库作为JobRepository的数据源而业务写库是另一个MySQL实例。配置的时候只配了一个DataSourceJobRepository和Writer都复用了它。结果Job执行到第500个Chunk时Writer出现一次死锁异常。重试后成功了但JobRepository的状态表却显示这个Chunk从未提交。原因是在同一个事务里写入和状态更新同时发生业务回滚导致状态也回滚了但Spring Batch内部已经把这个Chunk标记为处理完成。下一次重启时会认为这个Chunk还没提交于是又重复处理了一次目标库就有了重复数据。教训就是JobRepository的状态数据源必须和业务数据源物理隔离这是企业级批处理的底线不要省这一张表。6.3 坑三JVM参数没调千万级任务跑不满迁移任务最开始的JVM参数用的是默认值堆内存只有1GB。跑起来之后频繁进行Full GC任务进度极慢。后来我们在启动命令里做了针对性设置java -Xms4g -Xmx4g \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -jar material-migration.jarG1是比较稳妥的选择它能控制最大GC停顿时间。堆内存设置到4G对千万级数据迁移已经足够因为流式读取的设计下真正驻留堆内存的只有一个Chunk的数据外加一些缓存。用12G甚至更大堆反而会拖长GC暂停时间并不划算。这里想强调一个思路**不要以为数据量大就要无限调堆而是要把代码写成不残留大量对象的方式。**流式框架里最大的风险是过度缓存而不是堆不够大。6.4 坑四Producer端和Consumer端速率不匹配导致源库被打满我们的迁移Job一开始在Step里同时启了多线程读取想通过并发读提高抽取速度。配置了一个简单的AsyncItemProcessor用线程池处理转换逻辑。结果转换很快但读取和写入速率跟不上反而导致源库连接数和CPU瞬间打满当时线上还有在线交易在跑差点把源库搞挂。后来把并行度降下来只保留了Step间的顺序执行单线程读取、单线程处理、批量写入整个迁移耗时从预期4小时拉长到7小时但源库的负载非常平稳。如果一定要用多线程建议用Spring Batch官方推荐的Partitioning模式来做分片并且在分片数量上做好评估而不是简单给Processor加线程池。Spring Batch的Partitioning思路是把一个大Step拆成多个小分片每个分片有独立的Reader和Writer框架会为每个分片开一个线程执行最后合并执行状态。我们后来在另一张亿级流水表迁移时用了这个方案分4个分片每片负责一段ID区间配合partitioner里的范围计算整体耗时降到了原来的三分之一。6.5 迁移期间目标表如何平滑写入还有一个容易被忽略的问题目标表在迁移期间如果还在接收在线业务写入迁移Job写入可能会和业务产生锁竞争。我们的处理策略是先在目标表创建一个影子表影子库迁移Job写入影子表跑完校验后通过一次快速的元数据切换业务侧低峰期执行rename把影子表切换为正式表。如果迁移失败影子表直接drop不影响线上正式表。这套方案让我们在大规模迁移时敢反复重试、放心调参因为线上业务始终不受影响。7. 迁移任务的可观测性与运维沉淀7.1 Spring Batch的指标监控Spring Batch有JobExecutionListener和StepExecutionListener可以在执行前后拿到丰富的上下文信息。我们在Listener里把Job执行时间、Step的读数量、写数量、跳过数量、状态变化全部打点输出同时写入一张监控表方便后期追溯。Component public class MaterialMigrationListener implements StepExecutionListener { Override public void beforeStep(StepExecution stepExecution) { log.info(Step [{}] started, readCount{}, stepExecution.getStepName(), stepExecution.getReadCount()); } Override public ExitStatus afterStep(StepExecution stepExecution) { log.info(Step [{}] finished, readCount{}, writeCount{}, skipCount{}, status{}, stepExecution.getStepName(), stepExecution.getReadCount(), stepExecution.getWriteCount(), stepExecution.getSkipCount(), stepExecution.getStatus()); return stepExecution.getExitStatus(); } }这些数据推到监控系统后能做的事情很多。比如从stepExecution.getWriteCount()突然下降能判断源库抽取是否变慢从跳过数突增能判断是否有新的脏数据进入从JOB_EXECUTION表的耗时能评估本次迁移是否异常。7.2 Job参数化设计让重跑更安全Job启动参数我们用了统一的JobParametersBuilder包含一个外部传入的执行批次号和运行模式标识。重启已成功的Job时Spring Batch默认会抛出JobInstanceAlreadyCompleteException。为了支持强制重跑的场景我们给Batch配置了JobParametersIncrementer每次跑都会生成一个不同的运行ID。但这里前面已经说过参数一变JobInstance就变了旧状态找不到。所以我们的做法是业务批次号不变运行ID自动增长。这样同一业务批次的重跑能沿用所有表里的BATCH_ID而JobInstance每次都是新的不会与已完成状态冲突。7.3 上线前必须做的三类验证数据迁移这类任务发布上线前一定要跑三轮验证缺一轮都可能出事第一轮是小范围验证挑一张100万行的表跑通全部Step确认目标库记录数正确字段映射无误。这个跑得慢没关系目的是验证链路是通的。第二轮是全量数据校验所有表跑一遍但目标库指向影子表跑完执行差异比对误差率必须为0。这轮要重点看跳过的脏数据可以确定skipLimit上限是否合理。第三轮是压测性能验证把源库和目标库的连接池参数、批量提交参数全部调整到接近线上环境跑一次全量观察源库负载、目标库锁竞争、JVM GC曲线。压测发现的问题比任何代码评审都更有价值。7.4 上线执行的黄金时段选择全量迁移任务尽管性能优化后能控制在几小时但还是要选择业务低峰期执行。我们最终选在凌晨1:00到7:00执行一方面源库的查询压力小避免分页查询频繁锁行另一方面目标影子表切换的时刻要严格避开业务高峰减少切换窗口的抖动。上线前运维需要确认好目标数据库的备份策略最好在切换前做一次全量快照这样即使切换后发现问题也能快速回滚到切换前状态。8. 后续演进方向与我的个人体会迁移项目上线后我们又陆续接到了其他数据域的迁移需求比如供应商主数据、客户主数据。一个很自然的演进方向是把这套Spring Batch工程沉淀成一个迁移平台通过配置化方式定义数据源、表映射、转换规则、校验规则业务方只需要在界面上配置就能生成一个新的迁移Job而不是每个项目都重新写一遍代码。不过平台化的坑也很深尤其是字段映射规则如果都塞进数据库调试排错反而更麻烦。我的建议是先固化Step骨架复杂的转换逻辑还是留在代码里配置只解决简单字段映射和表名等元数据层面的事情。说一个我个人在多次试错后的体会数据迁移任务看着是技术活本质是数据治理。框架能替你解决事务边界、重启恢复、批量读写这些通用问题但数据质量能不能接受、脏数据阈值怎么设、校验差异怎么处理这些问题必须由懂业务的人拍板。技术方案做得再好如果业务侧没有提前定义迁移完成的标准上线后依然会吵成一锅粥。最后分享一个我在写这套Job时的一个小习惯每个Step里我都会把源表、目标表、转换规则的关键信息写到日志里不要只打印Step started和Step finished这种没营养的信息。迁移任务出错后的排查效率往往取决于你日志里留下了多少可追溯的上下文。这套基于Java原生Spring Batch的企业级数据迁移方案从选型到落地前后用了接近三周至今已经稳定支撑了多轮全量及增量迁移。如果让我重新做一次一定会提前把分页排序键、JobRepository独立数据源、影子表切换这三件事放在最前面规划这些是比业务转换逻辑更容易让你深夜爬起来处理的问题。希望这篇完整的梳理能帮你少踩几个我们踩过的坑。