Java Tips | コレクション:バッチ分割

Java Java
スポンサーリンク

バッチ分割は「一気にやると危ない処理を、小さな塊に分けて安全に回す」技

バッチ分割は、ざっくり言うと
「大量データを、バッチ(かたまり)単位に分けて処理する」ためのユーティリティです。

1 万件のレコードを一度に DB に insert すると重すぎる。
外部 API が「1 回 100 件まで」と制限している。
メール送信を 1 件ずつやると遅いので、50 件ずつまとめて処理したい。

こういうときに、「バッチ分割」があるかどうかで、
コードの安全性・読みやすさ・チューニングのしやすさが大きく変わります。


基本イメージ:List を「バッチサイズごと」に刻む

まずはシンプルなバッチ分割ユーティリティ

分割処理そのものは、前にやった「分割処理」とほぼ同じです。
ただし、ここでは「バッチ」という名前で、より“業務寄りの意味”を持たせます。

import java.util.ArrayList;
import java.util.List;

public final class Batches {

    private Batches() {}

    public static <T> List<List<T>> split(List<T> source, int batchSize) {
        if (source == null || source.isEmpty()) {
            return List.of();
        }
        if (batchSize <= 0) {
            throw new IllegalArgumentException("batchSize must be > 0");
        }

        List<List<T>> result = new ArrayList<>();
        int total = source.size();
        for (int i = 0; i < total; i += batchSize) {
            int toIndex = Math.min(i + batchSize, total);
            result.add(source.subList(i, toIndex));
        }
        return result;
    }
}
Java

ここでの重要ポイントは三つです。

一つ目は、「batchSize ごとにインデックスを刻んでいる」ことです。
i += batchSize というループが、「バッチ単位で進む」ことを表しています。

二つ目は、「最後のバッチは batchSize 未満でもよい」としていることです。
103 件を 50 件ずつに分けると、50・50・3 になりますが、それで正しい、というルールです。

三つ目は、「batchSize <= 0 を明示的に弾いている」ことです。
バッチサイズ 0 は論理的にありえないので、早めに例外で気づけるようにします。


業務例1:外部 API に 100 件ずつ送る

「バッチ分割+for」で素直に書ける形にする

よくあるのが、「外部 API の制限で、一度に送れる件数が決まっている」ケースです。

public class SendToApiSample {

    public static void main(String[] args) {
        List<String> ids = /* 1 万件くらいの ID 一覧 */ List.of();

        for (List<String> batch : Batches.split(ids, 100)) {
            sendToExternalApi(batch);
        }
    }

    private static void sendToExternalApi(List<String> idBatch) {
        // ここで外部 API 呼び出し
        // 例:POST /bulk { "ids": [...] }
    }
}
Java

ここでの重要ポイントは、
「呼び出し側のコードが“100 件ずつ送る”という意図だけを素直に書けている」ことです。

Batches.split(ids, 100) という一行が、
「大量の ID を 100 件単位のバッチに刻む」という業務ルールを表現しています。
for の中では「バッチ単位で送る」ことだけに集中できます。


業務例2:DB バルク insert を 1000 件ずつに分ける

トランザクションやロック時間を抑えるためのバッチ分割

DB に大量 insert するときも、バッチ分割はよく使います。

public class BulkInsertSample {

    public static void main(String[] args) {
        List<UserEntity> entities = /* 何万件かのデータ */ List.of();

        for (List<UserEntity> batch : Batches.split(entities, 1000)) {
            insertBatch(batch);
        }
    }

    private static void insertBatch(List<UserEntity> batch) {
        // ここでバルク insert
        // 例:INSERT INTO ... VALUES (...), (...), ...
    }
}
Java

ここでの重要ポイントは二つです。

一つ目は、「バッチサイズがそのまま“DB にかける負荷の単位”になっている」ことです。
1000 件ずつにすれば、トランザクション時間やロック時間をある程度コントロールできます。

二つ目は、「バッチサイズを定数や設定値にしておくと、あとからチューニングしやすい」ことです。

public static final int USER_INSERT_BATCH_SIZE = 1000;
Java

のようにしておけば、
「本番では 500 件に落とそう」といった調整が簡単になります。


subList の“ビュー”問題とコピー版バッチ

「バッチの中身を後で変更するか?」を意識する

subList は「元の List の一部を切り出したビュー」です。
つまり、元の List と要素を共有しています。

バッチの中身を変更しない(読み取り専用)なら問題になりにくいですが、
バッチごとに remove したり、sort したりするなら、
コピーを作っておく方が安全です。

public static <T> List<List<T>> splitCopy(List<T> source, int batchSize) {
    if (source == null || source.isEmpty()) {
        return List.of();
    }
    if (batchSize <= 0) {
        throw new IllegalArgumentException("batchSize must be > 0");
    }

    List<List<T>> result = new ArrayList<>();
    int total = source.size();
    for (int i = 0; i < total; i += batchSize) {
        int toIndex = Math.min(i + batchSize, total);
        result.add(new ArrayList<>(source.subList(i, toIndex)));
    }
    return result;
}
Java

ここでの重要ポイントは、
「バッチを“読み取り専用で使うのか”“中身をいじるのか”を最初に決める」ことです。

実務では、
「基本は読み取り専用の split を使う」
「中身をいじる必要がある処理だけ splitCopy を使う」
といったルールにしておくと、安全で分かりやすくなります。


バッチ分割とエラーハンドリング

「どのバッチで失敗したか」を追えるようにする

バッチ処理で大事なのが、「どのバッチで失敗したか」を特定できることです。

public class RobustBatchSample {

    public static void main(String[] args) {
        List<String> ids = /* 大量の ID */ List.of();

        List<List<String>> batches = Batches.split(ids, 100);

        for (int i = 0; i < batches.size(); i++) {
            List<String> batch = batches.get(i);
            try {
                sendToExternalApi(batch);
            } catch (Exception e) {
                System.err.println("バッチ " + i + " で失敗。件数=" + batch.size());
                // ログ出力やリトライキューへの登録など
            }
        }
    }
}
Java

ここでの重要ポイントは二つです。

一つ目は、「バッチのインデックス(何番目のバッチか)をログに残している」ことです。
これにより、「全体のうちどの範囲で失敗したか」がすぐ分かります。

二つ目は、「バッチ単位で try-catch している」ことです。
1 件の失敗で全体が止まるのではなく、
「このバッチは失敗したが、他のバッチは処理を続ける」といった制御がしやすくなります。


バッチ分割と並列処理の組み合わせ

「バッチ単位で並列に回す」という発想

バッチ分割は、並列処理と相性がいいです。

public class ParallelBatchSample {

    public static void main(String[] args) {
        List<String> ids = /* 大量の ID */ List.of();

        Batches.split(ids, 100)
               .parallelStream()
               .forEach(ParallelBatchSample::sendToExternalApi);
    }

    private static void sendToExternalApi(List<String> batch) {
        // バッチ単位の処理
    }
}
Java

ここでの重要ポイントは、
「並列化の単位を“1 件”ではなく“バッチ”にしている」ことです。

1 件ずつ並列にするとオーバーヘッドが大きくなりすぎますが、
100 件単位で並列にすると、
「そこそこ大きい仕事を、複数スレッドで分担する」形になり、効率がよくなります。

バッチサイズは、そのまま「並列処理の粒度」を意味します。
ここをユーティリティ+定数で管理しておくと、
負荷とスループットのバランス調整がしやすくなります。


まとめ:バッチ分割ユーティリティで身につけてほしい感覚

バッチ分割は、
単に「List を小分けにするテクニック」ではなく、
「大量データを、安全に・制御可能な単位で処理するための設計」です。

Batches.split(list, batchSize) のようなユーティリティで、「バッチサイズ」という業務ルールを一箇所に閉じ込める。
最後のバッチはサイズ未満でもよい、という仕様を明確にしておく。
subList がビューであることを理解し、「読み取り専用か、コピーが必要か」を意識して使い分ける。
バッチ単位でエラーハンドリングし、「どのバッチで失敗したか」を追えるようにする。
並列処理・外部 API・DB バルク処理などの「処理単位」を決める道具として、バッチ分割を位置づける。

もしあなたのコードのどこかに、
「for 文でインデックスをいじりながら、手作業で 100 件ずつに分けて API を叩いている」箇所があれば、
それを一度「バッチ分割ユーティリティ」に置き換えられないか眺めてみてください。

その小さな整理が、
「大量データを、落ち着いてさばけるエンジニア」への、
確かな一歩になります。

タイトルとURLをコピーしました