用脚步丈量人生宽度

Spring Batch 做跨网数据同步:chunk、批量写入和作业元数据

我维护着一个把考勤数据从源库同步到目标库的批处理服务。两个库不在同一个机房,中间隔着一条 RTT(一次网络往返的耗时)约 41ms 的链路;外部调度器每半小时调用一次 HTTP 接口触发作业,每次先清空目标表,再把源库的人员全量和最近一段时间的考勤明细灌进去。

第一版用的是 Spring Batch 的标准写法:MyBatisCursorItemReader 读,MyBatisBatchItemWriter 写,SqlSessionTemplate 配 BATCH 执行器。功能没有问题,但 3,017 条考勤写了 123 秒。每行摊下来约 40ms,和一次跨网往返几乎一样,这个数字后来成了我排查的起点。

这一篇记录我从这里往下查的过程:BATCH 执行器为什么开了却没有把往返合并掉、多值 INSERT 的边界在哪、chunk 为什么不是越大越好,以及 @StepScope 和 BATCH_* 元数据表这些容易忽略的细节。下面的表名和类名做了简化,数据量和耗时来自我实际跑过的几轮。

作业结构:清理和同步各包成一个 Flow

作业本身不复杂:先清空目标表,再同步人员和考勤。Spring Batch 里我把两段各自包成一个 Flow,再把 Flow 包成 Step,Job 只负责按顺序调用两个 Step。

@Bean
public Job hrJob(JobBuilderFactory jobs, Step delStep, Step syncStep) {
    return jobs.get("hrJob")
            .start(delStep)
            .next(syncStep)
            .incrementer(new RunIdIncrementer())
            .build();
}

delStep 内部是 delUserStep → delCheckinoutStep 两个 Tasklet;syncStep 内部是 syncUserStep → syncCheckinoutStep 两个 chunk Step。Flow 负责组内的顺序,Job 只保留 delStep 和 syncStep 两个入口;清理和同步的细节留在各自的配置类里。

RunIdIncrementer 是我第一次部署时碰到的一个点。外部调度每次调 /run,如果 JobParameters 完全相同,Spring Batch 会认为这个 JobInstance 已经完成,直接拒绝再跑,抛的是 JobInstanceAlreadyCompleteException。我在 Job 上加了 RunIdIncrementer,控制器里通过 new JobParametersBuilder(jobExplorer).getNextJobParameters(hrJob) 取下一组参数;incrementer 是在这一步生效的,jobLauncher.run 本身不会自动加 run.id。另外我放了一个每次触发都不同的 excuTime 参数,方便在元数据表里对应到具体触发时间。

下面的写法基于 Spring Boot 2.7 + Spring Batch 4.3。Spring Batch 5 里 JobBuilderFactory / StepBuilderFactory 这套 API 已经换了,代码结构也跟着变。

BATCH 执行器:开了不等于合并了往返

第一版的写端是 MyBatisBatchItemWriter + ExecutorType.BATCH。按文档的理解,chunk 内的多条 insert 应该先在本地攒着,提交时统一发给数据库。但实测每行 40ms,和 RTT 几乎一样,耗时还是按行增长,批量在链路上并没有生效。

我先试了驱动这一层:把 SQL Server 驱动从 6.2.2 升到 9.4.1、连接串加上 useBulkCopyForBatchInsert=true,耗时几乎没有变化。这个参数只作用于 JDBC 的批量插入;写端的一条 insert 要经过 MyBatis 执行器、SqlSession 和驱动几层才落到网络,配了这个参数并不代表它一定会走批量路径。

项目里有三个 DataSource:批处理元数据、源库、目标库。Spring Batch 的 chunk 事务由它的 transaction manager 管理;第一次跑作业时,日志里 DefaultBatchConfigurer 提示没有提供 transaction manager,于是用了 DataSourceTransactionManager,而它绑定在带 @Primary 的元数据库上。目标库的写入走自己的连接,不参与元数据那条事务,提交也是分开的。不过这只说明提交边界不同,不能直接推出 SqlSession 会不会复用;会话复用是另一层的事。

BATCH 执行器的累积范围是同一个 SqlSession:会话在,连续的同一条 insert 才排得进批处理队列;会话每次调用都新建,队列里就永远只有一条。到了 flush 阶段,多条语句能不能合成一次网络传输,又取决于驱动;而会话是否复用还受 Spring 的事务同步影响。这几层里任何一层不成立,BATCH 都可能失去合并往返的效果。

我当时没有把每一层都打开日志确认,能确定的是:把执行器设成 BATCH,并不保证一批 insert 只发一次请求。

与其继续在这条链上追,我换了方向:不让 BATCH 攒,直接把一批行拼成一条 SQL。这样不管哪一层没有合批,往返次数都由批次数决定,结果不依赖执行器的行为。

多值 INSERT:把往返次数从行数变成批次数

写端换成一个很小的 ItemWriter:考勤这边的 chunk 是 100,写入器把这一批交给一条 INSERT ... VALUES (...), (...), ... 语句发给目标库。

public class MultiRowInsertItemWriter<T> implements ItemWriter<T> {

    // SQL Server 一条 INSERT 最多 1000 组 VALUES、2100 个参数;
    // 11 列 × 150 行 = 1650 个参数,仍在上限内
    private static final int MAX_ROWS_PER_STATEMENT = 150;

    private final SqlSessionTemplate sqlSessionTemplate;
    private final String statementId;

    public MultiRowInsertItemWriter(SqlSessionTemplate sqlSessionTemplate,
                                    String statementId) {
        this.sqlSessionTemplate = sqlSessionTemplate;
        this.statementId = statementId;
    }

    @Override
    public void write(List<? extends T> items) {
        if (items == null || items.isEmpty()) {
            return;
        }
        for (int start = 0; start < items.size(); start += MAX_ROWS_PER_STATEMENT) {
            int end = Math.min(start + MAX_ROWS_PER_STATEMENT, items.size());
            sqlSessionTemplate.insert(statementId, items.subList(start, end));
        }
    }
}

对应的 Mapper XML 就是一个 foreach:

<insert id="insertBatch">
    insert into checkinout
        (userid, checktime, checktype, verifycode, sensorid,
         memoinfo, workcode, sn, user_ext_fmt, mask_flag, temperature)
    values
    <foreach collection="list" item="item" separator=",">
        (#{item.userid}, #{item.checktime,jdbcType=TIMESTAMP}, #{item.checktype},
         #{item.verifycode}, #{item.sensorid}, #{item.memoinfo},
         #{item.workcode}, #{item.sn}, #{item.userExtFmt},
         #{item.maskFlag}, #{item.temperature})
    </foreach>
</insert>

SQL Server 对 INSERT ... VALUES 有两个硬限制:一条语句最多 1,000 组 VALUES,参数上限 2,100。考勤表 11 列,150 行就是 1,650 个参数;再大就会碰到上限。写入器里把上限固定成 150,chunk 以后调大时它会自动拆成多条语句。

我这里用的是 SIMPLE 执行器。多值语句本身已经是一批;再交给 BATCH 攒一层没有意义,而且语句错误会推迟到提交时才暴露。

改造后的结果:3,017 条考勤按 chunk 100 切成 31 条语句,写库约 3 秒;考勤 Step 从 123 秒降到 4.4 秒,整作业落在 9 秒左右。时间从正比于行数变成正比于批次数。31 条语句大多在 85–130ms,偶尔会高一些,但每批只发一条语句,和 3,017 条单行语句不在一个量级。

chunk 大小:为什么人员 150、考勤 100

chunk 是 Spring Batch 每次读取、处理、写入并提交的行数。多值 INSERT 落地后,我自然想把 chunk 调大,减少语句数。把人员和考勤都从 100 调到 150:人员 Step 从 1.92 秒降到 1.09 秒,考勤 Step 却从 4.35 秒升到 6.59 秒,单条 insert 出现了 409、615、558、421、1,009ms 的尖峰,整作业反而从 9.06 秒变成 10.99 秒。

步骤chunk 100chunk 150
人员1.92s1.09s
考勤4.35s6.59s
整作业9.06s10.99s

原因不神秘:chunk 同时决定提交间隔和单条语句的大小。考勤表 11 列、字段更宽,150 行一条语句的参数更多、SQL 文本更长,SQL Server 的解析和网络传输都变大;跨网条件下,单条语句变大并不等于总时间变短。人员表只有 4 列,150 行仍然是小事,所以它受益了。

最后我把人员留在 150,考勤退回 100。两张表合适的值不一样,这个值只能测出来,和行数没有简单的比例关系。

@StepScope 和时间窗口

考勤读端只取最近一段时间的记录,窗口起点是在 Bean 方法里算的:当前时间往前推几天。这个 Bean 如果按单例创建,窗口起点就固定在应用启动那一刻;服务长期不重启,后面每次执行读的范围会越来越大。

给 reader 加上 @StepScope 之后,Spring Batch 会在每次 Step 执行前创建新的 Bean,窗口重新计算。

@Bean
@StepScope
public MyBatisCursorItemReader<Checkinout> checkinoutReader(
        SqlSessionFactory sourceSqlSessionFactory) {

    MyBatisCursorItemReader<Checkinout> reader = new MyBatisCursorItemReader<>();
    reader.setSqlSessionFactory(sourceSqlSessionFactory);
    reader.setQueryId("com.example.mapper.CheckinoutMapper.selectCheckinouts");

    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DAY_OF_MONTH, -6);
    Date startTime = calendar.getTime();

    Map<String, Object> params = new HashMap<>();
    params.put("time", startTime);
    reader.setParameterValues(params);
    return reader;
}

窗口长度本身也是个取舍:太短,服务停机超过窗口就会漏数据;太长,每轮扫描的数据量变大。我是按调度间隔和故障恢复时间来定的,没有一个固定的最佳值。

读端用的是 MyBatisCursorItemReader,游标逐行取,不会把整个结果集一次性装进内存。查询只列出需要的列,也能少传一些不必要的大字段。

作业元数据:BATCH_* 表怎么用

Spring Batch 把每次执行写进 BATCH_JOB_EXECUTION 和 BATCH_STEP_EXECUTION。排查时我主要看 STEP_NAME、READ_COUNT、WRITE_COUNT、COMMIT_COUNT 和起止时间。COMMIT_COUNT 很直观:3,017 行、chunk 100,就是 31 次提交;如果它和预期对不上,说明 chunk 或读端的行为和我以为的不一样。

SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, COMMIT_COUNT,
       START_TIME, END_TIME
FROM BATCH_STEP_EXECUTION
ORDER BY STEP_EXECUTION_ID DESC
LIMIT 10;

上面的查询以 MariaDB 为例;如果元数据库换成 SQL Server,把 LIMIT 10 写成 SELECT TOP 10 ...。

这套表的前提是它们已经存在。我在启动类里排除了 BatchAutoConfiguration,用 @EnableBatchProcessing 手动装配批处理基础设施;这种写法下,application.yml 里的 spring.batch.jdbc.initialize-schema 不会执行,BATCH_* 表需要提前建好。应用能正常启动,第一次跑作业才会因为找不到 BATCH_JOB_INSTANCE 表报错,所以换库或换环境时我先确认这组表。

元数据库带 @Primary 也很重要:@EnableBatchProcessing 默认拿主 DataSource 建 JobRepository,多个数据源时它会按这个标记来选。

全量覆盖式同步的取舍

这个作业每次先 TRUNCATE 两张目标表,再灌数据。好处是简单、结果确定,不需要处理更新和删除;代价是清空到写完之间目标表不完整,如果同步中途失败,目标表会停在半截,等下一轮再补。对每半小时跑一次、每次几千行的场景,我接受这个代价;如果下游随时都在读目标表,另一种做法是 staging 表或增量 upsert。

还有一个前提:同一时间只能有一个作业在执行。触发入口是一个 GET /run,本身没有互斥;如果上一轮还没结束、下一轮又被调起来,两边都会清表、写表。我在控制器注释里写明了需要外部调度保证串行,但如果重来一次,我会把互斥做进作业入口,而不是把正确性寄托在调度器的间隔上。

我最后留下的配置

最后这组参数是:人员 chunk 150,考勤 chunk 100;写入器单条语句最多 150 行;写端用 SIMPLE 执行器;读端 @StepScope;SQL Server 驱动 9.4.1。3,017 条考勤的写库时间约 3 秒,考勤 Step 约 4.4 秒,整作业约 9 秒;677 条人员大约 1–2 秒。

我没有继续上 SQLServerBulkCopy。它当然还能再压一截,但会多一条写入路径和一套错误处理;以现在的数据量和半小时的调度间隔,9 秒已经留出了足够余量。等数据量涨上来、或者调度间隔缩短,再考虑它也不迟。

学习时要特别注意的几处

重复触发时作业不会启动。 症状是第二次调用 /run 没有启动新的作业,日志里是 JobInstanceAlreadyCompleteException。原因是 Spring Batch 用 JobParameters 识别 JobInstance,参数完全相同就认为已经完成。判断方法是去 BATCH_JOB_INSTANCE 里按 JOB_NAME 和 JOB_KEY 查有没有旧实例;我的处理是加 RunIdIncrementer,再附一个每次触发都不同的时间参数。

BATCH 执行器开了,但每行还是一次往返。 症状是写入耗时和行数成正比,单行耗时接近 ping 的 RTT。ExecutorType.BATCH 只说明 MyBatis 会把语句交给批量执行器,能不能合成一次网络传输,还取决于会话有没有被复用、驱动有没有把多条参数合并;多数据源下,chunk 的事务管理器通常只绑在元数据库上,业务库的写入不在同一个事务连接里。判断方法是先量单行耗时和 RTT,再确认一个 chunk 实际发了几次请求;我的处理是改用多值 INSERT,把往返次数直接压到批次数。

换环境后第一次跑作业才报找不到 BATCH_* 表。 症状是应用启动正常,第一次触发时提示 BATCH_JOB_INSTANCE 不存在。原因是排除了 BatchAutoConfiguration,spring.batch.jdbc.initialize-schema 不会执行。判断方法是检查启动类的 exclude 列表和元数据库里有没有这套表;我换库时先把这组表建好再启动。

服务跑久了,读的时间窗口越来越宽。 症状是同一个查询在重启后的第一轮很快,后面几轮越来越慢,或者读到的数据范围和预期不符。原因是 reader 是单例,窗口起点在应用启动时就固定了。判断方法是每轮把窗口起点打进日志,和当前时间比一比;我的处理是给 reader 加 @StepScope。

chunk 调大后单条语句出现秒级尖峰。 症状是语句数少了,但单条 insert 偶尔超过 1 秒,Step 总时长反而变长。原因是单条语句的参数和 SQL 文本变大,跨网传输和数据库解析的成本都上来了。判断方法是把每条语句的行数和耗时打出来,按 chunk 大小各跑一轮对比;我的处理是人员 150、考勤 100,分开定。

这几个点里,对我影响最大的是第二个:BATCH 执行器看起来是“打开一个开关”,但开了之后一批语句会不会真的合成一次网络传输,还要看会话、事务和驱动几层。这件事之后,我不再按“有没有开 BATCH”判断性能,而是直接看一次 chunk 实际发了几次请求;多值 INSERT、chunk 大小和驱动参数的选择也都围绕这个指标来定。

评论

暂未收到

写下评论

支持 Markdown 语法