
この重複を防ぐために使われるのが withoutOverlapping() である。名称からは単純な実行制御に見えるが、実際にはキャッシュストレージを利用した相互排他ロックである。タスク開始前にロックを取得し、取得できなければその回の実行をスキップする。処理が終わればロックを解放する。
挙動を正確に理解しないまま有効期限だけを短くすると、処理の重複を再び許すことになる。逆に有効期限を長くしすぎると、異常終了後にタスクが長時間停止したように見える。withoutOverlapping の仕組みは、タスクの実行時間、キャッシュ構成、異常終了時の復旧手順まで含めて設計する必要がある。
重複実行を防ぐ処理の流れ
Laravelのスケジューラは、定義されたタスクを実行する前に、重複実行を許可する設定かどうかを判定する。withoutOverlapping() が指定されている場合、タスクの開始前にキャッシュを利用してロック取得を試みる。
概念上の処理は次の順序で進む。
1. スケジューラが実行対象のタスクを判定する。
2. タスクに withoutOverlapping() が指定されているか確認する。
3. キャッシュストレージ上で対象タスク用のロック取得を試みる。
4. ロックが存在しなければ取得し、タスクを実行する。
5. すでにロックが存在する場合、その回のタスク実行をスキップする。
6. タスク処理が完了すると、保持していたロックを解放する。
ここで重要なのは、後続の実行が待機する仕組みではない点である。ロック取得に失敗した実行は、前回の処理が終わるまで待機しない。その回の起動自体がスキップされる。
たとえば毎分実行する処理があり、1回の処理に3分かかるとする。最初の起動がロックを取得して処理を開始すると、次の1分後、2分後の起動では同じロックが存在する。この2回は実行されない。3分後に前回処理が正常終了してロックが解放されていれば、その時点の起動は新たにロックを取得できる。
この挙動は、データ集計や外部サービスとの同期で有効である。処理を待ち行列に積むのではなく、同時実行だけを抑制する設計である。
withoutOverlapping() は実行を遅延させる機能ではない。ロックを取得できない実行を、その回だけ破棄する機能である。schedule:run の起動自体は別に必要である
withoutOverlapping() を定義しただけではタスクは動かない。Laravelのスケジューラを定期的に起動する仕組みが必要である。一般的には、サーバー側のcronなどから php artisan schedule:run を毎分実行する。
この起動処理がなければ、スケジューラによる判定もロック取得も発生しない。withoutOverlapping() はスケジューラの実行機構に追加する排他制御であり、スケジューラそのものの起動機構ではない。
この区別を曖昧にすると、次のような誤診が起きる。
- cronが動いていないのに、ロックが原因でタスクが止まったと判断する
withoutOverlapping()を削除してもタスクが動かず、別の問題を見落とすschedule:runの実行間隔とタスクの処理時間を確認しない- 実行されなかった回が、ロックによるスキップなのか起動漏れなのか判別できない
最初に確認する対象は、ロックではなくスケジューラの起動経路である。cron、コンテナ内の常駐プロセス、実行ユーザー、アプリケーションの環境変数を順に確認する。ロックはその後の問題である。
キャッシュを使ったロック制御
withoutOverlapping() の内部では、Laravelのキャッシュ機能を使ってタスクごとのロックを管理する。ロックの実体は、アプリケーションが設定しているキャッシュストレージに保存される。
利用するキャッシュストレージがファイルであればファイルベースのキャッシュとして保存される。Redis、データベース、Memcachedなどを設定している場合は、それぞれのバックエンドがロック情報の保存先になる。
ただし、キャッシュストレージごとの取得性能やレイテンシを一律に比較することはできない。接続構成、ネットワーク、永続化設定、同時アクセス数によって結果が変わるためである。withoutOverlapping() の動作確認では、一般論の速度比較よりも、現在のアプリケーションがどのキャッシュストアを利用しているかを確認する方が実務的である。
ロック取得は、単なる値の保存ではない。すでに対象キーが存在する場合には新しいロックを取得できないようにする必要がある。これにより、複数のスケジューラプロセスがほぼ同時に起動しても、同じタスクを同時に通過させない。
ロックキーはタスクを識別するために生成される
複数のタスクを登録している場合、すべてのタスクが同じロックを使うわけではない。Laravelはタスクを識別するためのロックキーを生成する。
確認されている内部形式では、コマンド文字列や説明文のSHA1ハッシュ値などを基に識別子を作り、framework/schedule- という接頭辞を付けてロック名として扱う。
そのため、障害調査でキャッシュの内容を確認すると、タスク定義に書いたクラス名や処理名がそのままキーに表示されるとは限らない。ハッシュ化されたキーになるため、見慣れないロック名だけを見て別タスクのロックと判断するのは危険である。
ロックキーを調査するときは、次の情報を組み合わせる必要がある。
- スケジュールに登録されているコマンド
- コマンドの引数やオプション
- タスクに設定した説明情報
- 実行環境で利用しているキャッシュストア
- ロックが生成された時刻
- タスクの実行ログ
- cronまたはスケジューラ起動プロセスのログ
特に、同じコマンドを異なる引数で登録している場合は注意が必要である。ロックキーの生成条件に影響する定義が異なれば別のロックとして扱われる可能性がある。一方、同じ識別情報になる定義を複数登録すれば、意図せず同じロックを共有する可能性もある。
ここはアプリケーション固有のスケジュール定義を読んで判断する部分である。ロックキーの接頭辞だけを根拠に、どのタスクのものかを断定することはできない。
有効期限1440分の意味
引数なしで withoutOverlapping() を呼び出した場合、ロックの有効期限は1440分に設定される。24時間である。
この値は、処理の実行タイムアウトではない。タスクが24時間経過すると強制終了する設定でもない。処理が動作しているプロセスを監視し、一定時間で停止させる機能でもない。
有効期限は、ロックがキャッシュ上に残り続ける時間を意味する。タスクが正常に終了すれば、通常は処理完了時の解放処理によってロックが削除される。プロセス異常終了やサーバーダウンなどで解放処理が走らなかった場合に、残存ロックを永続化させないための期限である。
この違いを理解していないと、次のような設定ミスにつながる。
- 処理時間が10分だから
withoutOverlapping(10)にすれば安全だと判断する - 10分経過後にLaravelが実行中のプロセスを止めると誤解する
- ロック期限が切れた後、前回処理がまだ実行中でも新しい処理が始まる可能性を見落とす
- 24時間の既定値を、タスクの実行制限時間として運用する
withoutOverlapping(10) の10は、処理のタイムアウトではない。ロックの有効期限を10分にする指定である。
短いTTLは安全とは限らない
処理時間が通常5分のタスクに対して、ロックの有効期限を10分に設定する。この場合、処理が10分以内に正常終了すれば、ロックは処理完了時に解放される。
しかし、一時的な遅延で処理が10分を超えると、ロックの有効期限が先に切れる可能性がある。その状態で次回のスケジュール起動が発生すれば、前回処理がまだ動いているにもかかわらず、新しいロックを取得して処理を開始する可能性がある。
つまり、TTLを短くすれば残存ロックの復旧は早くなるが、長時間実行時の重複リスクが増える。TTLを長くすれば重複リスクは抑えやすいが、異常終了後の復旧に時間がかかる。
これは設定値の最適化ではなく、トレードオフである。
| 設定 | 利点 | 主なリスク |
|---|---|---|
| 短い有効期限 | 異常終了後にロックが自然消滅しやすい | 正常処理が期限を超えると重複実行の可能性がある |
| 長い有効期限 | 長時間処理でもロックを維持しやすい | 異常終了後にタスクが長時間スキップされる |
| 既定値の1440分 | 個別の実行時間を細かく見積もらなくても設定できる | 短時間で復旧したい処理には長すぎる場合がある |
有効期限を決めるときは、平均処理時間ではなく、遅延時の処理時間を見る必要がある。外部API、ファイル入出力、データベースのロック、大量データの集計などがボトルネックになる処理では、通常時の実行時間だけで判断できない。
withoutOverlapping(10) をタイムアウトとして使ってはいけない
withoutOverlapping(10) の典型的な誤解は、10分経過したらタスクを強制終了できるというものである。
実際には、10分という値はロックの保持期間である。実行中のPHPプロセスを終了させるものではない。データベースのクエリをキャンセルするものでもない。外部APIへのリクエストを中断するものでもない。
タスクそのものの実行時間を制御したい場合は、別の仕組みが必要になる。キューを使う処理であればワーカーのタイムアウト、外部通信であればHTTPクライアントのタイムアウト、データベース処理であればクエリやトランザクションの設計を確認する必要がある。
withoutOverlapping() が担当する範囲は、あくまで同じスケジュールタスクの重複起動を防ぐことに限定される。
たとえば次のような処理を考える。
- 毎分、未処理の注文を取得する
- 外部の配送サービスへ送信する
- 送信結果をデータベースに保存する
この処理に withoutOverlapping(10) を設定しても、外部サービスの応答が10分で返らなければ処理が止まるわけではない。逆に、10分経過後もプロセスが動作していれば、ロックだけが期限切れになる可能性がある。
この場合の設計上の問題は、ロック期限ではなく処理の冪等性にもある。同じ注文を二重送信しても問題が起きないか。送信前後の状態を正しく管理できるか。外部サービス側に重複排除の仕組みがあるか。こうした要素は withoutOverlapping() だけでは解決できない。
ロックは同時実行を抑える。処理の冪等性、通信タイムアウト、トランザクション境界までは代替しない。
キャッシュストアの確認が必要な理由
ロック情報がキャッシュに保存される以上、キャッシュの設定はタスクスケジュールの動作に直接関係する。
開発環境ではファイルキャッシュ、本番環境ではRedisという構成もある。コンテナ環境では、スケジューラを実行するコンテナとウェブアプリケーションのコンテナで環境変数が異なるケースもある。キャッシュストアが一致していなければ、同じアプリケーションのつもりでも、プロセスごとに異なるロック領域を参照する。
その結果、次のような状態が起きる。
- スケジューラAはロックを保存する
- スケジューラBは別のキャッシュストアを参照する
- Bから見るとロックが存在しない
- 同じタスクが二重に実行される
この問題は withoutOverlapping() のロジックが壊れているのではない。ロックを共有すべきプロセスが、同じ保存先を見ていないことが原因である。
確認対象は、アプリケーションコードだけでは足りない。次の設定を同じ実行環境で確認する必要がある。
- キャッシュドライバの設定
- キャッシュサーバーの接続先
- データベースキャッシュを使う場合のテーブル設定
- コンテナごとの環境変数
- スケジューラを実行するユーザー
- コンテナの再起動やデプロイによる設定差分
Laravelの設定キャッシュが有効な場合、環境変数を変更しても実行中のプロセスが古い設定を参照することがある。ロックの有無だけを調査して、実際にどのキャッシュストアへアクセスしているかを確認しなければ、原因の切り分けはできない。
単一サーバーと複数インスタンスの違い
単一サーバー上でcronから schedule:run を実行する構成では、ファイルキャッシュでもロックを共有できる場合がある。しかし、アプリケーションを複数インスタンスへ分散する構成では、各インスタンスが同じロック保存先へアクセスできる必要がある。
インスタンスごとにローカルファイルシステムが分離されている場合、ファイルキャッシュは共通のロックストアにならない。あるインスタンスが作成したロックを、別のインスタンスが認識できないためである。
この構成では、共有可能なキャッシュストレージを利用する設計が必要になる。ただし、どのストアを選ぶべきかは、既存のインフラ、障害時の扱い、接続方式、運用体制によって変わる。単にRedisへ変更すれば解決するという話ではない。
複数インスタンスで必要なのは、次の条件である。
- すべてのスケジューラプロセスが同じキャッシュストアを見る
- ロック取得をアトミックに扱える
- キャッシュストアへの接続障害を検知できる
- デプロイ時に設定がインスタンス間でずれない
- 残存ロックを調査、削除できる
withoutOverlapping() はアプリケーションのコードに1行追加するだけで使える。しかし、分散環境で正しく機能させるには、ロックを共有するインフラ設計が必要である。
プロセス異常終了とロック残留
正常終了時には、タスク処理の完了に合わせてロックが解放される。問題は、解放処理まで到達しないケースである。
サーバーの停止、コンテナの強制終了、PHPプロセスのクラッシュ、実行環境の障害などが発生すると、ロックがキャッシュに残ることがある。タスク自体はもう動いていないのに、次回以降のスケジューラはロックを検出し続ける。その結果、タスクの実行が毎回スキップされる。
この状態は、アプリケーションのログだけでは分かりにくい。タスクが処理を失敗したログを残す前にプロセスが終了している可能性があるためである。スケジューラの起動ログには、実行ではなくスキップに近い状態だけが記録されることもある。
調査では、少なくとも次を時間軸で並べる必要がある。
1. 最後に正常完了した時刻
2. 最後にロックを取得した時刻
3. プロセスが終了した時刻
4. サーバーまたはコンテナに障害が発生した時刻
5. 次回以降のスケジューラ起動時刻
6. ロックの有効期限
7. ロック削除またはキャッシュクリアの実行時刻
ロック残留とタスク処理失敗は、同じ障害の別の結果である場合が多い。ロックだけを削除して再実行すると、途中まで完了していた処理を再度実行することになる。データの重複や二重送信が起きる処理では、先に処理状態を確認する必要がある。
schedule:clear-cache による復旧
残存したスケジュール用のMutexロックは、php artisan schedule:clear-cache で一括削除できる。フレームワークが管理するスケジュール用のキャッシュロックを、手動で個別に探して削除するのではなく、用意されたArtisanコマンドで整理する方法である。
実行前には、対象環境を確認する必要がある。開発環境で実行するつもりが本番環境へ接続していた場合、必要なロックまで削除する可能性がある。特に複数のスケジューラプロセスが稼働している環境では、処理中のタスクが存在しないかを確認しなければならない。
復旧手順の考え方は次の通りである。
- タスクが現在も実行中でないことを確認する
- サーバー障害やプロセス異常終了の有無を確認する
- キャッシュストアの接続先が正しいことを確認する
php artisan schedule:clear-cacheを対象環境で実行する- 次回の
schedule:runでタスクが再評価されることを確認する - 再実行によるデータ重複がないか確認する
このコマンドは、ロック残留を解消するための手段である。根本原因であるプロセス停止、メモリ不足、外部APIの停止、データベース障害を修正するものではない。
ロックを消しても、同じ異常終了が繰り返されれば再びロックが残る。復旧操作と原因調査を分けて考える必要がある。
実行時間とTTLをどう決めるか
有効期限の設計では、タスクの処理時間を単一の値で見るべきではない。通常時、データ量が多い場合、外部サービスが遅延した場合、データベースが混雑した場合を分けて考える。
たとえば、通常は数分で終了する処理でも、月末の集計や大量データの取り込みで処理時間が伸びることがある。平均値に合わせてTTLを設定すると、異常ではない負荷上昇でロックが切れる可能性がある。
反対に、24時間の既定値をすべてのタスクへ適用すると、短時間で復旧可能なタスクまで長時間停止する。タスクの重要度と再実行の影響を考慮して、個別に設定する必要がある。
判断材料は次のように整理できる。
- タスクの通常時の実行時間
- 負荷上昇時に想定される最大実行時間
- 外部APIやネットワークに依存する時間
- タスクが失敗した場合の再実行方法
- ロックが残った場合に許容できる停止時間
- ロック期限切れによる重複実行の影響
- 処理対象に冪等性があるか
- 運用担当者が異常を検知できるか
TTLを短くする根拠は、単にタスクが短時間で終わることではない。処理が期限を超えないことを、負荷条件を含めて説明できる必要がある。
逆に、TTLを長くする場合は、ロック残留が発生した際の復旧手順を運用に組み込む必要がある。schedule:clear-cache を知っているだけでは足りない。誰が、どの環境で、どの条件を確認して実行するかを決めておく必要がある。
ロックによるスキップを障害と判断する前に
タスクが実行されていないように見えるとき、原因は複数ある。
schedule:runが起動していない- 実行時刻の条件に該当していない
withoutOverlapping()によりスキップされた- 前回タスクが実行中である
- 前回タスクが異常終了し、ロックだけ残っている
- キャッシュストアに接続できていない
- スケジューラとアプリケーションでキャッシュ設定が異なる
- コマンド自体がエラーで終了している
この中で、ロックによるスキップだけを先に疑うと調査の順序を誤る。まずスケジューラの起動を確認し、次にタスクの実行条件、最後にロックの状態を確認する方が効率的である。
また、ロックがあること自体は異常ではない。タスクが正常に実行中であれば、ロックは重複を防ぐために必要な状態である。問題は、処理が終了しているにもかかわらずロックが残っている場合である。
したがって、キャッシュ上のロックを見つけた時点で削除してはいけない。現在のプロセスが動いているか、処理対象が中途半端な状態になっていないかを確認してから復旧する。
設計時に分けるべき三つの責務
withoutOverlapping() を使うときは、三つの責務を分離すると設計が安定する。
1. 起動制御
cronなどから schedule:run を定期的に起動する責務である。ここが停止していれば、ロック以前にタスクは評価されない。
2. 重複制御
withoutOverlapping() によって同一タスクの同時実行を防ぐ責務である。キャッシュストレージとロックキーが関係する。
3. 処理の整合性
タスクが途中で停止した場合でも、再実行によってデータが壊れないようにする責務である。冪等性、処理状態、トランザクション、外部サービスとの連携設計が対象になる。
この三つは似ているが、代替関係ではない。withoutOverlapping() を追加しても、cronの停止は解決しない。ロックを長くしても、二重送信への耐性は生まれない。再実行可能な処理にしても、同時実行による競合を自動で防げるわけではない。
バックエンドの障害は、複数の責務が一つの設定に押し込まれたときに見えにくくなる。ロック設定はロック設定として扱い、処理の整合性は別に設計する必要がある。
まとめ
Laravelの withoutOverlapping() は、タスク実行前にキャッシュストレージからロックを取得し、すでにロックが存在する場合は後続の実行をスキップする機能である。内部ではスケジュール用のロックキーが生成され、通常は処理完了時に解放される。
押さえるべき点は次の通りである。
withoutOverlapping()は同時実行を防ぐ相互排他ロックである- ロックを取得できない実行は待機せず、その回だけスキップされる
- 引数なしの有効期限は1440分、24時間である
withoutOverlapping(10)の10はロックの有効期限であり、処理のタイムアウトではない- ロックはアプリケーションのキャッシュストレージに保存される
- 複数インスタンスでは、全プロセスが同じキャッシュストアを参照する必要がある
- 異常終了でロックが残った場合は
php artisan schedule:clear-cacheで削除できる - ロック削除は復旧操作であり、プロセス異常や処理失敗の原因を修正するものではない
- TTLを短くすると期限切れ後の重複リスクが増え、長くすると異常終了後の停止時間が伸びる
- 重複制御と、処理そのものの冪等性は別の設計課題である
最終的なボトルネックは、withoutOverlapping() の1行ではない。スケジューラの起動、キャッシュの共有、タスクの実行時間、異常終了時の復旧、再実行時のデータ整合性を一つの運用モデルとして扱えるかである。ロックの仕組みを理解すれば、タスクが動かない問題を感覚ではなく、起動・取得・実行・解放の各段階に分解して調査できる。
Related reading: AbortControllerが非同期リクエストを中断するブラウザの内部挙動 and Stripe決済の二重課金を防ぐべき等性の内部挙動と実装設計.