はじめに
業務で Spring Batch を使うことになり、TERASOLUNA Batch 開発ガイドライン を読んだものの、
- 結局これはどうやって動くの?
- 「スケジューラに登録して Job をキックする」ってどういうこと?
- Job?Step?Tasklet?チャンク?登場人物が多すぎる
となった人向けの記事です。
対象読者
- Spring Boot(MVC・DI)は業務レベルで書ける
- バッチアプリの開発経験はない、または Spring Batch は初めて
- すでに稼働中の Web アプリケーションにバッチ処理を追加することになった
この記事のゴール
- 「Web アプリのあの仕組みが、バッチではこれに相当する」という対応づけで Spring Batch の全体像をつかむ
- 稼働中の Web アプリにバッチを足すとき、どこにどう配置するのかを判断できるようになる(→ 6章)
後半(6章)では「100万アカウントを抱える稼働中の Spring Boot Web アプリに、日次バッチを追加する」という具体的なケースで、構成の選択肢と application.yml の書き方まで踏み込みます。
先に全体像
細かい話に入る前に、Spring Batch がどういう並びで動くのかを1枚で示します。この記事はこの図を順番に分解していく構成になっています。
この記事で伝えたい一番のポイントは、左端の「ジョブスケジューラ」が Spring Batch の外側にいる ということです。ここが分かるとイメージが一気につながります。
1. 一番大事な前提:Spring Batch は「常駐して定時に動くもの」ではない
初心者が最初につまずくのがここです。
Spring Batch にスケジューラ機能はありません。
Spring Batch は「決まった時刻に動く仕組み」を提供しません。提供するのは 「バッチ処理の書き方の型」と「実行状態の管理」だけ です。
時刻になったら起動する役目は、外部のジョブスケジューラ(cron、JP1、A-AUTO、Autosys、Rundeck、k8s CronJob など)が担当します。スケジューラがやることは、身も蓋もなく言えば シェルスクリプトを叩くだけ です。
つまり「Job をキックする」とは、スケジューラが java コマンドを実行し、Java プロセスが起動し、処理が終わったら Java プロセスが終了する、という流れのことです。
Web アプリとの決定的な違い
| Web アプリ (Spring MVC) | バッチ (Spring Batch) | |
|---|---|---|
| 起動のきっかけ | ユーザーの HTTP リクエスト | スケジューラ(または人)がコマンド実行 |
| プロセス | 常駐し続ける | 1回動いて終了する(が基本) |
| 入力 | リクエストパラメータ | ジョブパラメータ(コマンドライン引数) |
| 出力 | HTTP レスポンス | ファイル / DB / 終了コード |
| 結果の伝え方 | ステータスコード 200/500 | プロセスの終了コード 0/1 |
スケジューラは終了コードだけを見て成否を判定します。ここが Web との一番大きな発想の違いです。
2. 登場人物と全体の流れ
Spring Batch の構成要素は、最初に覚えるのは 5 つで十分です。
| 構成要素 | 役割 | Spring MVC で言うと |
|---|---|---|
| Job | バッチ処理 1 本ぶんの実行単位 | 1 つのユースケース全体 |
| Step | Job を構成する処理の単位。1 Job に 1〜N 個 | Controller のハンドラメソッド |
| JobLauncher | Job を起動するインターフェース | DispatcherServlet |
| ItemReader / ItemProcessor / ItemWriter | 入力・加工・出力の 3 分割 | Service を役割ごとに分けたもの |
| JobRepository | Job / Step の実行状態を DB に永続化する | ログ + 実行履歴テーブルの管理役 |
なぜ Job と Step が分かれているのか?
1 つの Job を複数 Step に分けると、以下ができるようになります。
- 処理の再利用(同じ Step を複数 Job で使う)
- 並列実行
- 条件分岐(Step A が成功したら B、失敗したら C)
- 失敗した Step から再実行
「Step 1(ファイル取込)は成功したが Step 2(集計)で落ちた」というとき、Step 2 からやり直せます。これが Step を分ける最大の実利です。
3. ビジネスロジックの書き方は 2 種類ある
Step の中身の書き方には チャンクモデル と タスクレットモデル の 2 つがあります。ここは必ず設計段階で選択を迫られるので、違いを押さえておきます。
3-1. チャンクモデル(大量データ向け)
一定件数(チャンク)ごとにまとめて処理する方式です。100 万件の CSV を読んで DB に入れる、といった典型的なバッチはこちらです。
重要なのは呼び出し回数の非対称性です。
- read と process はチャンク件数ぶん、1 件ずつ繰り返し呼ばれる
- write はチャンクにつき 1 回だけ、加工済みデータをまとめて受け取る
- その単位でトランザクションがコミットされる
write が 1 回にまとめられているのは、JDBC の addBatch / executeBatch のように I/O をまとめて性能を出すため です。1 件ずつ INSERT していたら 100 万件は現実的な時間で終わりません。
なお ItemReader / ItemWriter は、Spring Batch が実用的な実装をすでに用意してくれています。
| インターフェース | 代表的な実装 | 用途 |
|---|---|---|
| ItemReader | FlatFileItemReader |
CSV などのフラットファイル読み込み |
MyBatisCursorItemReader |
MyBatis 経由の DB 読み込み | |
JdbcCursorItemReader / JdbcPagingItemReader
|
JDBC 経由の DB 読み込み | |
| ItemProcessor | CompositeItemProcessor |
複数の Processor を順番に実行 |
PassThroughItemProcessor |
何もしない(加工不要なとき) | |
| ItemWriter | FlatFileItemWriter |
ファイル書き出し |
MyBatisBatchItemWriter / JdbcBatchItemWriter
|
DB への一括書き込み |
自分で書くのは基本 ItemProcessor だけ です。ビジネスロジックそのものなので、ここに既製品はありません。
「90日以上ログインしていないアカウントを抽出して、メール送信対象に変換する」という例で見てみます。
public class InactiveAccountProcessor
implements ItemProcessor<Account, MailTarget> { // (1)
@Override
public MailTarget process(Account account) throws Exception { // (2)
if (account.getLastLoginAt()
.isAfter(LocalDate.now().minusDays(90))) { // (3)
return null; // (4)
}
MailTarget target = new MailTarget(); // (5)
target.setAccountId(account.getId());
target.setEmail(account.getEmail());
target.setSubject("お久しぶりです");
return target; // (6)
}
}
| 項番 | 説明 |
|---|---|
| (1) |
ItemProcessor<入力の型, 出力の型> を実装する。ここでは「Account を受け取って MailTarget を返す」という宣言。ジェネリクスで型が決まるので、実装時に型を間違えるとコンパイルエラーになる |
| (2) | 実装するメソッドはこれ 1 つだけ。引数の account が ItemReader が読んだ 1 件分のデータ
|
| (3) | ビジネスロジック。ここでは「最終ログインが90日以内か」を判定している |
| (4) |
null を返すと、そのデータは ItemWriter に渡されません(=処理対象から除外される)。SQL の WHERE で絞りきれない条件をここで弾ける |
| (5) | 出力用のオブジェクトを作り、値を詰める |
| (6) | 返したオブジェクトが ItemWriter に渡る |
ポイントは 「1件ぶんの処理だけ書けばいい」 ことです。ループも、DBへの接続も、トランザクションも書きません。「100万件をどう回すか」は Spring Batch がやってくれるので、開発者は 1 件に対して何をするかだけに集中できます。ここが Spring Batch を使う一番のメリットです。
null を返した件数は BATCH_STEP_EXECUTION テーブルの FILTER_COUNT に記録されるので、「何件が対象外だったか」を後から確認できます。
3-2. タスクレットモデル(単発処理向け)
「読む・加工する・書く」の型に当てはまらない処理はこちらです。
- システムコマンドを 1 回叩くだけ
- 制御テーブルのレコードを 1 件だけ UPDATE
- 複数のテーブルやファイルを行ったり来たりする複雑な処理
「バッチ開始前に、前回の一時テーブルを削除する」という例です。
@Component // (1)
public class ClearTempTableTasklet implements Tasklet { // (2)
private final TempDataMapper mapper; // (3)
public ClearTempTableTasklet(TempDataMapper mapper) { // (4)
this.mapper = mapper;
}
@Override
public RepeatStatus execute(
StepContribution contribution, // (5)
ChunkContext chunkContext) throws Exception { // (6)
int deleted = mapper.deleteAll(); // (7)
log.info("一時テーブルを{}件削除しました", deleted);
return RepeatStatus.FINISHED; // (8)
}
}
| 項番 | 説明 |
|---|---|
| (1) | いつもの @Component。DI コンテナに登録され、Step 定義から使えるようになる |
| (2) |
Tasklet インターフェースを実装する。ジェネリクスは無い(入出力の型を決める必要がないため) |
| (3)(4) | コンストラクタインジェクション。Web アプリの Service と全く同じ書き方でよい。MyBatis の Mapper も @Service も普通に DI できる |
| (5) | 処理件数などを Spring Batch に報告するためのオブジェクト。使わないことも多い |
| (6) | ジョブパラメータや ExecutionContext にアクセスするためのオブジェクト。使わないことも多い |
| (7) | ここが処理の本体。やりたいことを普通に書くだけ |
| (8) |
FINISHED を返すとこの Step は終了。 RepeatStatus.CONTINUABLE を返すと execute がもう一度呼ばれる(繰り返し処理を自前で制御したいとき用) |
チャンクモデルと違い、ループもトランザクション境界も自分の責任 になります。この Tasklet の中で 100万件を List で取得したら普通に OOM で落ちます。だから「大量データはチャンク、単発処理はタスクレット」という使い分けになるわけです。
使い分けの判断基準
| チャンクモデル | タスクレットモデル | |
|---|---|---|
| 得意なこと | 大量データの定型処理 | 単発・非定型な処理 |
| トランザクション | チャンク単位で自動コミット | 自分で意識する必要あり |
| 再実行 | 途中から再開しやすい | 自前で考える必要あり |
| 実装量 | Processor だけ書けば済むことが多い | 全部自分で書く |
迷ったら、大量データならチャンク・それ以外はタスクレット で最初は十分です。
4. JobRepository:Spring Batch が DB テーブルを要求する理由
Spring Batch を導入すると、BATCH_JOB_INSTANCE などのテーブルを作れと言われます。「バッチを動かすだけなのになぜ?」となるポイントですが、理由は一つです。
再実行を実現するため。
バッチは Java プロセスが起動して終了します。プロセスが死ねばメモリ上の情報は消えます。それでも「昨日の夜に落ちたジョブを、途中から再開したい」を実現するには、前回実行時のスナップショットをプロセスの外(= DB)に残しておくしかありません。
メタデータの構造
ここで絶対に押さえるべきなのが JobInstance と JobExecution の違い です。
| 意味 | 識別方法 | |
|---|---|---|
| JobInstance | Job の「論理的」な実行 | Job 名 + ジョブパラメータ |
| JobExecution | Job の「物理的」な実行 | 実行するたびに毎回新しく作られる |
「2026年8月5日ぶんの売上集計」という論理的な仕事が JobInstance、それを「1回目は失敗、2回目で成功した」という実際の実行が JobExecution。よって 1 対多 の関係になります。
初心者が 100% 踏む地雷
同じ Job 名・同じパラメータで、正常終了済みの Job は二度と実行できません。
JobInstanceAlreadyCompleteException が飛んでプロセスが異常終了します。「動作確認しようと 2 回叩いたら 2 回目が落ちた」というのは、Spring Batch 初心者の通過儀礼です。
これは仕様です。「同じ仕事を二重に流してしまう」という、バッチで最も怖い事故を防ぐためのガードです。
対処法:日次実行のように繰り返し起動する Job には、JobInstance をユニークにするためだけのパラメータ を足します。
# 実行日を渡してユニークにする
--jobDate=20260805
# 開発中はタイムスタンプや乱数でも可
--run.id=1754380800000
Spring Boot なら RunIdIncrementer、TERASOLUNA ならパラメータ変換クラスを使う方針が案内されています。実運用では「業務日付」を渡すのが定石です(同じ日付で二重起動できないこと自体がガードになる)。
5. コードで見る(Spring Boot + Spring Batch 5 系)
5-1. その前に:この設定クラスは「処理をするコード」ではない
Spring Batch のコードで最初に混乱するのがここです。
設定クラスは、起動時に一度だけ動く「工場のライン設計図」です。データはここを流れません。
Web アプリの Controller は、リクエストが来るたびに中のコードが動きます。しかし Spring Batch の設定クラスは違います。アプリ起動時に1回だけ実行されて、Job と Step という「組み立て済みのオブジェクト」を作って終わり です。実際のデータ処理は、その後に別のタイミングで始まります。
| いつ動くか | 何が起きるか | |
|---|---|---|
| 組み立てフェーズ | アプリ起動時に1回 |
@Bean メソッドが Spring に呼ばれ、Job と Step が組み立てられる。データは1件も流れない
|
| 実行フェーズ | Job が起動されてから | 組み立て済みの Step が、100万件のデータを1件ずつ流す |
ベルトコンベアに例えると、設定クラスはコンベアを設置して「ここに読み取り係、ここに加工係、ここに箱詰め係を配置」と決めているだけです。ベルトが回り始めるのはその後です。
① 起動時 = 設計図を組み立てるだけ(1回)
この時点でデータは1件も流れていません。ベルトコンベアを設置しただけの状態です。
② 実行時 = データが1件ずつ流れる(100万回)
設定クラスに書いてあるのは①の話、実際にデータが流れるのは②の話 です。この2つを混同していると、いつまでもコードが読めません。
5-2. 登場するクラスは、どれがライブラリでどれが自作か
「これは Spring がもともと持っているもの? 自分で作るもの?」がはっきりすると、一気に読みやすくなります。
| 登場するもの | 出どころ | 自分で書く? |
|---|---|---|
@Configuration / @Bean
|
Spring Framework の基本機能 | アノテーションを付けるだけ |
Job / Step
|
Spring Batch(インターフェース) | 書かない |
JobBuilder / StepBuilder
|
Spring Batch(組み立て用のクラス) | 書かない |
JobRepository |
Spring Batch。Spring Boot が自動で用意 | 書かない |
ItemReader / ItemWriter
|
Spring Batch。既製品の実装を使う | ほぼ書かない |
ItemProcessor |
Spring Batch のインターフェース | 実装クラスを書く |
PlatformTransactionManager |
Spring Framework(spring-tx)。Spring Boot が自動で用意 |
書かない |
InactiveAccountJobConfig |
— | 自作(この設定クラス) |
Account / MailTarget
|
— | 自作(getter/setter だけの DTO) |
自作するのは、設定クラス1つ・Processor 1つ・DTO 2つだけ です。残りはすべてライブラリが持っているものを組み合わせているに過ぎません。
5-3. 引数はどこから来るのか
もう一つの疑問がこれです。JobRepository も ItemReader も、どこにも new していないのに引数として渡ってきます。
答えは単純で、このメソッドを呼ぶのはあなたではなく Spring だから です。
Spring は起動時に @Bean の付いたメソッドを見つけると、引数の型に合うオブジェクトを DI コンテナの中から探してきて渡した上で、メソッドを実行します。 @Service のコンストラクタに Repository を書けば勝手に入ってくるのと、まったく同じ仕組みです。Spring Batch 特有の魔法ではありません。
| 引数 | どこから来るか |
|---|---|
JobRepository jobRepository |
Spring Boot が自動生成してコンテナに入れている |
PlatformTransactionManager transactionManager |
同上。spring.datasource の設定から作られる |
Step inactiveAccountStep |
同じクラスの下のメソッドが作ったもの。引数名がメソッド名(=Bean名)と一致するので紐づく |
ItemReader<Account> accountReader |
6章で定義した accountReader() メソッドが作ったもの |
同じ型の Bean が複数あると Spring がどれを渡すか決められずエラーになります。引数名を Bean 名(メソッド名)と一致させておく のが確実です。
5-4. JobRepository の実体は何を指しているのか
「引数で受け取っているけど、これは一体どこの何なのか」が見えないと気持ち悪いと思います。
-
実体:Spring Boot が自動生成した
SimpleJobRepositoryのインスタンス -
書き込み先:
application.ymlのspring.datasourceで設定した、いつもの業務 DB -
書く内容:
BATCH_JOB_EXECUTIONなどのメタデータテーブル(4章参照)
BATCH_ テーブルを作らされるのはこのためです。役割は 進捗の記録係だけ で、業務データには一切触りません。「何時に始まった」「何件読んだ」「どこで落ちた」をひたすら書き続けます。
Job にも Step にも jobRepository を渡しているのは、Job 全体の状態と Step 単位の状態を、両方記録する必要があるから です。
5-5. コードを読む
以上を踏まえてコードを見ます。3章で作った「休眠アカウントにメールを送る」処理を、実際に Job として組み立てます。
@Configuration // (1)
public class InactiveAccountJobConfig {
@Bean // (2)
public Job inactiveAccountJob(JobRepository jobRepository, // (3)
Step inactiveAccountStep) { // (4)
return new JobBuilder("inactiveAccountJob", jobRepository) // (5)
.start(inactiveAccountStep) // (6)
.build(); // (7)
}
@Bean
public Step inactiveAccountStep(
JobRepository jobRepository,
PlatformTransactionManager transactionManager, // (8)
ItemReader<Account> accountReader, // (9)
ItemProcessor<Account, MailTarget> processor,
ItemWriter<MailTarget> writer) {
return new StepBuilder("inactiveAccountStep", jobRepository)
.<Account, MailTarget>chunk(1000, transactionManager) // (10)
.reader(accountReader) // (11)
.processor(processor) // (12)
.writer(writer) // (13)
.build();
}
}
| 項番 | 説明 |
|---|---|
| (1) | いつもの @Configuration。「ここに Bean の定義を書きますよ」という宣言 |
| (2) | このメソッドが返す Job を DI コンテナに登録する |
| (3) |
JobRepository を DI で受け取っている。自分で new する必要はなく、Spring Boot が自動で用意している。これが実行状態を DB に書く担当 |
| (4) | 下で定義している Step を DI で受け取っている。Bean 名(メソッド名)で紐づく ので、inactiveAccountStep という名前が一致していれば Spring が繋いでくれる |
| (5) |
JobBuilder に Job 名 を渡す。この文字列が BATCH_JOB_INSTANCE.JOB_NAME に保存され、--spring.batch.job.name=inactiveAccountJob で指定する名前にもなる。適当に付けず、意味のある名前を付けること
|
| (6) | 最初に実行する Step を指定。Step を連結したいときは .next(secondStep) を続けて書く |
| (7) | 組み立て完了。Job のインスタンスが返る |
| (8) |
トランザクションマネージャ。 チャンクごとにコミットするのは誰かというと、こいつです。Spring Boot が自動で用意しているので DI するだけ。Spring Batch 5 から chunk() の引数として必須になった |
| (9) | 3章と6章で作った ItemReader / ItemProcessor / ItemWriter を DI で受け取る |
| (10) |
ここが一番重要な1行。 <Account, MailTarget> は「Account を読んで MailTarget を書く Step です」という型の宣言。1000 が チャンクサイズ(=1000件ごとにコミットする)。旧世代の XML 設定で commit-interval と呼ばれていたものと同じ |
| (11)(12)(13) | 「読む人・加工する人・書く人」を Step に登録する。ここで初めて3つの部品が1本の処理として繋がる |
5-6. 結局データはどう流れているのか
「大きなストリームがあって、要素がくるくる回って処理されているの?」というイメージは、ほぼ合っています。ただし正確には 「押し出す」のではなく「引っ張る」 動きです。
このクラスには for 文も if 文も SQL もありません。 ループを書いているのは Spring Batch 側で、実行時には次のように動きます。
ポイントは3つです。
-
Step が
reader.read()を呼びに行く(Reader が勝手に流し込むのではない) -
read()がnullを返したら「もうデータが無い」の合図。これで Step が終了する - 1000件溜まるごとに write とコミット。だから途中で落ちても、コミット済みの分はDBに残る
.reader() .processor() .writer() と . で繋げて書くのは ビルダーパターン といって、Spring Security の設定などでも見かける書き方です。「設定項目を繋いでいって、最後に .build() で完成品を得る」とだけ理解しておけば十分です。
図にすると、設定クラスがやっているのは「部品をStepに登録し、StepをJobに登録する」という組み立てだけだと分かります。
起動はこれだけです。
java -jar batch-app.jar \
--spring.batch.job.name=inactiveAccountJob \
jobDate=20260805
Spring Boot が JobLauncherApplicationRunner を自動で用意してくれるため、CommandLineJobRunner を明示的に書く必要はありません。これをシェルスクリプトに包んで、スケジューラに登録する——というのが冒頭の「スケジューラに登録して Job をキックする」の正体です。
#!/bin/bash
# run_inactive_account_job.sh
java -jar /opt/app/batch-app.jar \
--spring.batch.job.name=inactiveAccountJob \
jobDate=$(date '+%Y%m%d')
exit $? # ← 終了コードをスケジューラに返すのが重要
6. 【本題】稼働中の Web アプリケーションにバッチを足す
ここからが実務です。次のような状況を想定します。
- 100万アカウントを抱える Spring Boot 製の Web アプリが本番稼働中
- ALB(またはロードバランサ)の配下に アプリインスタンスが複数台
- そこに「毎日夜間に全アカウントの利用状況を集計して、対象者にメールを送る」バッチを追加したい
6-1. 結論:Web アプリと同じプロセスでバッチを動かさない
最初に結論を書きます。バッチは Web アプリとは別のプロセスとして切り出すのが基本 です。
「Web アプリに @Scheduled を足せば済むのでは?」と考えたくなりますが、100万アカウント規模では次の問題が起きます。
インスタンスが 3 台あれば、同じ集計が 3 回同時に走ります。 メールが 3 通届く、集計が 3 重計上される、という事故になります。オートスケールで台数が変動する環境ならなおさら制御不能です。
問題はそれだけではありません。
| 問題 | 100万アカウント規模で何が起きるか |
|---|---|
| 多重実行 | インスタンス数ぶんバッチが走る。二重計上・二重メール送信 |
| リソース競合 | 100万件の読み込みで GC が走り、同居しているユーザーのレスポンスが劣化する |
| コネクション枯渇 | バッチが HikariCP のコネクションを掴み、Web の API が接続待ちになる |
| デプロイの巻き込み | バッチだけ直したいのに Web アプリ全台の再デプロイが必要 |
| 障害の巻き込み | バッチが OOM で落ちると、Web アプリのプロセスごと落ちる |
推奨は、業務 DB は共有したまま プロセスだけを分離 する構成です。
Web アプリは常駐し続け、バッチは必要なときだけ起動して終了する別プロセス。これが Spring Batch が想定している標準構成です。
6-2. 起動パターンの選択肢
とはいえ、実務では要件によって選択肢があります。
| パターンA:別プロセス | パターンB:Web アプリに同居 | パターンC:API から非同期起動 | |
|---|---|---|---|
| 構成 | バッチ用の jar を作り、スケジューラが java -jar
|
Web アプリ内で @Scheduled + JobLauncher
|
管理画面や API 経由で JobLauncher を非同期実行 |
| 起動契機 | cron / JP1 / k8s CronJob | アプリ内タイマー | 人間の操作 |
| 多重実行対策 | スケジューラ側で制御できる | 自前で排他制御が必須 | 実行中チェックが必要 |
| 向いているケース | 定時実行の本命。大量データ | 単一インスタンス・軽量処理のみ | リラン、緊急対応、運用オペレーション |
| 100万アカウントでは | ◎ これを選ぶ | ✕ 避ける | △ パターンA と併用する |
実務では A(定時実行)+ C(管理画面からのリラン用) の組み合わせが多い構成です。B は「1台構成の社内ツール」以外では選びにくいと考えてよいです。
6-3. 最大の地雷:Web アプリを起動するたびにバッチが走る
ここが今回の質問の「アプリケーションプロパティに書くのか?」に直結する部分です。
既存の Web アプリの pom.xml に spring-boot-starter-batch を追加し、Job の Bean を 1 つ定義すると、Spring Boot は「アプリ起動時にその Job を自動実行」します。
これは JobLauncherApplicationRunner という仕組みによる Spring Boot の親切機能で、単体のバッチ jar では便利なのですが、Web アプリに同居させると悲惨なことになります。
- デプロイのたびにバッチが走る
- オートスケールで新しいインスタンスが立ち上がるたびにバッチが走る
- 障害でコンテナが再起動するたびにバッチが走る
100万アカウントの集計がデプロイのたびに走ったら事故です。Web アプリ側では必ず無効化してください。
# Webアプリ側の application.properties
# 起動時にJobが自動実行されるのを止める(同居させるなら必須)
spring.batch.job.enabled=false
# YAML なら
spring:
batch:
job:
enabled: false
逆にバッチ側では、実行したい Job をプロパティで指定します。Job の Bean が複数あるときはこれで選択します。
# バッチプロセス側
spring.batch.job.name=inactiveAccountJob
コマンドラインからも渡せるので、シェルスクリプトから起動する場合はそちらが便利です。
java -jar batch-app.jar --spring.batch.job.name=inactiveAccountJob jobDate=20260805
6-4. ソースは1本、起動時に Web / バッチを切り替える
「Web アプリとバッチでリポジトリを完全に分けると、Entity や Repository が二重管理になる」というのは現実的な悩みです。そこで実務でよく使われるのが、同じ jar を Spring Profile で使い分ける 方法です。
# application.yml(共通)
spring:
batch:
job:
enabled: false # デフォルトでは自動実行しない(Webアプリを守る)
jdbc:
initialize-schema: never # 本番でDDLを自動実行させない(後述)
---
# バッチとして起動するときのプロファイル
spring:
config:
activate:
on-profile: batch
main:
web-application-type: none # Tomcatを起動しない
batch:
job:
enabled: true # このプロファイルのときだけ自動実行を許可
起動コマンドはこうなります。
# Webアプリとして起動(常駐)
java -jar app.jar
# 同じjarをバッチとして起動(処理が終わればプロセス終了)
java -jar app.jar \
--spring.profiles.active=batch \
--spring.batch.job.name=inactiveAccountJob \
jobDate=$(date '+%Y%m%d')
spring.main.web-application-type=none を付けると Tomcat が立ち上がらず、純粋なバッチプロセスとして動きます。ポート競合も起きません。
なお spring.batch.jdbc.initialize-schema は、BATCH_ テーブルを自動生成するかどうかの設定です。
| 値 | 挙動 | 使う場面 |
|---|---|---|
always |
起動のたびに DDL を実行 | ローカル開発 |
embedded |
組み込みDBのときだけ実行(デフォルト) | ローカル開発 |
never |
何もしない | 本番は必ずこれ |
本番の共有 DB に対してアプリが勝手に DDL を打つのは避けるべき です。Spring Batch の JAR 内に schema-*.sql(schema-postgresql.sql など)が同梱されているので、それを Flyway / Liquibase / 手動 DDL で管理してください。
6-5. 100万件を捌くための現実的な設定
稼働中のサービスの裏で 100万件を処理するときに、実際に効いてくるポイントです。
① DB コネクションを Web と食い合わない
バッチ用に別の DataSource とコネクションプールを用意します。バッチが少数のコネクションしか使わないよう明示的に絞るのが安全です。
spring:
datasource:
hikari:
maximum-pool-size: 5 # バッチプロセス側は少なくてよい
② 100万件をメモリに載せない
SELECT の結果を List で全件受け取る実装は即 OOM です。Cursor 系の ItemReader を使い、DB から少しずつ取り出します。
@Bean // (1)
@StepScope // (2)
public MyBatisCursorItemReader<Account> accountReader(
SqlSessionFactory sqlSessionFactory, // (3)
@Value("#{jobParameters['jobDate']}") String jobDate) { // (4)
return new MyBatisCursorItemReaderBuilder<Account>() // (5)
.sqlSessionFactory(sqlSessionFactory) // (6)
.queryId("com.example.mapper.AccountMapper.selectAll") // (7)
.parameterValues(Map.of("targetDate", jobDate)) // (8)
.build(); // (9)
}
| 項番 | 説明 |
|---|---|
| (1) | いつもの Spring と同じ。このメソッドの戻り値を DI コンテナに登録し、Step から .reader(...) で使えるようにする |
| (2) | 後述。ジョブパラメータを受け取るために必要 |
| (3) | MyBatis の設定一式。Spring Boot が自動で作ってくれるので、引数に書けば DI される |
| (4) | 起動時のコマンドライン引数 jobDate=20260805 を受け取っている。#{...} は SpEL(Spring 式言語)で、jobParameters から取り出す書き方 |
| (5) | ここから Builder パターン。new で直接インスタンスを作らず、.xxx() を繋げて設定していく |
| (6) | どの DB 接続を使うかを指定 |
| (7) |
実行する SQL の場所を指定。MyBatis の Mapper XML に書いた <select id="selectAll"> を、パッケージ名込みのフルネームで指す |
| (8) | SQL に渡すパラメータ。Mapper XML 側で #{targetDate} として使える |
| (9) | ここで組み立て完了。ItemReader のインスタンスが返る |
MyBatisCursorItemReader の "Cursor" が重要な部分です。 これは DB のカーソルを使い、SELECT 結果を 1件ずつ順番に取り出す 実装です。100万件が一度にメモリに載ることはありません。対になる MyBatisPagingItemReader は LIMIT / OFFSET でページ単位に分けて何度も SELECT を発行する方式で、件数が多いと後ろのページほど遅くなる傾向があります。大量件数を頭から全部処理するなら Cursor 系 が基本です。
@StepScope とは何か
@StepScope は、「この Bean を、アプリ起動時ではなく Step が始まるときに作ってください」という指定 です。
通常の @Bean は Spring Boot のアプリ起動時に一度だけ作られます(シングルトン)。しかし上のコードは jobDate というジョブパラメータを使っています。ジョブパラメータの値が決まるのは Job が起動された後 なので、アプリ起動時に Bean を作ろうとすると「そんな値まだ無いよ」とエラーになります。
覚え方はシンプルです。
ジョブパラメータを使う Bean には
@StepScopeを付ける。使わないなら不要。
なお @StepScope を付けた Bean を他の @Bean メソッドの引数として受け取ると、Spring がプロキシ経由で解決してくれるため、Step の定義側は特に意識せずに書けます。副次的なメリットとして、Step が終われば破棄されるのでメモリに残り続けません。
③ チャンクサイズは大きすぎても遅くなる
chunk(1000) あたりから始めて実測するのが定石です。大きくするほど I/O 回数は減りますが、その分だけメモリを使い、ロック保持時間が延びて 稼働中の Web アプリ側のクエリを待たせます。1件ずつコミットしていた頃と比べれば 1000 でも十分速いので、いきなり 10万などにしないでください。
④ 読み取りはリードレプリカへ逃がす
「リードレプリカ」という言葉が出てきたので、先に説明します。
リードレプリカ(read replica)とは、本番DB(プライマリ)の内容を自動でコピーし続けている、読み取り専用の複製DB のことです。AWS の RDS でも Aurora でも、ボタン一つで作れます。
なぜこれが必要かというと、100万件を読むような重いSELECTをプライマリDBに投げると、同じDBを使っているユーザーのリクエストが遅くなるから です。DBのCPUやディスクI/Oは有限で、バッチが全力で読み込むとWebアプリの「ログイン」や「商品検索」がその順番待ちになります。
そこで「読むだけ」のバッチはレプリカに向けます。レプリカがどれだけ酷使されても、プライマリは無傷なのでユーザー影響がゼロになります。
ただし注意点が2つあります。
-
レプリカには書き込めません。 集計結果のINSERTや、
BATCH_メタデータの更新はプライマリに向ける必要があります。つまり「読みはレプリカ、書きはプライマリ」という2つのDataSourceを使い分ける構成になり、設計が一段複雑になります。 - レプリカへの反映には遅延があります(レプリケーションラグ、通常は数ミリ秒〜数秒)。「今まさに更新された最新データ」を厳密に読む必要がある処理には向きません。夜間の集計バッチなら問題になりませんが、リアルタイム性が要る処理では要注意です。
初手からここまでやる必要はありません。まずはプライマリDBのまま作り、実測してユーザー影響が出るようなら検討する、という順序で十分です。
⑤ 実行時間帯を意識する
100万件の処理は数十分〜数時間かかることがあります。深夜のアクセスが少ない時間帯に寄せる、Step を分割して並列実行する、といった検討が必要になります。
6-6. 多重起動をどう防ぐか
4章で「同じ Job 名+同じパラメータは 1 回しか正常実行できない」と書きましたが、多重起動対策としてこれに頼りきるのは危険 です。挙動を整理します。
| 状況 | Spring Batch の挙動 |
|---|---|
| 同じパラメータで、前回が正常終了済み |
JobInstanceAlreadyCompleteException で弾かれる |
| 同じパラメータで、前回が実行中 |
JobExecutionAlreadyRunningException で弾かれる |
パラメータが毎回違う(タイムスタンプ、RunIdIncrementer など) |
別の JobInstance と判定され、何重でも起動できてしまう |
つまり 「開発中は便利だからタイムスタンプをパラメータに入れる」をそのまま本番に持ち込むと、多重起動のガードが完全に外れます。 本番では業務日付(jobDate=20260805)のような、その日に 1 回しか成立しない値を渡してください。「同じ日付では二度と流せない」こと自体が安全装置になります。
その上で、スケジューラ側でも排他をかけます。
-
k8s CronJob:
concurrencyPolicy: Forbidを指定すると、前回の Job が実行中なら次回をスキップする - JP1 / Autosys など:同一ジョブネットの多重起動禁止設定を使う
- パターンB(Web アプリ同居)をどうしても選ぶ場合:ShedLock などで DB や Redis を使ったロックを取り、複数インスタンスのうち1台だけが実行するようにする
6-7. 移行の進め方(おすすめの順序)
いきなり本番の 100万件に手を出さないのが安全です。
-
まず Step 1 個・タスクレットモデルで「ログを1行出すだけの Job」を作り、
java -jarで起動する(Spring Batch の起動と終了コードの感覚をつかむ) -
BATCH_テーブルを開発環境の DB に作り、実行後にレコードを SELECT して眺める(JobInstance と JobExecution の関係が体感で分かります) - 同じコマンドを 2 回叩いて
JobInstanceAlreadyCompleteExceptionを出す(本番で驚かないため) - チャンクモデルに置き換え、件数を 1万 → 10万 → 100万 と増やして実行時間とメモリを実測する
- スケジューラに登録し、異常終了時に終了コードが 1 で返ることを確認する
特に 5 は必ず確認してください。 スケジューラは終了コードしか見ません。Job が失敗しているのに終了コード 0 で返っていると、障害が誰にも気づかれないまま毎晩流れ続けます。 Spring Boot なら JobExecutionExitCodeGenerator が自動設定されるため通常は問題ありませんが、独自に try-catch で握りつぶしていないか、テスト環境でわざと失敗させて確かめておく価値があります。
7. バージョンの注意(ここは必ず確認する)
Spring Batch は世代によって API がかなり変わっています。ネットの記事をコピペして動かない原因はだいたいこれです。
| 世代 | 特徴 |
|---|---|
| Spring Batch 4 以前 |
JobBuilderFactory / StepBuilderFactory を DI して使う。CommandLineJobRunner が現役 |
| Spring Batch 5(Spring Boot 3 系) | 上記 Factory が非推奨に。new JobBuilder(name, jobRepository) を使う。chunk() に PlatformTransactionManager が必須 |
| Spring Batch 6 |
CommandLineJobRunner が非推奨、後継の CommandLineJobOperator が登場。@EnableBatchProcessing が JDBC 前提でなくなり、@EnableJdbcJobRepository などが追加 |
TERASOLUNA Batch 5.x のガイドラインを読むときの注意
TERASOLUNA のガイドラインは XML によるジョブ定義や CommandLineJobRunner を前提に書かれた記述が多く、ベースにしている Spring Batch も現行より古い世代です。アーキテクチャの解説(Job / Step / チャンク / JobRepository の考え方)は今でもそのまま有用ですが、コードの書き方はプロジェクトが使っているバージョンの公式リファレンスで必ず確認してください。
まずは pom.xml / build.gradle の Spring Boot・Spring Batch のバージョンを確認するところから始めるのが確実です。
8. つまずきポイントまとめ
Spring Batch そのものについて
-
Spring Batch にスケジューラ機能はない。cron や JP1 が
javaコマンドを叩くだけ - プロセスの終了コードがすべて。スケジューラはそれしか見ない
- 同じ Job 名 + 同じパラメータは 1 回しか正常実行できない。日付などでユニークにする
-
BATCH_ テーブルは再実行のために必須。DDL は Spring Batch の JAR 内に
schema-*.sqlとして同梱されている - チャンクモデルでは write だけ呼び出し回数が違う(read/process は N 回、write は 1 回)
- 自分で書くのはほぼ ItemProcessor だけ。Reader / Writer は既製品でだいたい足りる
- 性能が出ないときの調整ポイントはチャンクサイズ、フェッチサイズ、並列化。ただし大きくしすぎると DB 側の負荷で逆効果
稼働中の Web アプリに足すときについて
- バッチは Web アプリと別プロセスにする。同居させると多重実行・リソース競合・デプロイの巻き込みが起きる
-
Web アプリ側では
spring.batch.job.enabled=falseを必ず入れる。入れないとデプロイやオートスケールのたびにバッチが走る -
本番では
spring.batch.jdbc.initialize-schema=never。DDL はアプリではなくマイグレーションツールで管理する -
バッチ起動時は
spring.main.web-application-type=noneで Tomcat を立ち上げない - ジョブパラメータにタイムスタンプを使うと多重起動のガードが外れる。本番は業務日付を渡す
- バッチ用のコネクションプールは小さく絞る。Web 側の API を接続待ちにさせない
- 異常終了時に終了コード 1 が返ることをテスト環境で確認する。スケジューラはそれしか見ていない
おわりに
Spring Batch は覚えることが多く見えますが、Spring Boot 経験者にとっての新規要素は実質、
- Job / Step という処理の入れ物
- JobRepository による実行状態の DB 管理
の 2 つだけです。DI もトランザクション管理も、いつもの Spring がそのまま土台になっています。
そして、稼働中の Web アプリにバッチを足すときに本当に難しいのは Spring Batch の書き方ではなく、「どのプロセスで、誰が、いつ起動するか」というアプリケーション構成の設計 のほうです。
- バッチは Web アプリと別プロセスにする
- 起動は外部のスケジューラに任せる
- Web アプリ側では
spring.batch.job.enabled=false - ジョブパラメータには業務日付を渡す
この 4 点を最初に決めてしまえば、あとは ItemProcessor にビジネスロジックを書くだけの、いつもの Spring の世界です。
まずは Step 1 個・タスクレットモデルで「ログを 1 行出すだけの Job」を作って、java -jar で 2 回叩いて JobInstanceAlreadyCompleteException を体験してみるのが、一番の近道だと思います。
