はじめに
Web開発に入門すると、多くの人はMVCパターンを学びます。ユーザーにインターフェースを提供し、ユーザーの操作に応じて、保存先に格納された各種データをアプリケーションサーバー側で加工した結果を返す、という形でMVCを理解していく一方で、ユーザーとの相互作用なしにサーバー上で実行される「バッチ処理」については、あまり学ばないまま進むことも多いです。
Webプログラミングは、言語やフレームワークに関係なくMVCという基本パターンを使い、必要に応じて拡張しながらアーキテクチャを決めていきます。そのため、どの言語・フレームワークを目標にしていても、MVCは必須として学びやすいです。一方で、バッチ処理は言語やフレームワークが提供する方式がそれぞれ違うため、共通して覚えておくべきパターンが提示されにくい、という事情があります。
バッチ処理の有名なアーキテクチャとしてはJava SpringのSpring Batchがありますが、言語・フレームワークごとに得意なバッチ処理の仕組みは異なるので、そのまま移植できるわけではありません。この記事では、バッチ処理を学ぶうえで基本となる概念を整理しつつ、Laravelでバッチ処理をどう設計するとよいか、ひとつのパターンを紹介します。
この考え方はLaravelに限らず、似た方式でバッチ処理を行うRailsやDjangoなどのプロジェクトでも応用できると思います。
本編
バッチ処理とは?
一般的にプログラムは、ユーザーとの相互作用を通してデータを作成・保存・変更する仕組みを提供するコンピュータシステムです。
ユーザーとシステムの相互作用が発生したとき、処理に時間がかかって操作体験が悪くなる場合があります。また、ユーザーが操作したタイミングではなく、別のタイミングで実行したい処理もあります。そういった処理を、特定のスケジュール(実行タイミング)を指定して、ユーザーとの相互作用なしに実行する方式がバッチ処理です。
つまり、ユーザーとの直接的な相互作用なしに行うシステムのデータ処理方式 がバッチ処理と言えます。
一般的には、システムリソースの消費が少ない時間帯を選び、ユーザー操作の中では処理しづらい作業を実行します。
バッチ処理とユーザーインターフェースによる処理
Web開発は、よく知られているMVCパターンの上で開発するのが基本です。この構造は多くの人に馴染みがあり、ユーザー設定などからロジックに必要な値を入力してもらえます。そのため、バッチ処理に比べると比較的コードを書きやすいです。
ユーザーに提供するインターフェースから受け取った値でロジックを実行するのに対し、バッチ処理ではデータベースの状態やサーバーの状態などを考慮して、処理に必要な設定をプログラマー側で指定する必要があります。その分、動作条件の設計が難しくなり、スケジューラー、キュー、ジョブなどへの理解も求められます。
また、スケジューラーに依存する機能を動かすためにコマンドラインの命令を用意するなど、定型コード(ボイラープレート)が増えやすい点も悩みどころです。
バッチ処理はMVCより多くのボイラープレートが必要になるケースもあります。作り始める前に、ユーザーインターフェースで処理するのがよいか、バッチ処理にするのがよいかを判断する必要があります。
ユーザーインターフェースで処理するとよい作業
バッチ処理を作るよりも、ユーザーインターフェースを提供したほうが処理しやすい作業は、UIで扱うようにします。
たとえば、リクエスト〜レスポンスの時間がそれほど長くなく、ユーザーが結果を得るまでの待ち時間が大きな負担にならない作業が該当します。ユーザーはレスポンスに含まれる結果をすぐ確認できる、というメリットがあります。
バッチ処理はユーザー視点では非同期で動きます。Webリクエストの結果がレスポンスに含まれず、別途バッチ処理が動きます。いつ完了するかを正確に知るのは難しく、「しばらくしてから確認してください」といった案内になりがちです。
バッチ処理に向いている作業
作業完了前に素早くレスポンスを返したい
要求された作業に長い時間がかかる場合、リクエスト〜レスポンスのライフサイクルが長くなりユーザーは不便に感じます。その場合はバッチ処理にタスクを渡して素早く応答し、ユーザーには「しばらくしてから確認してください」と伝えるほうが安心感につながります。
ユーザーが要求する前に先に処理しておきたい
時間のかかる作業は、ユーザーが要求する前に、条件が揃い次第バッチ処理で実行しておきます。事前処理によって、ユーザーは必要なタイミングで処理済みの結果だけをすぐ見られます。
特定のタイミングで処理しないといけない作業
特定の実行タイミングに依存する情報もあります。たとえばある情報が継続的に変化していて、特定のタイミングで実行しないまま後から実行すると、処理結果が変わってしまい、欲しかった時点の結果を得られないことがあります。
もちろん値を更新しないようにして履歴を積む設計が望ましいですが、状況によっては値が更新され続けるケースもあります。そういうときは、実行すべきタイミングでスケジューラーからバッチ処理を実行し、適切なタイミングの結果を生成・保存するためにバッチ処理を使います。
Laravelにおけるバッチ処理
Laravelがバッチ処理のために提供している機能には、スケジューラー、ジョブ、キュー、Artisanコマンドがあります。
スケジューラー(Scheduler)とは?
指定したスケジュールに従ってプログラムを実行する機能をスケジューラーと言います。毎日何時、毎月何日、1時間に一定間隔で何回、といった設定で、狙った時間に狙った作業を実行できます。
LaravelではLinuxのcronをベースにしたスケジューラーを提供しており、app/Console/Kernel.php に「Artisanコンソール」1 を追加して、Linux cronがArtisanコンソールのPHPコードを実行するようにします。
スケジューラーのコード例
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
final class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule): void
{
$schedule->command('billing:dispatch-issue-invoices')
->dailyAt('01:00')
->withoutOverlapping(30)
->onOneServer();
}
}
-
->dailyAt('01:00'): 毎日01:00に請求書発行のジョブをキューに積む -
->withoutOverlapping(30): 同一サーバーでcronジョブが重複実行されるのを防ぐ(最大30分ロック) -
->onOneServer(): 複数のバッチサーバーで同じスケジューラーが重複実行されるのを防ぐ(Redisなどのキャッシュドライバが必要です)
キュー(Queue)とは?
キューは先入れ先出しの性質を持つデータ構造です。カゴに色の違うボールを入れて、先に入れた色のボールを先に取り出すようなイメージです。
Webプログラミングで一般に「キュー」と言う場合、ある処理ロジックを積んでおいて、順番に実行することを指します(設定によって順序を保証しない場合もあります)。
Laravelのキューは、データベースなどの保存先にシリアライズされたジョブデータを保存し、保存された順にジョブデータをデシリアライズして実行する流れを指します。
このとき、キューに1つずつ入れて取り出すシリアライズ済みのジョブデータを「ペイロード」と呼びます。
ジョブ(Job)とは?
ジョブはキューに積める処理単位で、Laravelではジョブクラスに実行するコードを定義します。
ジョブをディスパッチすると、キューの保存先にジョブオブジェクトの情報が保存され、これをペイロードと呼びます。キューのペイロードからジョブオブジェクトを復元できます。キューに保存する前にジョブオブジェクトのプロパティに設定した値も含めて、実行可能なジョブとして復元できます。
Artisan コマンドとは?
Laravelでは php artisan CustomCommand のように、コマンドラインから実行できる命令を作れます。
スケジューラー(app/Console/Kernel.php)にArtisanコマンドを登録すると、指定した時間にそのコマンドが実行されます。
スケジューラー用途だけでなく、バッチ処理が失敗したあとに修正コードをデプロイしたものの、スケジューラーの実行タイミングを過ぎてしまった場合に、サーバーのコマンドラインから手動でバッチを実行する用途にも使えます。
バッチ処理を考えるための前提知識
ジョブ(Job)
Laravelフレームワークでバッチ処理をきれいに実装する方法を考えてみます。
ジョブの特徴
Laravelのジョブは、キューから1つのデータを取り出して実行する処理単位です。このデータはシリアライズされてDBなどの保存先に格納され、ワーカー(worker)プロセスが順番に取り出してデシリアライズし、実行します。
ジョブの失敗
ジョブの実行中に例外やエラーが適切に処理されないと、実行中のジョブは中断され、失敗したジョブとして扱われます。ジョブが成功すれば保存先から削除されます。
失敗したジョブはリトライ可能で、リトライするとリトライ回数が1増えた同一タスクのジョブが新たにキューへ追加され、失敗したジョブは削除されます。この流れを指定回数リトライしても失敗する場合、ジョブはキューから外れて別の場所(Laravelデフォルトではfailed_jobsテーブル)に保存されます。タスクが正常に動作すれば完了後にキューから削除されます。
ジョブのタスクが失敗した場合、再実行できるように「ジョブ実行前の状態」に戻せる必要があります。そのため、ジョブはトランザクション単位でまとめて処理するほうがよいです。
Laravelキューのリトライは、設定によっては一時障害が解消する前に再実行されるほど間隔が短くなることがあります。その場合は、同じタスクをスケジューラーで数分〜数時間間隔で再実行するほうが運用しやすいこともあります。
小さなロジックにする
トランザクションはデータベースのリソースを占有します。長いトランザクションが増えるとDBリソースを多く使い、性能低下や他処理のタイムアウトにつながります。ジョブ内のロジックが多いほどトランザクションも長くなるので、できるだけ短いトランザクション単位でジョブを作るのがよいです。
ロジックを小さくするとトランザクション時間を短縮でき、ロールバックやコミットの負担を減らせます。コミット単位が小さければ、失敗した部分だけ原因を直して再処理しやすい、というメリットもあります。
ジョブに状態を持たせる
LaravelのJobは、ジョブクラスをオブジェクト化し、そのシリアライズデータを保存先に格納します。オブジェクトとして保存されるため状態を持てて、プロパティにジョブの動作オプションを入れられます。同じクラスのジョブでも、プロパティの値により分岐して様々な動作が可能です。
購入者に対するバッチ処理を例にすると、「注文完了後の後処理(領収書発行、ポイント付与、配送依頼、通知送信)」の大まかな流れは似ています。
- 注文が有効か確認する
- すでに処理済みの注文か確認する(重複処理防止)
- 決済方法/配送方法/会員ランク/プロモーション適用有無で詳細処理が変わる
- 処理が終わったら「処理完了」状態を残す
つまり、ロジックの骨格は同じで、購入者や注文データの条件により「どの下位処理を実行するか」だけが変わるケースが多いです。
このときLaravelのJobはオブジェクトとしてキューに積まれるので、orderId だけでなく paymentMethod、shippingType、isFirstPurchase、campaignId のような状態(オプション)も一緒に入れてディスパッチできます。ワーカーは同じジョブクラスを実行しつつ、ジョブオブジェクトが持つ状態に応じて分岐し、注文ごとの後処理を行います。
ワーカー
Laravelのキューはワーカーというプロセスによって実行されます。ローカル環境では、ジョブごとにプロセスを起動してコード変更を即時反映しやすい php artisan queue:listen で動かせます。プロダクション環境では、プロセスが落ちたら復帰させるLinuxのsupervisor、またはDockerのCMDで php artisan queue:work / php artisan queue:listen を実行して動かします。
キューからジョブを取り出して処理する際に問題が起きてワーカーが落ちても復帰できるように、supervisorでワーカーを動かすか、コンテナのPID 1が終了したらDockerが再起動できるようにCMDで php artisan queue:work を実行してワーカーを動かすのがよいです。3
ワーカーのオプション設計
PHPは、PHPエンジンがPHPパーサーを起動して実行し、終了する、という動きに向いています。Webリクエストを処理してレスポンスを返し、プロセスが終了するライフサイクルが終わると、実行中にメモリに載っていたデータはすべて破棄されます。この方式のメリットは次の通りです。
- プロセス間でメモリを共有しないため、独立に動くプロセス同士の影響を最小化でき、意図通りに動くことを期待しやすい
- プロセス終了により不要なメモリ占有を避けられ、長期運用の安定性が上がる
- コードを書くときにメモリ占有をそこまで気にしなくてよい
1つのPHPプロセスは、長時間動かすよりも、短い単位の処理を何度も実行するほうが安定しやすいです。実行時間が長くなると、PHPコード実行時のメモリリークによって、ある時点でプロセスが強制終了され、進行中作業の整合性を失う可能性があるためです。4
キューに積まれた順にジョブを処理しつつ、ジョブごとに子プロセスを起動して実行するワーカーは、Laravelでは php artisan queue:listen で動かせます。ジョブ実行後に子プロセスが終了するため、親プロセスのワーカーが複数ジョブを処理してもメモリがリセットされ、メモリリーク問題を根本的に避けられます。
ただし listen はジョブごとにLaravelフレームワークを起動するため遅い、というデメリットがあります。そこで、php artisan queue:work --memory=256 のようにメモリ使用量が一定を超えたら終了させたり、php artisan queue:work --max-jobs=1000 や php artisan queue:work --max-time=3600 のように一定回数/一定時間で終了させたりして、メモリ問題を事前に防ぐ戦略も併用します。
シングルワーカーとマルチワーカー
キューは先入れ先出しの順でジョブを処理します。1つのジョブが完了してから次のジョブを処理するためには、1つのキューは1つのワーカーで動かす必要があります。
1つのキュー(DBテーブルに保存されたシリアライズ済みジョブ一覧)を複数ワーカーで処理できますが、このときキューに A, B, C, D, E, F が積まれていて3並列で処理すると、A, B, C が各ワーカーに割り当てられます。Aが先に終わってB, Cが終わっていない場合、Aを処理したワーカーがDを処理します。もしDが終わるまでBが終わらなければ、A→C→D→B... のように順序通りに処理されない場合があります。
順序が重要な作業は1つのキューにシングルワーカーを割り当て、順序を気にしなくてよい作業はマルチワーカーで高速に処理します。
LaravelのJob Chainingを使うと、マルチワーカー環境でも特定のジョブの処理フローを順番に実行できます。ですが、チェーン機能を組むには、同じ対象に対して役割の異なる複数のジョブを分割してつなげる設計が必要になり、複雑さが増えることがあります。大規模な処理が不要であれば、異なる責務を持つジョブを異なる時間にスケジューラーで実行する方法のほうが、問題が発生したときに段階ごとに原因を把握して対応しやすいです。
Spring BatchとPHPバッチの戦略の違い
一般にバッチ処理の標準として語られることが多いのが、Java SpringのSpring Batchです。PHPが小さな単位のジョブでバッチ処理を進める戦略を取りやすいのに対し、Spring Batchは大量データの処理に重点があります。
保存先から処理可能な量のデータ塊(チャンク)をバッチアプリに取り込み、その後は速度低下につながる外部接続をできるだけ減らし、コンパイル言語の計算性能を最大限使って一気に大量の結果を作り、結果をチャンク単位でトランザクションしながら保存する、という構成になっています。
PHPでもSpring Batchのような大量処理はできます。ただしPHPはインタプリタ言語で、相対的に計算リソース効率が低いので、超巨大スケールのバッチよりも、ビジネスの流れを正確に表現できるロジックを作るほうが向くことが多いです。
多くのPHPプロジェクトでは、計算性能を最大限活かすために、必要なデータを一度に大量に取り込み、大量の結果をまとめて保存できる単位でチャンク処理を作る戦略は、あまり合わないことがあります。
ビジネスの流れが分かりやすいロジックを組み、ステップに合わせてアプリケーション外部からデータを取り込むようにするのがよいです。I/Oによる速度低下があっても、メンテナンス性を考えたコードを優先します。
Spring Batchでも、read - process - write の構造に沿いつつ、process段階でビジネスロジックの流れに合わせたI/O処理が入るバッチケースは多いです。これは、Spring Batchが提供する基本構造の中で、プロジェクトの要件に応じて実装方法が変わるためです。
フレームワーク内での構造
Laravelはキュー、ジョブ、スケジューラー、Artisanコマンドといった機能を提供しますが、公式のバッチ処理戦略はありません。
ここまでの内容を踏まえ、PHPに合うバッチ処理戦略として「バッチユニット」「ジョブクラス」「ジョブトリガー」「Artisanコマンド」という概念に分けて構成してみます。
バッチユニット(Batches/Units/*)
- ジョブクラスからバッチユニットのオブジェクトを取得して実行するために使います。
- バッチユニットは、1つのジョブを処理するためのコードを定義する部分です。
- 1つのジョブを定義する用途なので、バッチユニットクラスの実行は、失敗時に初期状態へ戻せるトランザクション単位で作ります。
バッチユニット導入のメリット
ジョブクラスにすべてを書かず、バッチユニットを別に用意してジョブから呼ぶ理由は、似たロジックがバッチ処理だけでなく、ユーザーとの相互作用で実行する場合にも使えるからです。
1つのユニットは、バッチ処理用にJobから使うこともできますし、ユーザーインタラクションを担当するサービスレイヤーやコントローラ(またはユースケース)から呼ぶこともできます。このためユニットを分離して再利用性を上げるほうがよいです。
こうしておくと、あるバッチ処理が条件不足で失敗した場合に、ユーザー側で再実行する機能を提供しやすくなります。
ジョブは非同期でディスパッチされるのでテストが難しいですが、詳細ロジックをバッチユニットへ移すと単純なオブジェクトになり、テストコードも書きやすくなります。
バッチユニットの例
namespace App\Batches\Units\Billing;
use App\Models\Invoice;
use App\Models\Subscription;
use Illuminate\Support\Facades\DB;
final class IssueInvoiceUnit
{
public function run(int $subscriptionId, string $billingDate): void
{
DB::transaction(function () use ($subscriptionId, $billingDate) {
$subscription = Subscription::query()
->whereKey($subscriptionId)
->lockForUpdate()
->firstOrFail();
$alreadyIssued = Invoice::query()
->where('subscription_id', $subscription->id)
->where('billing_date', $billingDate)
->exists();
if ($alreadyIssued) {
return;
}
Invoice::query()->create([
'subscription_id' => $subscription->id,
'billing_date' => $billingDate,
'amount' => $subscription->monthly_fee,
'status' => 'issued',
]);
});
}
}
ジョブクラス(Job/*)
ジョブクラスは Batches/Job/* に置きたいのですが、Laravelの制約で Job/* 配下に置かないとキューに積めないため、既存の配置をそのまま使います。6
ジョブは、データベースのような保存先にシリアライズ済みのジョブオブジェクトを保存し、保存順にデシリアライズして、キューに積まれた順でジョブロジックを実行するためのコードです。
処理の詳細ロジックはバッチユニットに定義し、ジョブはそれを呼び出して実行するために使います。
ジョブクラスの例
namespace App\Jobs\Billing;
use App\Batches\Units\Billing\IssueInvoiceUnit;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class IssueInvoiceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $subscriptionId,
public readonly string $billingDate,
) {}
public function handle(IssueInvoiceUnit $unit): void
{
$unit->run(
subscriptionId: $this->subscriptionId,
billingDate: $this->billingDate,
);
}
}
ジョブトリガー(Batches/JobTriggers/*)
ジョブを実行する対象リストを取得し、対象ごとの情報をオプションとしてジョブに渡しながらディスパッチ(キューに積む)するために定義します。
ジョブをディスパッチするとき、ディスパッチオプションによりジョブオブジェクトに状態を持たせられます。ジョブはそのオプションをバッチユニットへ渡し、条件に応じて分岐処理を行います。
バッチユニットはジョブ経由でのみキューに登録できます。ジョブトリガーは複数対象を抽出して、対象ごとの情報をジョブへ渡しながらバッチ処理を進めます。キューに積むために、バッチユニットを直接実行せずジョブをディスパッチする構成にします。
設計上の注意点
バッチ処理が途中で止まったとき、未処理のジョブを再ディスパッチする必要があります。トリガーで対象リストを取るときに、処理済みか未処理かを区別して対象を選ぶことが重要です。
バッチ処理の完了を示すフラグを、進行状況や結果を保存するテーブルに残せるよう、カラム追加も検討します。
リトライしても失敗するジョブは出るので、ログや通知を用意して、開発者が後続対応できるようにします。
ジョブトリガーは基本的に未処理ジョブを処理するために使いますが、トラブル対応のためにバッチをクリアしたりリフレッシュしたりできる機能を用意するのもよいです。
冪等性(べきとうせい、Idempotency)
冪等性とは、何度繰り返して実行しても結果が同じになることを指します。数式では f(f(x)) = f(x) で表せます。状態xに対して処理fを行い f(x) になったとします。このとき f(x) の状態でさらに f を実行しても、xの状態で一度だけfを実行した結果と同じであるべき、という意味です。
キュー処理では、ワーカー障害、リトライ、スケジュールの重複実行、重複ディスパッチといったミスや障害により、ジョブが2回以上実行されることがあります。たとえば結果データがすでに作られているのに、ジョブが重複実行されて同じ結果データをもう一度作ってしまうと、本来は「1回実行した結果」と同じであるべきところ、テーブルには同じデータが2件できてしまいます。
結果データを作るジョブでは、ジョブが重複実行されても結果が重複しないように備えるのがおすすめです。代表的な方法は次の通りです。
- 結果データにユニークな識別子を付与できるなら、DBにユニークキーを置いて重複生成を防ぐ
- ジョブ実行時にガードとして、同じ結果がすでに存在するかを事前チェックし、存在するなら実行をスキップする
ジョブトリガーの例
namespace App\Batches\JobTriggers\Billing;
use App\Jobs\Billing\IssueInvoiceJob;
use App\Models\Subscription;
final class IssueInvoicesJobTrigger
{
public function dispatchForDate(string $billingDate): void
{
Subscription::query()
->where('status', 'active')
->orderBy('id')
->chunkById(500, function ($subscriptions) use ($billingDate) {
foreach ($subscriptions as $subscription) {
IssueInvoiceJob::dispatch(
subscriptionId: $subscription->id,
billingDate: $billingDate,
)->onQueue('billing');
}
});
}
}
Artisanコマンド
ジョブトリガーをバッチ処理の流れに合わせて定義するために使います。
バッチ処理の実行タイミングを決めるには、スケジューラーに登録する必要があります。
スケジューラーに登録するには、Artisanコマンドとして実行できるように定義する必要があります。
Artisanコマンドを定義するときは、バッチを実行するジョブトリガーを直接呼び出します。
Artisanコマンドを定義したら、スケジューラーにいつ実行するかを登録します。
Artisanコマンドの例
namespace App\Console\Commands\Billing;
use App\Batches\JobTriggers\Billing\IssueInvoicesJobTrigger;
use Illuminate\Console\Command;
final class DispatchIssueInvoices extends Command
{
protected $signature = 'billing:dispatch-issue-invoices {--date= : billing date (YYYY-MM-DD)}';
protected $description = 'Dispatch invoice issuing jobs to the queue for a given billing date.';
public function handle(IssueInvoicesJobTrigger $trigger): int
{
$billingDate = $this->option('date') ?: now()->toDateString();
$trigger->dispatchForDate($billingDate);
$this->info("Dispatched invoice issuing jobs for date={$billingDate}.");
return Command::SUCCESS;
}
}
全体の実行フロー
- Linuxのcronサービスが動きます
- cronの実行によりLaravelのスケジューラーが動きます
- スケジューラーに登録されたArtisanコマンドが、指定タイミングで実行されます
- Artisanコマンドがジョブトリガーを実行します
- ジョブトリガーが、多数のジョブをキュー保存先に登録します(キュー/ジョブの形で積みます)
- 登録されたジョブが順に実行され、ユニット単位でロジックを処理します
フォルダ構成
app
|- Console
| |- Commands
| |- Batches
| |- Billing
| |- DispatchIssueInvoices.php
| |- Maintenance
| |- DispatchDataRepair.php
|
| |- Kernel.php
|
|- Batches
| |- JobTriggers
| | |- Billing
| | | |- IssueInvoicesJobTrigger.php
| | |- Maintenance
| | |- DataRepairJobTrigger.php
| |
| |- Units
| |- Billing
| | |- IssueInvoiceUnit.php
| |- Maintenance
| |- RepairRecordUnit.php
|
|- Jobs
| |- Billing
| | |- IssueInvoiceJob.php
| |- Maintenance
| |- RepairRecordJob.php
|
|- Models
|- Subscription.php
|- Invoice.php
|- SomeDomainModel.php
サービス改善
現職でメイン担当していたプロジェクトは、Laravelスケジューラーを中心に回っている2年目のサービスでした。以前の構造では、1分ごとにcronが実行され、1回に1件ずつトランザクション処理していたため、全体のバッチ処理が非常に遅く、非エンジニアが結果を検証しづらい状況でした。
サービス規模が大きくなるにつれ、一部のバッチ処理が完了するまでに5〜10分かかるようになり、cronが1分ごとに重複実行されて、瞬間的にサーバー上で5〜10個のバッチが同時に動いてメモリ不足問題でサーバーがダウンする障害が発生しました。
また、失敗したタスクを復旧する際も、タスクを小さな単位に分割して処理していなかったため、復旧に多くの時間がかかりました。
この問題を解決するため、バッチ構造をキュー/ジョブベースに全面改善しました。その結果、
- 一部のケースでは、数時間かかっていたバッチが数十秒で完了し、数時間かかっていた作業の多くも数分〜数十分以内に短縮されました
- 非エンジニアでもバッチ動作と結果を検証しやすくなりました
- タスクが順に安定して処理され、メモリ使用量も予測可能な範囲で安定して管理できるようになりました
- 問題が発生したときに、小さな単位で復旧作業を段階的に進めやすくなりました
- その後も現在まで、目立った障害なくバッチ処理を安定運用できています
- さらに、バッチ処理をパターン化して、他の開発者も同じ型で良いコードを書けるようにしました
おわりに
Springのようにバッチ処理構成がよく知られたパターンに沿う場合もありますが、言語・フレームワーク・利用ライブラリによってバッチ処理のツール機能が違うため、戦略は言語やフレームワークごとに変わり得ます。また、フレームワークやライブラリが明確なガイドラインを出していないこともあり、バッチ処理の構成はプロジェクトの特徴によって変わります。たとえば、速度を重視するか、メンテナンス性を重視するか、といった価値観でも設計は変わります。
ここで紹介した方法は、私が担当しているプロジェクトで過去4年間、実務で大きな問題なく使ってきたパターンです。ここではこれを Trigger-Job-Unit パターンと呼ぶことにします。
要するに、Trigger–Job–Unitパターンは、「対象選定(Trigger)–キュー実行(Job)–トランザクション単位のロジック(Unit)」として責務を分離することです。
12月25日はクリスマスです。クリスマスの起源は、暗い世の中で人々の心に喜びと希望をもたらす「光」の誕生を意味すると言われています。
この記事がバッチ処理に悩んでいる多くの方々にとっても、光になって問題解決の良い手がかりとなることを願っております。
-
Artisanコンソールは、Laravelの機能をコマンドラインから実行できる仕組みです。Laravel標準の
php artisan SomeCommandだけでなく、開発者が作成したArtisanコマンドもphp artisan CustomCommandとして実行できます。作り方は公式ドキュメントを参照してください。 ↩ -
サンプルコードはOpenAIのChatGPT("ChatGPT 5.2")を参考に下書きを作成し、最終的には筆者が確認・修正しました。 ↩
-
ENTRYPOINTを使う場合、entrypoint.shのようなシェルスクリプトがコンテナのPID 1で動きます。このときスクリプト内でphp artisan queue:workをexecなしで起動すると、ワーカーが子プロセスとして動く場合があります。コンテナ終了/入れ替え時に、DockerがPID 1へ送るSIGTERM(終了シグナル)がワーカーまで届かず、ワーカーが正常終了(graceful shutdown)できないことがあります。そのためexec php artisan queue:work ...のように、ワーカーがPID 1として動くように構成するほうが安全です。そうしないと最後のジョブを完了できず中断したり、アプリケーション側の後処理(ロック解除など)が走らずRedisロックが残る、といった問題が起きる可能性があります。 ↩ -
メモリリークが起きないようにコードを作れれば理想ですが、レガシーコードやライブラリ、フレームワーク側の実装まで含めると、長時間実行を前提にしていないコードが混ざる可能性が高い言語生態系です。そのため、常にメモリリークが起き得る前提で考えるほうが安全です。 ↩
-
サンプルコードはOpenAIのChatGPT("ChatGPT 5.2")を参考に下書きを作成し、最終的には筆者が確認・修正しました。 ↩
-
フォルダ位置を変える方法は見つけられていませんが、もし可能なら構造上は
Job/*よりBatches/Job/*に置くほうが分かりやすいと思います。 ↩ -
サンプルコードはOpenAIのChatGPT("ChatGPT 5.2")を参考に下書きを作成し、最終的には筆者が確認・修正しました。 ↩
-
サンプルコードはOpenAIのChatGPT("ChatGPT 5.2")を参考に下書きを作成し、最終的には筆者が確認・修正しました。 ↩
-
サンプルコードはOpenAIのChatGPT("ChatGPT 5.2")を参考に下書きを作成し、最終的には筆者が確認・修正しました。 ↩