Javaでバッチ処理を作るだけなら、mainメソッドを1つ書いてcronで呼べば足ります。
難しいのは動かすことではなく、止まったあとの始末です。
夜間に走らせた処理が途中で止まり、再実行すると二重登録になりそうで手が出せない。この状況を作らない設計が、バッチ開発の本題になります。
この記事では、Javaでバッチを作る3つの方法と選び方、手元で実行して確かめたサンプル、再実行に強い設計のポイント5つをまとめました。
Spring Batchの中身は別記事に任せ、ここは「何で作るか」を決める入口に絞っています。
Java 23.0.1で動作を確認しています(2026年10月)。
この記事でわかること
- バッチ処理の定義と、リアルタイム処理との違い
- Javaでバッチを作る3つの方法と、規模別の選び方
- mainメソッドだけで作る日次集計バッチ(実行結果つき)
- 冪等性・再実行・トランザクション・ログ・失敗時の備えという設計ポイント5つ
PARTS LIST /目次
バッチ処理とは?リアルタイム処理との違い
バッチ処理とは、まとめたデータを人の操作なしに一括で処理する方式です。
夜間の売上集計、月末の請求書作成、他システムへのデータ移行などが典型で、Javaに限らず業務システムの裏側で広く使われています。
設計の考え方は言語を問わず共通なので、後半の設計ポイントは他の言語でもそのまま使えます。
バッチ処理が向いている処理
バッチ処理が向くのは、結果がすぐに要らず、件数が多く、正確さが問われる処理です。
リアルタイム処理との違いを表にまとめます。
リアルタイム処理は「失敗を利用者に見せる」処理、バッチ処理は「失敗を後から取り戻す」処理です。取り戻せる設計になっているかどうかが、バッチの出来を決めます。
バッチファイル(.bat)とは別物
バッチファイルは、Windowsで拡張子 .bat のコマンドを順に実行するファイルです。
バッチ処理は処理の方式を指す言葉なので、別の概念です。
.bat でJavaプログラムを起動して定期実行することはできますが、それはバッチ処理を実現する手段の1つにすぎません。
Javaでバッチ処理を作る3つの方法
Javaでバッチを作る方法は、処理を起動する主体で3つに分けると選びやすくなります。
OSのスケジューラが起動するのか、常駐するJavaアプリが自分で起動するのか、フレームワークが実行管理まで担うのか、という違いです。
次の図は、失敗時の再開が必要か、常駐アプリの中で動かすか、という順に判断する流れをまとめたものです。
3つの方法を並べると、次のようになります。
方法1:mainメソッド+OSのスケジューラ
mainメソッドに処理を書き、cron(Linux・macOS)、launchd(macOS)、タスクスケジューラ(Windows)から起動する方法です。
最も単純で、処理が終わればプロセスごと消えるため、メモリの掃除や常駐の管理を考えなくて済みます。
小さな定期処理なら、この形で足ります。複雑さが必要になった時点で、方法2か3へ乗り換えれば間に合います。
弱点は、再実行や失敗時の通知をすべて自分で書く点です。この記事の後半で扱う設計ポイントは、主にこの弱点を埋める話になります。
方法2:アプリ内で動かすスケジューラ
Webアプリのように常駐するJavaプロセスが、自分で処理を起動する方法です。
JDK標準の ScheduledExecutorService なら、追加ライブラリなしで一定間隔の実行ができます。
毎月最終金曜の朝のように暦に沿って指定したい場合や、祝日を除外したい場合、ジョブの状態をDBに保存したい場合は、Quartzの使い方で扱っているQuartzが候補です。
ScheduledExecutorService には、知らないと痛い仕様があります。
Javadocには、実行中のタスクが例外を出すと以降の実行は抑止される、と書かれています。
実際に、2回目だけ例外を投げるタスクで確かめました。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class SchedulerDemo {
public static void main(String[] args) throws Exception {
AtomicInteger count = new AtomicInteger();
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleWithFixedDelay(() -> {
int n = count.incrementAndGet();
try {
System.out.println(n + "回目を実行");
if (n == 2) {
throw new IllegalStateException("2回目だけ失敗");
}
} catch (Exception e) {
// 握りつぶさず記録し、次回の実行は止めない
System.err.println("失敗: " + e.getMessage());
}
}, 0, 200, TimeUnit.MILLISECONDS);
Thread.sleep(1000);
scheduler.shutdown();
scheduler.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("合計 " + count.get() + " 回");
}
}
1秒間に200ミリ秒間隔で動かした結果は、try-catchありで5回、なしだと2回で止まりました。
しかも、なしの場合はスタックトレースも出ず、プロセスは正常終了します。
「気づいたら定期実行が止まっていた」の原因として、最も静かなパターンです。タスクの最外周は、必ず try-catch で囲んでください。
実行確認のメモ
Java 23.0.1で java SchedulerDemo.java として実行しました。scheduleAtFixedRate でも、Javadocの記述は同じです。
方法3:バッチフレームワーク(Spring Batch・jBatch)
大量データを一定件数ごとにコミットしたい、失敗したら途中から再開したい、という要件が出たら、フレームワークの出番です。
代表は Spring Batch で、実行履歴をDBに保存して再実行を管理する仕組みを標準で持っています。
Jakarta EEのアプリケーションサーバー上で動かすなら、標準仕様の Jakarta Batch(jBatch) が選択肢に入ります。
Job・Step・Chunkの構造、Spring Boot 3・4での設定、リトライとスキップの書き方は、Spring Batchとは?Bootとの違いにまとめています。
この記事では深入りしません。「フレームワークに頼る前に、自作でどこまで行けるか」を測る材料を、次の章から出していきます。
mainメソッドで作る最小のJavaバッチ
方法1の実物を作ります。入力は日付つきの売上CSV、出力は商品コード別の合計CSVです。
作りは単純でも、あとで効く3点を最初から入れました。日付を引数で受けること、結果を一時ファイル経由で置き換えること、終了コードで結果を伝えることです。
サンプルコード:日次売上の集計
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** 指定日の売上CSVを商品コード別に集計して書き出すバッチ。 */
public class DailySalesBatch {
public static void main(String[] args) {
if (args.length != 1) {
System.err.println("使い方: java DailySalesBatch.java 2026-10-04");
System.exit(64);
}
LocalDate target = LocalDate.parse(args[0]);
Path in = Path.of("sales_" + target + ".csv");
Path out = Path.of("summary_" + target + ".csv");
Path tmp = Path.of("summary_" + target + ".csv.tmp");
try {
Map<String, Long> totals = new TreeMap<>();
int bad = 0;
int lineNo = 0;
// 1行ずつ読むので、入力が大きくてもメモリを食わない
try (BufferedReader reader = Files.newBufferedReader(in, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
lineNo++;
String[] cols = line.split(",");
try {
totals.merge(cols[0], Long.parseLong(cols[1].trim()), Long::sum);
} catch (RuntimeException e) {
bad++;
System.err.println("WARN " + lineNo + "行目をスキップ: " + line);
}
}
}
// 一時ファイルに書いてから置き換える。途中で落ちても out は壊れない
StringBuilder sb = new StringBuilder();
totals.forEach((k, v) -> sb.append(k).append(',').append(v).append('\n'));
Files.writeString(tmp, sb.toString(), StandardCharsets.UTF_8);
Files.move(tmp, out, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
System.out.println("INFO 集計完了 商品数=" + totals.size() + " スキップ=" + bad);
System.exit(bad > 0 ? 1 : 0);
} catch (IOException e) {
System.err.println("ERROR 入出力に失敗: " + e);
System.exit(2);
}
}
}
JDK 11以降なら、コンパイルなしで java DailySalesBatch.java 2026-10-04 と実行できます。
本番ではGradleかMavenでJARにして java -jar で起動する形が一般的ですが、動作の確認にはソース直接実行が手軽です。
実行結果と終了コードの見方
次の5行を sales_2026-10-04.csv に置いて実行しました。4行目は金額が文字、5行目は列が足りない不正行です。
A001,1200
A002,800
A001,300
A003,abc
A002
結果は、不正行を警告つきでスキップしたうえで、有効な3行を集計して終了コード1で終わりました。
WARN 4行目をスキップ: A003,abc
WARN 5行目をスキップ: A002
INFO 集計完了 商品数=2 スキップ=2
出力の summary_2026-10-04.csv は A001,1500 と A002,800 の2行です。
同じ入力でもう一度実行し、出力ファイルのmd5を比べると、1回目と2回目で一致しました。再実行しても結果が変わらない、というのが次章の冪等性です。
終了コードは4種類に分けています。
64は、UNIX系で「使い方の誤り」に使う慣習の番号です。
入力ファイルがない2026-10-05を指定すると、NoSuchFileException を出して終了コード2になりました。
cron・launchd・タスクスケジューラで定期実行する
起動はOSに任せます。Linuxのcronなら、毎日午前2時30分に前日分を集計する設定は次のとおりです。
30 2 * * * cd /opt/batch && java DailySalesBatch.java $(date -d yesterday +\%F) >> /var/log/batch/daily_sales.log 2>&1
date -d yesterday はGNU版dateの書き方で、Linuxで使えます。crontabでは % を \% と書く決まりです。
macOSで同じことをするなら、launchdで定期実行する手順が参考になります。私が個人のスクリプトを回すときも、macOSではlaunchdを使っています。
cronで初めて動かすと、手元で動いたはずの処理が動かない場面によく出会います。理由はたいてい次のどれかです。
- cronの環境変数は最小限で、
PATHにjavaが含まれていない場合がある(フルパスで書く) - 作業ディレクトリが想定と違う(
cdで移動するか、絶対パスで書く) - 標準出力と標準エラーを捨てている(
>> ログ 2>&1で残す) - 前回の実行が終わる前に次が始まる(ロックファイルや、Linuxなら
flockで防ぐ)
Windowsのタスクスケジューラも考え方は同じで、実行ファイルのフルパスと「開始」ディレクトリを明示します。
失敗しにくいバッチの設計ポイント5つ
ここからは、言語を問わないバッチ設計の要点を5つ見ていきます。
先に挙げた3つの方法のどれを選んでも、この5点は自分で決める必要があります。方法3のフレームワークを使えば、一部は機能として肩代わりしてくれるだけです。
冪等性:再実行しても結果が同じになる
冪等性とは、同じ処理を何回実行しても結果が同じになる性質です。
バッチは必ず一度は失敗するので、冪等でなければ「再実行が怖い」状態になります。
冪等にする方法は、出力先によって選びます。
もう1つ、冪等の前提になる習慣があります。サンプルのように、対象日を LocalDate.now() ではなく引数で受けることです。
現在日付で動くバッチは、3日前の分だけを作り直したくなったときに手の打ちようがありません。
再実行:どこからやり直すかを決めておく
失敗したとき、やり直しの範囲は3段階で考えられます。
自作するなら、上から順に試してください。
- 全部やり直す(冪等なら最も安全で、実装も最小)
- 対象期間やID範囲を引数で指定して、そこだけやり直す
- 処理済みの位置を保存しておき、続きから再開する
3は実装が一気に重くなります。位置の保存とデータの書き込みが別々だと、保存だけ成功して書き込みが失敗する、といった食い違いが生まれるためです。
ここまで必要になった時点が、Spring Batchのようなフレームワークに乗り換える合図です。実行履歴をDBに持って、続きから再開する機能が標準で用意されています。
トランザクション:コミットの単位を決める
コミットの単位は、次の3通りから選びます。
大量データでは、チャンクが現実的な落としどころです。JDBCで1000件ごとにコミットする骨格は次のとおりです。
static void insertInChunks(Connection con, List<Sale> sales) throws SQLException {
final int chunkSize = 1000;
String sql = "INSERT INTO daily_sales (sales_date, product_code, amount) VALUES (?, ?, ?)";
con.setAutoCommit(false);
try (PreparedStatement ps = con.prepareStatement(sql)) {
int inChunk = 0;
for (Sale s : sales) {
ps.setDate(1, Date.valueOf(s.date()));
ps.setString(2, s.code());
ps.setLong(3, s.amount());
ps.addBatch();
if (++inChunk == chunkSize) {
ps.executeBatch();
con.commit();
inChunk = 0;
}
}
if (inChunk > 0) {
ps.executeBatch();
con.commit();
}
} catch (SQLException e) {
con.rollback();
throw e;
}
}
Sale は日付・商品コード・金額を持つ record です。接続先のDB用ドライバを用意すれば、そのまま呼べます。
失敗時に巻き戻るのは直近のチャンクだけで、それ以前の分はコミット済みで残ります。だから、チャンク方式は前節の冪等性とセットで使います。
コミットとロールバックの仕組み自体は、Javaトランザクションの解説を参照してください。
ログ:何件目のどのデータかを残す
バッチのログは、あとから再実行の判断をするための材料です。
開始と終了の時刻、読み込んだ件数、書き込んだ件数、スキップした件数の4つは、最低限そろえておきます。
サンプルでも、不正行は「4行目をスキップ: A003,abc」と、位置と中身が分かる形で出しました。
新人のころは、原因調査のために System.out.println を埋め込みながら動かしていました。
本番で原因不明のエラーが出たとき、残っているログから分かるのは「落ちた」ことだけで、特定に何日もかかりました。
バッチは夜間に無人で動くので、その場で調べ直せません。落ちた時点の入力と位置が、ログに残っているかどうかですべてが決まります。
実務では System.out ではなく、ログレベルとファイル出力を管理できるロギングライブラリを使います。設定はlogback.xmlの書き方にまとめています。
ログに出してはいけないものもあります。
- パスワード・トークン・接続文字列
- 氏名・住所・電話番号などの個人情報(代わりにIDを出す)
- 1件ごとの全項目ダンプ(件数が多いと、ログ自体が先に溢れる)
失敗時のリカバリ:検知・通知・リトライ
失敗への備えは、検知・通知・復旧の3段で考えます。
- 検知:終了コードが0以外なら失敗とみなす。方法2では、例外を握ってログに出す
- 通知:失敗がメールやチャットに届くようにする。cronなら
MAILTOや、ラッパースクリプトからのWebhook通知 - 復旧:原因を直して再実行する。一時的な障害だけは自動リトライする
リトライは、効く場面が限られます。
DB接続のタイムアウトや外部APIの一時的な503のように、時間を置けば直る障害には有効です。
一方、不正データやプログラムの不具合は、何度試しても結果が変わりません。リトライを3回、間隔を数秒から数分と決めて、それでも失敗したら人に知らせて止めるのが基本です。
最後に、実行が長引いたときの備えです。
前回の実行が終わらないまま次の起動時刻が来ると、同じ入力を2つのプロセスが同時に処理します。多重起動の防止(ロックファイル、flock、DBの排他制御)と、実行時間の上限の監視は、無人運用では欠かせません。
どの方法を選ぶか:使い分けの目安
最初は方法1で作り、困ったら上に上がる、で十分です。
それぞれの乗り換えの合図を表にしました。
数百件から数万件を、夜に1回まとめて処理する程度なら、方法1と冪等な設計で足ります。
フレームワークは、再開位置の管理を自作するのが割に合わなくなったときの道具です。入れるほど安心、という種類のものではありません。
よくある質問
Javaのバッチ処理について、よく出る質問をまとめました。
Q1. Javaでバッチ処理を作る最短の方法は?
A1. mainメソッドに処理を書き、cronやタスクスケジューラから起動する方法が最短です。JDK 11以降ならソースを直接実行できるので、まず java Batch.java で動かして確かめます。
Q2. バッチ処理とバッチファイル(.bat)は同じですか?
A2. 別物です。バッチ処理は一括処理の方式で、バッチファイルはWindowsのコマンドを並べた実行ファイルです。.bat はバッチ処理を起動する手段の1つにすぎません。
Q3. Spring Batchは小規模でも使うべきですか?
A3. 数百件から数万件を1日1回処理する程度なら、必須ではありません。途中再開や件数ごとのコミットを自作するのが重くなったときに検討します。使い方はSpring Batchの解説記事にあります。
Q4. ScheduledExecutorServiceで例外が出るとどうなりますか?
A4. 以降の実行が抑止され、定期実行が止まります。Javadocにも書かれている仕様で、Java 23.0.1で確認しました。タスクの最外周を try-catch で囲み、例外をログに残してください。
Q5. バッチ処理が失敗したときの対処は?
A5. ログで失敗した入力と位置を特定し、一時的な障害ならリトライ、データ不正なら修正してから再実行します。冪等に作ってあれば、再実行で二重登録になる心配はありません。
Q6. バッチ処理のメモリ不足はどう防ぎますか?
A6. 全件をListに読み込まず、1行ずつ、または一定件数ずつ処理します。ファイルなら BufferedReader、DBならカーソルやページングを使い、件数ごとにコミットします。
Q7. バッチの開発で最初に決めることは?
A7. 入力、出力、起動のきっかけ、失敗したときの扱いの4つです。とくに「失敗したらどうやり直すか」を先に決めておくと、冪等性や引数の設計が自然に決まります。
まとめ:まず方法1で作り、再実行の設計を先に決める
Javaのバッチ処理は、mainメソッドとOSのスケジューラで作り始められます。
- 方法は3つ。main+OSスケジューラ、アプリ内スケジューラ、バッチフレームワーク
- 選ぶ基準は、途中再開と件数ごとのコミットが要るかどうか
- 設計の核は、冪等性・再実行の範囲・コミット単位・ログ・失敗時の備えの5つ
ScheduledExecutorServiceは、例外で黙って止まる。最外周にtry-catchを置く
コードを書く前に、「失敗したらどうやり直すか」を1行で書いてみてください。それが決まれば、使う道具は自然に絞れます。
公式の情報は、ScheduledExecutorServiceのJavadocと、Jakarta Batchの仕様ページで確認できます。
| REV. | DATE | 内容 |
|---|---|---|
| 5 | 2026-10-19 | 同じ説明の繰り返しを減らし、読みやすく整えた |
| 4 | 2026-10-15 | 図解を描き直し(図面のデザインにそろえた) |
| 3 | 2026-08-18 | 2026年8月時点の情報で全体を見直し(古い記述を3件更新) |
| 2 | 2026-05-09 | 2026年5月時点の情報で全体を見直し(古い記述を3件更新) |
| 1 | 2025-10-05 | 初版 |

