MyBatisでMapperを書いた経験はあるのに、Spring BatchのChunk処理に手を出すとJPAやCSVの話ばかりで困ったことはないでしょうか。実際には、既存のMapper XMLやアノテーションをそのままItemReader・ItemWriterに流用できます。この記事では MyBatisCursorItemReader でのカーソル読み取りと MyBatisBatchItemWriter でのバッチ更新を使って、Job/Step/Chunk構成を組んでいきます。

MyBatisとは何か、Mapperの基本的な実装方法は MyBatisとは何かを解説した記事 と MyBatisのMapper実装ガイド に譲り、ここではSpring Batchとの接続部分に絞ります。FlatFileItemReaderを使ったCSV取り込みは CSVインポートの記事 で解説しているので、そちらも合わせてどうぞ。

MyBatisCursorItemReaderとMyBatisBatchItemWriterが解決すること

MyBatisCursorItemReader は、カーソルを使って1件ずつ結果を読み出すItemReaderです。全件をメモリに乗せずに大量データを処理できます。

一方 MyBatisBatchItemWriter は、chunkにまとまったアイテムを1回のバッチ処理でまとめて書き込むItemWriterです。どちらも既存のMapperインターフェースやXMLのSQLをそのまま参照できるのが大きな利点で、ItemReader/ItemWriter専用のSQLを新しく書く必要はありません。

依存関係の準備

必要な依存はmybatis-spring-boot-starterとspring-boot-starter-batchの2つだけです。MyBatisCursorItemReaderとMyBatisBatchItemWriterはmybatis-spring(org.mybatis.spring.batchパッケージ)に含まれているので、starterを入れれば自動的に使えます。

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-batch</artifactId>
</dependency>

mapper-locationsなどのapplication.ymlの設定は、普段のMyBatis実装から変える必要はありません。

MyBatisCursorItemReaderの設定

ItemReaderのBean定義では、queryIdにMapperのnamespace付きIDを指定します。新しいMapperメソッドを増やす必要はなく、既存のselect文をそのまま流用できます。

@Bean
public MyBatisCursorItemReader<User> userItemReader(SqlSessionFactory sqlSessionFactory) {
    return new MyBatisCursorItemReaderBuilder<User>()
            .sqlSessionFactory(sqlSessionFactory)
            .queryId("com.example.mapper.UserMapper.selectAllForBatch")
            .parameterValues(Map.of("status", "ACTIVE"))
            .build();
}

queryIdで参照しているのは、普段のMapper XMLに書いているのと同じselect文です。

<select id="selectAllForBatch" resultMap="userResultMap">
    SELECT id, name, email, status
    FROM users
    WHERE status = #{status}
    ORDER BY id
</select>

resultMapもそのまま使えるので、エンティティ側を変更する必要はありません。parameterValuesに渡したMapが#{status}に展開されます。

MyBatisBatchItemWriterの設定とExecutorType.BATCH

ItemWriterの設定もほぼ同じ形で、statementIdに更新用のMapper IDを指定します。

@Bean
public MyBatisBatchItemWriter<User> userItemWriter(SqlSessionFactory sqlSessionFactory) {
    return new MyBatisBatchItemWriterBuilder<User>()
            .sqlSessionFactory(sqlSessionFactory)
            .statementId("com.example.mapper.UserMapper.updateStatus")
            .build();
}

itemToParameterConverterを指定しなければ、chunk内のオブジェクトがそのままパラメータとしてMapperのstatementに渡されます。

内部的にはSqlSessionがExecutorType.BATCHで開かれ、chunk分のstatement呼び出しが1回のflushでまとめて実行される点が、通常のMapper呼び出し(SIMPLE)と違うところです。BATCHモードでは個々の更新の戻り値(影響行数)を正確に取れないので、件数チェックはread/writeのカウントで行う方が安全です。

Job/Step/Chunk構成を組み立てる

ここまでのReaderとWriterをchunkでつなぐだけで、Job/Step構成ができあがります。

@Bean
public Step userUpdateStep(JobRepository jobRepository,
                            PlatformTransactionManager transactionManager,
                            MyBatisCursorItemReader<User> userItemReader,
                            MyBatisBatchItemWriter<User> userItemWriter) {
    return new StepBuilder("userUpdateStep", jobRepository)
            .<User, User>chunk(100, transactionManager)
            .reader(userItemReader)
            .writer(userItemWriter)
            .listener(loggingStepExecutionListener())
            .build();
}

@Bean
public Job userUpdateJob(JobRepository jobRepository, Step userUpdateStep) {
    return new JobBuilder("userUpdateJob", jobRepository)
            .start(userUpdateStep)
            .build();
}

chunk(100, …)で指定した件数が、コミット単位であると同時にMyBatisBatchItemWriterが1回でflushする件数にもなります。件数を大きくすると書き込みのオーバーヘッドは減りますが、1件のエラーで巻き戻る範囲も広がるので、まずは100〜500件あたりから試すのがおすすめです。

トランザクション境界で気をつけること

ExecutorType.BATCHで開かれるSqlSessionは、chunk単位のトランザクションと同じ範囲で管理されます。Writer内で例外が発生すると、そのchunk分の更新は すべてロールバック されます。読み込んだ件数と書き込んだ件数が最後までズレないか、後述のListenerで確認しておくと安心です。

カーソルとコネクション、fetchSizeの注意

MyBatisCursorItemReaderはカーソルをオープンしたまま1件ずつ読むため、Step実行中はDBコネクションを専有し続けます。fetchSizeを大きくすると一度に取得する行数が増え、 メモリ使用量と読み取り速度のトレードオフ になります。長時間カーソルを保持する場合は、DB側のコネクションタイムアウト設定も合わせて確認しておきましょう。

処理件数をStepExecutionListenerで確認する

実装した構成が正しく動いているかは、read件数とwrite件数をログに出すだけでも十分確認できます。

@Bean
public StepExecutionListener loggingStepExecutionListener() {
    return new StepExecutionListener() {
        @Override
        public ExitStatus afterStep(StepExecution stepExecution) {
            log.info("read={}, write={}",
                    stepExecution.getReadCount(), stepExecution.getWriteCount());
            return stepExecution.getExitStatus();
        }
    };
}

read件数とwrite件数が一致していれば、カーソル読み取りからバッチ更新までが想定どおりに流れている証拠になります。

ハマりやすいポイント

  • queryId・statementIdはMapperのnamespaceを含めたIDで指定します。クラス名だけ書いてNotFoundになるケースが多いです。
  • データソースを複数構成している場合、ItemReaderとItemWriterに渡すSqlSessionFactoryを取り違えないよう注意しましょう。
  • BATCHモードでは更新の戻り値を個別に当てにできないので、件数チェックはStepExecutionの統計値で行いましょう。

まとめ

MyBatisCursorItemReaderとMyBatisBatchItemWriterを使えば、既存のMapper実装をそのまま流用してChunk処理を組めます。本番投入前には、ExecutorType.BATCHによるトランザクション境界と、カーソル保持中のfetchSize・コネクション使用量の2点だけは必ず確認しておきましょう。Mapperの実装パターンは MyBatisのMapper実装ガイド、MyBatisとJPAの使い分けは MyBatis vs JPA比較ガイド で詳しく解説しています。