Spring Bootの定期処理で二重実行を防ぐ方法|単体・複数台構成の対策

Spring Bootの定期処理で二重実行を防ぐ方法|単体・複数台構成の対策

Spring Bootで定期処理を動かしていると、同じ更新や通知が二度行われることがあります。原因は、処理時間が長いことだけとは限りません。複数インスタンスの起動、非同期処理、手動実行、途中失敗後の再試行など、同じ業務処理へ入る経路を確認する必要があります。

対策の基本は、ジョブを同時に動かさないための排他制御と、同じデータを再処理しても結果を重複させない冪等性を分けて設計することです。この記事では、@Scheduledの確認から、単一JVMと複数台構成での対策、共有DBを使うロック、障害後の復旧までを整理します。

スケジューラーやロックの詳細は、採用するJava・Spring Boot・DB・ライブラリのバージョンに応じて確認してください。導入時は、実際の構成でロックの適用範囲と競合時の動作を確かめます。

目次

Spring Bootの定期処理で防ぎたい二重実行を整理する

同じジョブの同時実行と同じデータの再処理を分ける

「二重実行」には、少なくとも二つの問題があります。一つは、同じジョブが重なって動くことです。たとえば、二つのプロセスが同時に未処理の注文を読み取り、それぞれ更新を始めるケースです。

もう一つは、時間をずらして同じ業務データを再処理することです。通知の送信後、送信済み状態を保存する前に停止すると、次の実行で同じ通知を送る可能性があります。このケースでは、ジョブが同時に動いていなくても重複が発生します。

ロックは同時実行を抑える仕組みです。一方、再処理による重複には、業務キー、一意制約、処理状態、外部サービスの冪等性キーなどを組み合わせます。ロックを取得できたことだけで、業務結果が一度だけ反映されるとは限りません。

単一JVM・複数インスタンス・手動実行の経路を確認する

まず、同じ処理を呼び出せる場所を洗い出します。定期実行用のメソッドだけを見ても、管理画面からの再実行や別プロセスからの起動があれば、競合する経路を把握できません。

  • 同じJVM内に、同じ業務処理を呼ぶスケジュールが複数ないか。
  • 同じアプリが、別サーバー・コンテナ・プロセスで動いていないか。
  • 管理画面、API、コマンドなどから手動実行できないか。
  • 失敗時のリトライやデプロイ中の旧プロセスが、同じデータを処理しないか。

起動経路が複数ある場合は、共通の実行サービスへ集約し、その入口で排他制御を適用すると確認しやすくなります。手動実行だけがロックを通らない構成では、定期実行側の対策を迂回してしまいます。

@Scheduledの設定と二重起動の原因を確認する

fixedDelay・fixedRate・cronと実行方式を確認する

fixedDelayは前回の実行完了から次の開始までの間隔、fixedRateは一定周期での実行、cronはカレンダー上の時刻条件を指定するために使います。ただし、指定した時刻と実際の開始時刻は、スケジューラーの空き状況や処理時間によってずれる場合があります。

fixedRateだから、処理時間が周期を超えると必ず同じタスクが並列実行される、という理解は適切ではありません。通常のScheduledThreadPoolExecutorで一つの周期タスクとして登録された処理は、そのタスク自身の実行が重ならない性質を持ちます。実際には、使われているスケジューラー、タスクの登録数、非同期処理の有無を合わせて確認します。

また、メソッドが別スレッドへ処理を渡してすぐ戻る場合、スケジューラーが認識する完了と業務処理の完了は一致しません。fixedDelayでも、引き渡した業務処理が終わるまで待つ保証にはならないため、何の完了を基準にしているかが重要です。ここでは通常の同期メソッドを中心に考え、リアクティブな定期処理などは別途仕様を確認します。

スケジューラー・@Async・Beanの重複登録を調べる

確認するのは、設定ファイルのスレッド数だけではありません。実際に使用されるTaskScheduler、独自のスケジューラー設定、@Asyncが有効になる呼び出し経路を調べます。スレッド数を増やすと別のタスクを並行して処理できる場合がありますが、それだけで同一タスクの重複を断定できません。

@Asyncが適用されると、呼び出し側が戻った後も別スレッドで処理が続く構成になります。ロックを取得したメソッドが非同期処理を起動するだけで終了すると、業務処理の完了前にロックが解放されるおそれがあります。ロックの保護範囲は、実際の更新が終わるまで含めて設計します。

同じ処理を持つBeanが複数登録されていないか、複数のアプリケーションコンテキストでスケジュールが有効になっていないかも確認します。同じメソッドへの複数のスケジュール指定や、別メソッドから同じサービスを呼ぶ構成も確認対象です。ログにはインスタンス識別子と実行IDを付け、どの起動経路から始まった処理か追えるようにします。

複数台起動やデプロイ時の新旧プロセスを確認する

各インスタンスでスケジューリングを有効にすると、それぞれが自分のスケジュールに従って処理を起動します。@Scheduledだけでは、複数インスタンス全体で実行担当を一つに決める仕組みにはなりません。

通常運用が一台でも、ローリングデプロイなどでは新旧プロセスが一時的に共存します。旧プロセスが停止するまで業務処理を続けるなら、その期間も複数台構成として考えます。ロック名や取得方式を変更するデプロイでは、新旧双方が同じ排他制御に参加できることも必要です。

構成に合わせて二重実行の防止方法を選ぶ

単一JVM内の排他制御と適用範囲を整理する

同じJVM内だけの競合であれば、共有するオブジェクトのsynchronized、ReentrantLock、原子的なフラグなどが候補になります。重要なのは、競合するすべての呼び出しが同じロックを使うことです。呼び出しごとに新しいロックを作っても、互いの実行を制限できません。

次は、同じインスタンスを共有し、処理を同期的に完了させる場合の説明例です。実行中なら待たずにスキップし、戻り値で呼び出し側へ伝えます。

import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;

public final class LocalJobRunner {
    private final AtomicBoolean running = new AtomicBoolean(false);

    public boolean runIfIdle(Runnable job) {
        Objects.requireNonNull(job, "job");

        if (!running.compareAndSet(false, true)) {
            return false;
        }

        try {
            job.run();
            return true;
        } finally {
            running.set(false);
        }
    }
}

compareAndSetで、未実行かどうかの判定と実行中への変更を一つの原子的な操作にしています。単にフラグを読み、その後で書き換える実装では、複数の呼び出しが同時に未実行と判断する余地があります。

このクラスはSpringへの登録を含まない例です。利用する場合は、同じジョブの定期実行と手動実行が、同じインスタンスを共有するように組み込みます。別JVMの処理や再起動後の再処理には効かず、job.run()が非同期処理を開始するだけの場合も保護範囲が不足します。

複数インスタンスで共有するロックを検討する

複数インスタンスでは、各プロセスのメモリー外にある共有先で実行権を管理します。共有DBのロック用テーブルを使う方法は、その候補です。全インスタンスが同じ共有先と同じロック名を使い、取得判定を原子的に行えることが前提になります。

「ロック行がないことをSELECTで確認してからINSERTする」だけでは、確認と作成の間に競合します。自作する場合は一意制約や条件付き更新に加え、期限、所有者、解放時の競合まで設計が必要です。既存ライブラリを使う場合も、それが保証する範囲と期限切れ時の動作を確認します。

ロック用テーブルで期限を管理する方式と、DBの行ロックをトランザクション終了まで保持する方式は異なります。長時間ジョブ全体をDBトランザクションで囲むと、接続や行ロックの保持が長くなるため、業務データの更新単位とジョブの実行権を分けて検討します。

競合時にスキップするか待機するかを決める

定期的に未処理データを探すジョブでは、別の実行が動いていればスキップし、次回に残りを処理する設計が考えられます。ただし、毎回の実行が特定期間の処理を担うなら、その回を飛ばしても取りこぼさない仕組みが必要です。

すべての実行要求を残したい場合は、単純なスキップだけでは要件を満たせません。永続的な実行要求やキュー、処理対象期間の記録などを検討します。待機する方式を選ぶ場合も、待機時間の上限と、要求がたまり続けた場合の扱いを決めます。

手動実行では「実行中のため受付しなかった」と「受け付けて待機中」を区別して返します。また、ロック取得の通信エラーを、正常な競合スキップとして記録しないようにします。取得できたか不明な場合に、そのまま業務処理を進める構成は避けます。

共有DBを使う排他制御の実装を組み立てる

ShedLockを使う構成案と導入条件を確認する

ShedLockは、定期処理の同時実行を抑えるための候補です。共有DBを使う構成では、Springとの連携、JDBCなどのロックプロバイダー、ロック情報を保存するテーブルを組み合わせます。ShedLock自体を、実行要求をすべて保存して順番に実行するキューとして扱うことはできません。

採用前には、Java・Spring Bootとの互換性、対応DB、必要な依存関係、テーブル定義、時刻の扱いを確認します。DB時刻を使う構成が利用できる場合は、プロセス間の時計のずれを抑えるために検討します。ただし、それだけで期限超過による競合まで防げるわけではありません。

導入時は、採用するバージョンとDBに合わせて依存関係やDDL、アノテーションの設定を選びます。対応するロックプロバイダーを設定し、スケジューリングとロック連携を有効にしてから、実際の呼び出しでロックが適用されることを確認します。

ロック名・共有先・取得から解放までの流れをそろえる

ロック名は、排他したい業務処理の単位を表します。たとえばorder-settlementという名前を使うなら、その注文確定処理を実行する各インスタンスが同じ名前を使います。インスタンスIDを名前に含めると別々のロックになり、全体の排他ができなくなります。

反対に、独立して動いてよいジョブまで同じ名前にすると、必要以上に処理を制限します。環境やテナントで名前を分ける場合は、処理対象データも独立しているかを確認します。同じ本番データを扱う新旧プロセスは、同じ実行権を競う必要があります。

基本の実行順序は次のとおりです。

  1. 定期実行または手動実行から、共通の入口を呼ぶ。
  2. 共有先で対象ジョブのロック取得を試みる。
  3. 取得できた場合だけ業務処理へ進み、競合時は決めた方針でスキップする。
  4. 業務更新のコミットを含め、保護すべき処理の完了までロックを保持する。
  5. 結果を記録し、ロックプロバイダーの手順で解放する。

Springのプロキシによる連携を利用する場合は、同じクラス内の直接呼び出しなどでロック処理を迂回しないか確認します。別のBeanに実行入口をまとめる構成は、その境界を明確にする方法です。ロックとトランザクションの適用順序も、実際の構成で確かめます。

ロックの有効期限と処理時間超過への対処を設計する

期限付きロックでは、プロセスが停止して解放できなくても、期限後に別プロセスが実行権を取得できるようにします。ShedLockのlockAtMostForは、この最大保持期限を考えるための設定です。処理を強制終了するタイムアウトではありません。

ロック期限を過ぎても、元の業務処理が止まるとは限りません。元の処理が続いたまま別プロセスがロックを取得すると、同時実行が起こり得ます。ロック期限は、処理の停止や業務結果の一度限りの反映を保証するものではありません。

lockAtLeastForは、処理が短時間で終わった場合も一定期間ロックを保持するための設定です。ただし、対象日などの業務キーに基づいて「その回は完了済み」と記録する仕組みではありません。期限を過ぎた後の再処理は、別途制御する必要があります。

保持期限は、外部通信のタイムアウト、DB待ち、処理件数などを含めて設計します。長くすると停止後の引き継ぎが遅れ、短くすると実行中に期限切れになる可能性が高まります。処理件数を区切る、外部呼び出しに上限を設ける、対応する構成ではロック延長を検討するなど、業務処理の長さも制御します。

延長に失敗した場合や、停止していたプロセスが後から再開する場合も想定します。古い実行者の書き込みを拒否する必要があるなら、世代番号を更新先で検証するフェンシングなど、追加の仕組みが検討対象です。採用したロックがその保証まで提供しているかを確認し、提供しない場合は業務データ側の競合制御と冪等性で備えます。

トランザクションと冪等性で重複更新に備える

ジョブの排他制御と@Transactionalの役割を分ける

ジョブのロックは、同じ処理へ同時に入る実行者を制限します。@Transactionalは、DB更新をどの範囲でコミットまたはロールバックするかを定めます。アノテーションを付けるだけで、複数インスタンスのジョブ起動が一つに集約されるわけではありません。

たとえば、二つのトランザクションがそれぞれ同じ未処理データを読み取る場合、両方が処理を進める余地があります。必要に応じて条件付き更新、一意制約、楽観ロック、行ロックなどを選び、DBの分離レベルを含めて競合時の動作を決めます。

大量データを扱うジョブでは、一件または一定件数ごとにトランザクションを区切る構成も考えられます。その場合、途中までコミットされた状態から再開できるようにします。@Transactionalの適用条件やロールバックの基礎は、次の記事で補足しています。

業務キー・一意制約・処理状態で再処理を扱う

冪等性は、同じ処理要求を繰り返しても、意図する結果が重複して増えない性質です。まず「同じ処理」を識別するキーを決めます。月次集計なら対象月と集計対象ID、注文の確定なら注文IDと処理種別などが候補になります。

毎回生成する実行IDはログの追跡には使えますが、再実行時に変わるため、そのままでは業務上の重複判定に使えません。同じ処理をやり直す場合は、同じ業務キーで照合できる必要があります。一方、訂正処理を別操作として許可する場合は、訂正版を区別するルールも決めます。

DB内で完結する処理なら、業務キーに一意制約を設けた処理記録と、業務更新を同じトランザクションで確定する設計が考えられます。単に「処理済みか確認してから更新する」だけでは競合するため、制約違反や条件付き更新の結果を扱います。制約違反後のトランザクション状態はDBや利用方式で異なるので、例外を捕捉してそのまま続行する実装にはしません。

処理状態を使う場合は、「未処理」から「処理中」へ条件付きで変更し、変更できた実行者だけが先へ進む方法があります。更新件数を確認し、獲得できなかったデータを処理しないことが重要です。「処理中」を先にコミットする設計では、強制終了後にその状態が残るため、所有者、期限、再取得条件も必要になります。

外部APIや通知の重複と途中失敗に備える

DBのロールバックで、送信済みメールや外部APIで確定した操作まで取り消せるとは限りません。外部サービスが処理を受け付けた後、応答が届く前に通信が切れると、呼び出し側には成功か失敗か判断できない状態が残ります。

相手側が冪等性キーに対応している場合は、同じ業務操作の再試行に同じキーを使います。キーの有効期間、重複時の応答、同じキーで異なる内容を送った場合の扱いは、相手側の仕様で確認します。対応がなければ、結果照会や照合、人間による確認を含め、結果不明時の扱いを決めます。

DB更新と通知要求の記録を同じトランザクションで保存し、後から通知を送るアウトボックス方式も候補です。これは通知要求の記録漏れを防ぐために役立ちますが、送信後に送信済み記録を残せず、再送になる可能性は残ります。重複送信に備え、送信先での重複排除などを組み合わせます。

二重実行防止と障害復旧を検証する

検証では、外部APIや通知はテスト用の接続先を使い、同時実行の抑止と、最終的な業務結果の両方を確認します。

複数プロセスの同時起動と手動実行の競合を試す

共有ロックの検証では、同じDBへ接続する二つのアプリプロセスを用意します。一方がロックを取得して業務処理中になったことを確認してから、もう一方を起動すると、競合する条件を作れます。同時刻に起動ボタンを押すだけより、取得済みの状態を制御するほうが確認しやすくなります。

  • ロックの有効期間内に、同じジョブの業務処理へ入る実行者が一つになるか。
  • 取得できなかった側が、業務更新をせずに競合スキップとして記録されるか。
  • 定期実行中の手動実行も、同じ排他制御に参加するか。
  • 新旧プロセスが共存する条件でも、共有先とロック名が一致するか。

単一JVM内のテストだけでは、別プロセスとの排他を確認できません。ロックプロバイダーとDBを含めた検証を用意し、最終的な更新件数や処理記録も照合します。

処理遅延・強制終了・ロック期限切れを確認する

正常終了だけでなく、業務処理を意図的に遅らせる、ロック取得後にプロセスを強制終了する、DBへの接続を失わせるなど、復旧条件を変えて確認します。特に期限切れは、元のプロセスが停止している場合と、動き続けている場合を分けます。

強制終了の検証では、期限が過ぎた後に別プロセスが引き継げるか、途中まで確定したデータを再処理しても重複しないかを確認します。処理時間超過の検証では、古い実行者と新しい実行者が重なる可能性を再現し、業務データ側の制約や状態管理が想定どおり働くかを調べます。

外部通知の後、完了記録の前で停止するケースも確認対象です。再実行後に通知が重複するなら、ロックだけでは要件を満たしていないことが分かります。また、取得エラー時に処理を続けないことや、ロック延長を採用する場合の延長失敗も確認します。

復旧のためにロック行を削除する前には、元の実行者が停止しているかを確認します。実行中のままロックを操作すると、別の実行を許可してしまうおそれがあります。ライブラリの管理方法に合わせ、停止確認と再実行判断を含む復旧手順を用意してください。

実行・スキップ・失敗の記録と再実行後の結果を確認する

ログでは、実行要求があったことと、ロックを取得して業務処理を開始したことを区別します。少なくとも、ジョブ名、実行ID、インスタンス識別子、起動経路、開始・終了時刻、結果、処理件数を追えるようにします。業務キーも必要に応じて記録しますが、認証情報や個人情報を不用意に含めないようにします。

結果は、成功、競合スキップ、取得エラー、業務処理失敗、結果不明など、次の対応を判断できる粒度に分けます。ロック取得だけで成功とせず、業務更新の確定まで確認します。強制終了では終了ログが残らないため、開始記録と完了記録の不一致も監視対象です。

再実行後は、同じ業務キーの結果が重複していないこと、未処理データが残り続けていないこと、処理中のまま取り残されたデータを回収できることを確認します。スキップが続いていても別の実行が処理を進めているなら正常な場合がありますが、最終成功時刻が古いままなら調査が必要です。処理時間、未処理件数、期限への接近も合わせて監視すると、停滞を見つけやすくなります。

まとめ

Spring Bootの定期処理で二重実行を防ぐには、最初に同時実行と再処理を分け、スケジュール設定、非同期処理、Beanの登録、複数プロセス、手動実行の経路を確認します。fixedRateやスレッド数だけを原因と決めつけず、実際にどの処理が重なっているかを追うことが出発点です。

単一JVMでは共有するローカルロック、複数インスタンスでは共有DBなどを使うロックが候補になります。いずれも、競合するすべての入口を保護し、業務処理の完了まで排他の範囲に含めます。期限付きロックでは、期限超過後も元の処理が続く可能性を考慮してください。

さらに、トランザクション、業務キー、一意制約、処理状態、外部連携の冪等性を組み合わせ、途中失敗後も安全にやり直せるようにします。同時起動、強制終了、期限切れ、再実行後の業務結果まで確認することで、二重実行の抑止と障害復旧を一つの設計として扱えます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次