Laravelのキュージョブを冪等にする — uniqueジョブに頼らない設計
この記事で扱う問題
Laravel のキューは「少なくとも1回は実行する」仕組みです。「ちょうど1回」は保証されません。例えば、次のような場面で同じジョブが2回実行されます。
- ジョブの途中で例外が出て、リトライされた。ただし例外の前に、ポイントの付与や外部 API の呼び出しは済んでいた
- 処理が
retry_after(SQS なら可視性タイムアウト)より長くかかり、まだ実行中のジョブが別のワーカーにも渡された - DB へのコミットは終わったが、キューからジョブを消す前にワーカーが停止した(デプロイ時の再起動など)
- 利用者の操作やバッチの再実行で、同じ内容のジョブがもう一度ディスパッチされた
「注文確定のポイントを付与する」「配送業者に出荷を依頼する」といったジョブが2回走ると、二重付与や二重出荷になります。
Laravel には ShouldBeUnique や WithoutOverlapping があり、これを付ければ重複を防げると考えがちです。ただ、これらが防ぐのは上の一部だけです。
判断
重複実行は起きる前提で、ジョブ自体を、何回実行しても結果が1回と同じになるように作ります。ShouldBeUnique などは、無駄な実行を減らす最適化として使い、正しさの保証には使いません。
ジョブは処理に必要な値だけを受け取り、判断は UseCase に任せる
final class GrantOrderPointsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 5;
public int $timeout = 30; // retry_after より数秒以上短くする
public function __construct(
public readonly string $orderId,
public readonly int $customerId,
public readonly int $points,
) {
}
/** @return list<int> */
public function backoff(): array
{
return [10, 60, 300];
}
public function handle(GrantOrderPoints $useCase): void
{
$useCase->execute(new PointGrant(new OrderId($this->orderId), $this->customerId, $this->points));
}
}
付与するポイント数は、注文を確定したときに決めてジョブに載せています(ジョブの実行時に注文を読み直して計算する形でも構いません)。ジョブの中に業務の判断は書かず、UseCase を呼ぶだけにします。こうしておくと、同じ処理を管理画面やコマンドからやり直すときも、同じ冪等性の仕組みを通ります。
「1回だけ」を DB の一意制約で守る
final readonly class PointGrant
{
public function __construct(
public OrderId $orderId,
public int $customerId,
public int $points,
) {
}
}
interface PointLedger
{
/** まだ付与していなければ付与して true を、付与済みなら何もせず false を返す */
public function grantOnce(PointGrant $grant): bool;
}
final class GrantOrderPoints
{
public function __construct(
private PointLedger $ledger,
private TransactionManager $transaction,
) {
}
public function execute(PointGrant $grant): void
{
$this->transaction->run(fn () => $this->ledger->grantOnce($grant));
}
}
実装では、付与の記録を先に INSERT し、一意制約(order_id と reason の組み合わせ)で2回目を弾きます。残高の加算は、記録の INSERT に成功したときだけ、同じトランザクションで行います。
final class DatabasePointLedger implements PointLedger
{
public function grantOnce(PointGrant $grant): bool
{
// point_grants には (order_id, reason) の一意制約がある
$inserted = DB::table('point_grants')->insertOrIgnore([
'order_id' => $grant->orderId->value,
'reason' => 'order_confirmed',
'customer_id' => $grant->customerId,
'points' => $grant->points,
]);
if ($inserted === 0) {
return false; // 付与済み。リトライや重複ディスパッチはここで止まる
}
// point_balances の行は、会員登録時に作られている前提
DB::table('point_balances')
->where('customer_id', $grant->customerId)
->increment('balance', $grant->points);
return true;
}
}
insertOrIgnore() は、MySQL では INSERT IGNORE、PostgreSQL では INSERT ... ON CONFLICT DO NOTHING になります。retry_after を超えて2つのワーカーが同時に実行した場合も、MySQL(InnoDB)の既定の REPEATABLE READ や PostgreSQL の既定の READ COMMITTED なら、後から来た INSERT は先の INSERT のトランザクションが終わるまで待ち、コミットされていれば何もしません。PostgreSQL の REPEATABLE READ 以上では、待ったあとに serialization failure になることがありますが、その場合もジョブのリトライで再実行され、2回目は付与済みとして止まります。
注意点として、MySQL の INSERT IGNORE は一意制約の違反以外のエラー(型の不一致による値の切り詰めなど)も警告に変えてしまいます。入力を UseCase の手前で検証しておくか、MySQL では INSERT ... ON DUPLICATE KEY UPDATE で何も変えない更新にする書き方も選べます。
外部 API を呼ぶジョブは、業務から決まるキーを渡す
外部 API の呼び出しは DB のトランザクションで巻き戻せません。相手が Idempotency Key に対応していれば、業務の操作から決まるキーを渡します。
// RequestShipmentJob の handle()。$orderId は GrantOrderPointsJob と同じくコンストラクタで受け取る前提
public function handle(ShippingGateway $shipping): void
{
// ジョブの UUID は、同じ操作を再ディスパッチすると変わるのでキーに使わない
$shipping->requestShipment(
new OrderId($this->orderId),
idempotencyKey: "shipment-request:{$this->orderId}",
);
}
$this->job->uuid() はリトライの間は同じですが、同じ操作をもう一度ディスパッチすると別の値になります。「この注文の出荷依頼」のように、操作そのものから決まる値をキーにします。相手が Idempotency Key に対応していない場合の考え方(結果が分からない依頼を「要確認」として残す)は、冪等性の記事と同じです。
なぜそう判断したか
ShouldBeUnique が防げるもの・防げないもの
ShouldBeUnique は、ディスパッチの時点でキャッシュのロックを取り、同じ uniqueId() のジョブがすでにキューにある(または処理中の)間は、新しいディスパッチを捨てます。ロックはジョブの処理が終わるか、リトライをすべて失敗したときに解放されます。
防げるのは「同じジョブが同時期にキューに2つ積まれる」ことだけです。次のものは防げません。
- 同じジョブのリトライ:ロックはディスパッチの時点で確かめるものなので、キューにあるジョブ自体の再実行は止めません。例外の前に済んだ副作用は、2回目でもう一度実行されます
retry_afterを超えた実行の重なり:同じジョブが2つのワーカーに渡るのも、ディスパッチを経ない再配送なので止まりません- 処理が終わった後の再ディスパッチ:ロックはすでに解放されているので、同じ注文のジョブがもう一度ディスパッチされれば、そのまま実行されます
- ロックの消失:
uniqueForの期限切れや、キャッシュ(Redis など)の障害・退避でロックが消えれば、重複を通します - バッチの中のジョブ:
Bus::batch()で投入したジョブには、unique の制約が適用されません
WithoutOverlapping ミドルウェアは、実行の時点でロックを取り、同じキーのジョブが同時に走るのを防ぎます。重なりには効きますが、処理が終わった後に同じジョブがもう一度走ることは防げません。
どちらも、ロックの置き場所がキャッシュで、業務のデータとは別の場所にあります。「付与したかどうか」は、付与の記録と同じ DB、同じトランザクションで判断しないと、ずれる瞬間が必ず残ります。
タイムアウトの設定は、重複の頻度を下げるだけ
ジョブの $timeout は、キューの retry_after より数秒以上短くします。逆になっていると、まだ実行中のジョブが別のワーカーに渡され、重複が日常的に起きます。ただ、この設定を正しくしても、ワーカーの停止やコミット後の失敗による重複はなくなりません。頻度を下げる設定と、正しさを守る仕組みは分けて考えます。
ジョブの中で状態を判断しない理由
ジョブにポイント付与の判断を直接書くと、同じ付与を管理画面の「再付与」ボタンやコマンドから実行するとき、冪等性の仕組みを別に書くことになります。UseCase と一意制約に寄せておけば、どの経路から呼んでも2回目は止まります。キューはあくまで「いつ実行するか」の手段で、「何回実行してよいか」を決める場所ではない、という整理です。
どこから設計過剰になるか
- 集計の作り直し、検索インデックスの更新、キャッシュの再生成のように、結果を上書きするだけのジョブはもともと冪等です。付与記録のような仕組みは要りません。こうしたジョブには、無駄な実行を減らす目的で
ShouldBeUniqueを付けるのが適しています - 社内向けの通知のように、まれに2通届いても困らないものは、重複を許容して「少なくとも1回」のままにするほうが単純です。どこまで許容するかは、重複したときに誰が困るかで決めます
- 付与記録のテーブルは、保存期間を決めないと増え続けます。ただしポイントのように後から照合が必要な記録は、冪等性のためだけでなく履歴としても残す価値があるので、消すかどうかは業務の要件で決めます
まとめ
- Laravel のキューは「少なくとも1回」。リトライ・タイムアウト・ワーカーの停止で、同じジョブは2回実行される
ShouldBeUniqueとWithoutOverlappingは重複を減らす最適化で、正しさは一意制約と同じトランザクションで守る- 外部 API には、ジョブの UUID ではなく業務の操作から決まる Idempotency Key を渡す