Skip to main content

トランザクションと同時実行制御 — 在庫の二重引き当てを塞ぐ

第1章で挙げた 4 つの問題のうち、まだ手を付けていないものが 1 つ残っています。在庫の二重引き当てです。

第5章では、在庫チェックを Form Request に書かない理由として「確認した時点と減らす時点がずれる」ことを挙げました。第6章では StockChecker を書いたあとに「この実装はまだ安全ではありません」と警告を貼りました。どちらも、解決をこの章に預けています。ここで回収します。

いま何が壊れているか

第6章で書いた OrderPlacementService の処理順を思い出してください。

  1. assertEnough で在庫が足りるか確認する
  2. 注文と明細を作る
  3. decrease で在庫を減らす

このコードには、性質の違う壊れ方が 2 つあります。混ざりやすいので、先に分けておきます。

壊れ方 1: 途中で失敗すると中途半端に残る

明細の作成中に例外が起きたとします。注文レコードは既に保存されています。在庫はまだ減っていません。注文はあるのに商品が確保されていない状態が、そのままデータベースに残ります。

第10章で「エラーを返しても途中まで処理が進んでいることがある」と書いたのは、この状態を指しています。

壊れ方 2: 同時に来ると二重に引き当てる

在庫が 1 個の商品に、2 人が同時に注文したとします。

A の確認と減算のあいだに、B の確認が割り込みます。B から見ても在庫は 1 個あるので、チェックを通過します。

この 2 つには別々の対策が要ります。 壊れ方 1 はトランザクションで解決します。壊れ方 2 はトランザクションでは解決しません。ここが本章でいちばん取り違えられる点です。

トランザクション — 全部やるか、何もやらないか

DB::transaction() にクロージャを渡すと、その中の処理がまとめて 1 つの単位になります1。例外が投げられれば自動的にロールバックし、例外はそのまま外へ再送出されます。最後まで通れば自動的にコミットされます。

app/Services/OrderPlacementService.php
use Illuminate\Support\Facades\DB;

public function place(User $user, array $items): Order
{
return DB::transaction(function () use ($user, $items) {
$this->stockChecker->assertEnough($items);
$this->stockChecker->decrease($items);

// ... 注文と明細の作成 ...

return $order;
});
}

return を付けています。 DB::transaction() はクロージャの戻り値をそのまま返すので、注文をこの形で取り出せます。

これで壊れ方 1 は解決しました。明細の作成に失敗すれば、注文の作成も在庫の減算もなかったことになります。全部やるか、何もやらないかの 2 択になります。

トランザクションだけでは足りない

壊れ方 2 は残ったままです。トランザクションは「自分の書き込みをまとめる」仕組みであって、「他人の割り込みを止める」仕組みではありません。

先ほどの図で、A と B がそれぞれトランザクションに入っていたとします。順序は変わりません。両方とも在庫 1 を読み、両方とも足りると判断します。2 つとも正常に完了するので、ロールバックも起きません。

止めたいのは、A が在庫を読んでから減らし終えるまで、B に同じ行を触らせないことです。それが行ロックです。

行ロック — lockForUpdate

lockForUpdate() を付けて読むと、読んだ行に排他ロックがかかります2。他のトランザクションは、こちらがコミットするまでその行を更新できず、同じロックも取れません

普通の SELECT は止まりません。 ロックを取らない読み取りは、ロックを無視して更新前の値を返します3。止まるのは、書き込もうとした側とロックを取ろうとした側だけです。在庫を減らそうとする別の注文は、こちらが終わるまで待たされます。

StockChecker::assertEnough を書き換えます。第6章からの変更は 2 行です。

app/Services/StockChecker.php
public function assertEnough(array $items): void
{
$stocks = Stock::whereIn('product_id', array_column($items, 'product_id'))
->orderBy('product_id')
->lockForUpdate()
->get()
->keyBy('product_id');

foreach ($items as $item) {
$available = $stocks->get($item['product_id'])?->quantity ?? 0;

if ($available < $item['quantity']) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: $available,
);
}
}
}

これで B は、A がコミットするまで在庫行を読めません。A が在庫を 0 にしてコミットしたあと、B は 0 を読んで 409 を返します。

ロックはトランザクションの中でしか意味を持たない

lockForUpdate() を単独で呼んでも、ほとんど何も守りません。 ロックはトランザクションの終わりで解放されます。トランザクションに入っていなければ、そのクエリが終わった瞬間に解放されます。読み終えた直後に他のリクエストが入れるので、隙間は塞がりません。

assertEnough のコードだけを見ても、この前提は読み取れません。呼び出し側がトランザクションを張っているかどうかで、意味が変わります。メソッドに隠れた前提があるときは、呼び出し側と一緒に読む必要があります。

ロックの範囲を在庫行に閉じ込める

ロックしているのは stocks の行だけです。products には触れていません。

第3章で在庫を別テーブルに分けたのは、このためでした。もし在庫数が products のカラムだったら、在庫を確保するあいだ商品情報を書き換える処理まで待たされます。価格改定や説明文の修正が、誰かの注文処理の裏で止まります。

商品一覧を表示するだけの読み取りは、どちらの設計でも止まりません。待たされるのは書き込む側です。 それでも、在庫の増減は注文のたびに起こるのに対し、商品情報の更新はたまにしか起こりません。頻度の高い処理のロックに、頻度の低い処理を巻き込む理由はありません。

テーブルを分けておけば、ロックの影響は在庫を触る処理だけに収まります。

SQLite では行ロックが効かない

第1章で、この連載の環境を SQLite としました。SQLite に対しては lockForUpdate() が SQL を何も生成しません。

Laravel の SQLite 用のクエリ文法は、ロック句を組み立てる処理が空の文字列を返すよう実装されています4->lockForUpdate() と書いても、発行される SQL は付けなかった場合と一字も変わりません。

手元で試して動いても、守られたことにはなりません

SQLite で lockForUpdate() を付けたコードは、エラーも警告も出さずに動きます。ロックがかかっていないだけです。 「動いたから直った」と判断できません。

行ロックの効果を確かめたいなら、MySQL か PostgreSQL を使ってください。

このため、第3章で「在庫を別テーブルにすればロックの範囲を閉じ込められる」と書いた話も、ロック句を生成するデータベースでの話でした。設計としては正しいままですが、効果が出る条件が付きます。

環境によって黙って無効になる仕組みだけに頼るのは危険です。どのデータベースでも同じ SQL になる書き方を、もう 1 つ用意します。

読んでから書くのをやめる

競合が起きるのは、読んでから書くまでに隙間があるからです。隙間をなくす方法として、ロック以外にもう 1 つあります。読むのをやめて、書き込み自体に条件を付けることです。

UPDATE stocks SET quantity = quantity - 3
WHERE product_id = 1 AND quantity >= 3

この UPDATE は 1 文で完結します。「在庫が足りていれば減らす」が分割できない 1 つの操作になります。足りなければ、条件に合う行が無いので何も更新されません。

更新された行数を見れば、成功したか分かります。Laravel の decrement() は影響行数を返します5

app/Services/StockChecker.php
public function decrease(array $items): void
{
foreach ($items as $item) {
$affected = Stock::where('product_id', $item['product_id'])
->where('quantity', '>=', $item['quantity'])
->decrement('quantity', $item['quantity']);

if ($affected === 0) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: Stock::where('product_id', $item['product_id'])->value('quantity') ?? 0,
);
}
}
}

0 行だったときに在庫を読み直しています。 影響行数からは「足りなかった」しか分かりません。第9章で決めたエラーの形は available を含むので、実際の残数が要ります。ここで 0 を代入すると、在庫 3 個の商品に 5 個注文した人へ「在庫なし」と伝えることになります。読み直しのクエリが 1 回増えますが、エラーのときだけです。

それでもトランザクションは要る

条件付き UPDATE は、1 行ぶんの安全しか保証しません。

明細が 2 件ある注文を考えます。1 件目の在庫は足りていて減算に成功し、2 件目で足りずに例外が飛びました。1 件目の減算はもう実行されています。トランザクションが無ければ、注文は作られていないのに在庫だけ減った状態が残ります。

複数の行にまたがる操作をまとめるのはトランザクションの仕事で、1 行の競合を防ぐのは条件付き UPDATE の仕事です。役割が違うので、片方が片方の代わりにはなりません。

2 つを両方入れる

最終形には行ロックと条件付き UPDATE の両方を入れます。

技法効くこと効かないこと
行ロック読んだあとの状態が安定する。書く前に全明細をまとめて検査でき、正確な残数を返せるSQLite ではロック句が出ない。トランザクションの外では意味を持たない
条件付き UPDATE1 行の減算そのものが競合に耐える。どのデータベースでも同じ SQL になる不足が分かるのが書き込み時点。複数明細なら既に減らした行が残る

ロック句が出るデータベースでは、ロックが主な防御になります。SQLite では条件付き UPDATE だけが働きます。どちらの環境でも、在庫が負になる経路は残りません。

デッドロック

複数の行をロックすると、新しい問題が生まれます。

商品 1 と商品 2 を含む注文が、同時に 2 件来たとします。A が商品 1 をロックし、B が商品 2 をロックしました。次に A は商品 2 を待ち、B は商品 1 を待ちます。どちらも相手のロックが解けるのを待ち続けます。 これがデッドロックです。

対策は単純で、全員が同じ順序で行を触ることです。輪ができなければ、待ち合いも起きません。

app/Services/OrderPlacementService.php
usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);

明細を product_id の昇順に並べ替えてから処理します。効くのは decrease のループです。1 件ずつ UPDATE を発行するので、並び順がそのままロックを取る順序になります。A も B も商品 1 から先に触るので、あとから来たほうが待つだけで済みます。

assertEnoughwhereIn の 1 文で全行をまとめて読むため、PHP 側の並べ替えは影響しません。1 文の中でどの行から順にロックが取られるかは、データベースの実行計画が決めます。 orderBy('product_id') を付けているのは意図を示すためで、ロックの取得順を保証するものとして読まないでください。順序を確実に揃えたいなら、decrease のように 1 行ずつ処理する形にします。

それでも起きたら再試行する

順序を揃えても、デッドロックを完全には避けられません。別の処理が別の順序で同じ行を触るかもしれません。

DB::transaction() の第 2 引数に試行回数を渡すと、デッドロック時にクロージャを最初から実行し直します1

}, attempts: 3);

この再試行は SQLite でも意味を持ちます。 Laravel が再試行すべきエラーとして扱う文字列の一覧には、SQLite が返す database is lockeddatabase table is locked が含まれています6。ロック句を生成しないことと、ロック起因のエラーを再試行しないことは別の話です。

再試行が効くのは、いちばん外側のトランザクションだけです

DB::transaction() は入れ子にできますが、内側は SAVEPOINT になります。内側で attempts: を指定しても再試行されず、DeadlockException がそのまま投げられます7。外側のトランザクションまでは巻き戻せないためです。

呼び出し側が既にトランザクションを張っていないか、意識しておいてください。テストで RefreshDatabase を使っている場合も、各テストがトランザクションに包まれています。

トランザクションの中でやってはいけないこと

再試行の仕組みには、見落としやすい前提があります。再試行はクロージャをもう一度実行します。 部分的にやり直すのではなく、頭から実行し直します。

データベースへの書き込みはロールバックされるので、やり直しても問題ありません。データベースの外に出た操作は戻りません。

❌ Bad: トランザクションの中で外に出る
DB::transaction(function () use ($user, $items) {
$order = $this->createOrder($user, $items);

$user->notify(new OrderConfirmed($order)); // 送信済みは取り消せない
$this->paymentGateway->charge($order->total_amount); // 課金済みは取り消せない

return $order;
}, attempts: 3);

デッドロックで 3 回試行されれば、確認メールが 3 通届き、3 回課金されます。トランザクションがロールバックされても、送ったメールは戻ってきません。

再試行が無くても問題は残ります。外部 API の応答が遅ければ、そのあいだトランザクションが開いたままになります。ロックを握ったまま数秒待つことになり、他のリクエストが詰まります。

トランザクションの中には、データベースへの操作だけを置きます。 メール送信・外部 API 呼び出し・キューへの投入は、コミットが終わってから実行します。キューについては、投入をコミット後まで遅らせる afterCommit の設定を第16章で扱います。

二重注文を一意制約で止める

ここまでで在庫は守れました。残っているのは、同じ注文が 2 回作られる場合です。

利用者が送信ボタンを二度押したとき。ネットワークが不安定でクライアントが自動再送したとき。どちらもサーバーから見ると、区別のつかない 2 つのリクエストが届きます。

第13章で、Webhook が 5xx を返すと再送されると書きました。同じイベントが 2 回届く前提で作る必要がある、とも書きました。ここで扱うのは、その一般形です。決済業者から届くイベント ID を、これから作る仕組みの鍵として使えば、同じ入金を二重に記録せずに済みます。

キューに入れたジョブが 2 回実行される場合は、第17章で扱います。 受け口で重複を止めるのがこの章、実行側で重複に耐えるのが第17章です。

クライアントに鍵を持たせる

サーバー側だけでは、2 回目が再送なのか、本当に 2 件目を注文したいのか判断できません。同じ操作であることを知っているのはクライアント側です。

そこで、リクエストごとに一意な値をクライアントに付けてもらいます。Idempotency-Key というヘッダ名が使われます8

POST /api/v1/orders HTTP/1.1
Idempotency-Key: 9f2b1c4e-6a3d-4f8b-9c1e-2d5a7b0f3e4c

再送するときは、1 回目と同じ値を送ります。サーバーは、この値を見て再送かどうかを判断します。

保存先に一意制約を置く

判断をアプリケーションのコードだけでやると、確認と保存のあいだにまた隙間ができます。この章でずっと扱ってきた問題と同じ構造です。

隙間を作らないために、データベースの一意制約を使います。

database/migrations/xxxx_add_idempotency_key_to_orders_table.php
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->string('idempotency_key')->nullable()->after('total_amount');
$table->unique(['user_id', 'idempotency_key']);
});
}

user_id との複合にしています。 キーの値はクライアントが決めるので、別の利用者が偶然同じ文字列を使うかもしれません。idempotency_key だけに一意制約を置くと、2 人目には他人の注文が返ります。エラーになるより悪い結果です。利用者ごとに一意であれば足ります。

nullable にしています。 ヘッダを送らないクライアントもあります。NULL 同士は一意制約の重複と見なされないので、ヘッダ無しの注文はいくつでも作れます。この列を必須にすると、これまでの章で書いたテストとファクトリが全部通らなくなります。

モデル側にも追加します。第11章で user_id を足したときと同じ作業です。

app/Models/Order.php
#[Fillable(['user_id', 'customer_email', 'status', 'total_amount', 'idempotency_key'])]

忘れても、エラーは出ません。 第3章で触れたとおり、#[Fillable] に無い属性は既定では黙って捨てられますidempotency_key が常に NULL になり、一意制約は一度も発火しません。再送の防止が丸ごと効かない状態が、何の兆候も出さずに完成します。 移行ファイルだけ作って属性を足し忘れる形が起こりやすいので、両方を 1 組として扱ってください。

2 回目に何を返すか

一意制約に引っかかったとき、エラーを返したくなります。返しません。

冪等性とは「同じリクエストを何度送っても結果が 1 つになること」です。再送してきたクライアントが欲しいのは、エラーではなく1 回目の結果です。ネットワークが切れて応答を受け取れなかったクライアントに 409 を返すと、注文できたのかどうか分からないままになります。

Idempotency-Key の仕様草案も、完了済みの操作については 1 回目の結果を返すよう定めています8

探すのは、在庫を触る前です。 順序を間違えると、再送が別の理由で失敗します。在庫 10 個の商品を 3 個注文して成功し、その応答がクライアントに届かなかったとします。そのあいだに他の利用者が残り 7 個を買い切りました。ここでクライアントが同じキーで再送すると、在庫の確認が先に走って在庫不足の 409 を返します。注文は成立しているのに、失敗したと伝えることになります。 既存の注文を先に探せば、在庫の判定に入らずに済みます。

app/Services/OrderPlacementService.php
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;

public function place(User $user, array $items, ?string $idempotencyKey = null): Order
{
if ($idempotencyKey !== null) {
$existing = $this->findByIdempotencyKey($user, $idempotencyKey);

if ($existing !== null) {
return $existing;
}
}

usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);

try {
return DB::transaction(function () use ($user, $items, $idempotencyKey) {
$this->stockChecker->assertEnough($items);
$this->stockChecker->decrease($items);

$products = Product::findMany(array_column($items, 'product_id'))->keyBy('id');

$total = 0;
foreach ($items as $item) {
$total += $products[$item['product_id']]->price * $item['quantity'];
}

$order = Order::create([
'user_id' => $user->id,
'customer_email' => $user->email,
'status' => OrderStatus::Pending,
'total_amount' => $total,
'idempotency_key' => $idempotencyKey,
]);

foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $products[$item['product_id']]->price,
]);
}

return $order;
}, attempts: 3);
} catch (UniqueConstraintViolationException $e) {
$existing = $idempotencyKey === null
? null
: $this->findByIdempotencyKey($user, $idempotencyKey);

if ($existing === null) {
throw $e;
}

return $existing;
}
}

private function findByIdempotencyKey(User $user, string $key): ?Order
{
return Order::where('user_id', $user->id)
->where('idempotency_key', $key)
->first();
}

見つからなければ例外を投げ直しています。 この catch に来る一意制約違反が、必ず idempotency_key によるものとは限りません。第3章のスキーマでは orders に他の一意制約が無いので現状は 1 つに絞れますが、あとから列を足せば話が変わります。該当する注文が見つからないなら、原因は別にあります。 握りつぶさずに投げ直せば、想定外の例外として第9章の仕組みが 500 と追跡 ID を返します。firstOrFail() で済ませると、原因の違う不具合が 404 になって調査を誤らせます。

鍵が null のときは検索しません。 where('idempotency_key', null) は「その列が NULL の行」を探す条件になり、鍵を付けずに出した過去の注文を拾います。鍵を送っていないリクエストに対して、まったく別の注文を 200 で返すことになります。現在のスキーマでは NULL 同士が一意制約に当たらないためこの経路には入りませんが、条件式の意味は明示的に閉じておきます。

catch はトランザクションの外に置いています。 中で受けると、例外のあとに書いた処理が同じトランザクションの一部として commit されます。ロールバックさせたいから例外にしたのに、握りつぶした結果が保存されます。外に置けば、DB::transaction() がロールバックを済ませてから例外を再送出します。

現在のスキーマでこの catch に来るのは、同時に届いた再送だけです。 通常の再送は先頭の検索で見つかって返ります。1 回目がまだコミットしていないうちに 2 回目が届いた場合だけ、検索が空振りして両方が処理に進み、あとから来たほうが一意制約に当たります。アプリケーションの検索では埋められない隙間を、データベースの制約が塞いでいます。 この章でずっと扱ってきた「確認と書き込みのあいだ」と同じ構造です。

コミット前の再送は、まだ取りこぼします

1 回目がコミットする前に届いた再送は、事前の検索で見つかりません。残りが最後の 1 個だった場合、2 回目は在庫不足の 409 を受け取ります。 1 回目が成功するので、クライアントには「失敗した」と伝わったまま注文が成立します。

一意制約が守るのは「注文が 2 件できないこと」までで、この取りこぼしは塞いでいません。塞ぐには、注文本体とは別に「このキーで処理を開始した」という記録を先に作り、2 回目はそれを見て待つか 409 を返す形にします。仕様草案が 409 を割り当てているのも、この「処理中」の状態です8

この連載では実装しません。 クライアントが同じキーで再試行を続ければ、コミット後には正しい結果を受け取れるためです。

このとき、2 回目が減らした在庫はロールバックで戻ります。在庫の減算を注文の作成より前に置いているので、注文だけが失敗して在庫が減ったままになる状態は残りません

UniqueConstraintViolationException は Laravel が用意している例外で、一意制約違反のときだけ投げられます9。SQLite でも投げられます。

一意制約違反を広く捕まえないでください

この catch はサービスクラスの中に閉じています。bootstrap/app.phpUniqueConstraintViolationException を一律に変換すると、メールアドレスの重複まで「二重注文」として扱われます。捕まえる範囲は、原因が 1 つに絞れる場所に置きます。

コントローラで受け取る

ヘッダを読んでサービスへ渡します。

app/Http/Controllers/OrderController.php
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$order = $service->place(
$request->user(),
$request->validated('items'),
$request->header('Idempotency-Key') ?: null,
);

return (new OrderResource($order))
->response()
->setStatusCode($order->wasRecentlyCreated ? 201 : 200);
}

?: null で空文字を潰しています。 header() はヘッダが無いときに null を返しますが、値が空のヘッダに対しては空文字を返します10。空文字は NULL と違って重複と見なされます。潰さずに渡すと、常に空値を送るクライアントに対して、2 件目以降の注文がすべて 1 件目の注文の使い回しになります。新しい注文を出したつもりが、古い注文が 200 で返ってくる形です。エラーが出ないぶん気づきにくい壊れ方です。

wasRecentlyCreated で状態コードを分けています。 この属性は、そのインスタンスが今 INSERT で作られたときだけ true になります。既存の行を読み出した場合は false です。新規作成なら 201、再送に対する応答なら 200 を返します。

テスト

第11章で認証が必要になったので、注文を作るテストは Sanctum::actingAs() で認証済みの状態を作ります。

在庫が減ることを確認する

第10章で書いたテストがそのまま検出器になります。

tests/Feature/OrderCreationTest.php
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 7, // 10 - 3
]);

トランザクションを入れた結果、在庫の減算が消えていないかを確認します。第10章で「第14章の検出器になる」と書いたのはこれです。

在庫切れで注文が残らないことを確認する

第3章で用意した soldOut() を使います。

tests/Feature/OrderCreationTest.php
test('在庫が足りないと409になり注文は作られない', function () {
Sanctum::actingAs(User::factory()->create());

$product = Product::factory()->create();
Stock::factory()->soldOut()->create(['product_id' => $product->id]);

$this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
])
->assertStatus(409)
->assertJsonPath('code', 'INSUFFICIENT_STOCK');

$this->assertDatabaseCount('orders', 0);
});
このテストはトランザクションを検証していません

assertEnough が注文を作る前に例外を投げるので、トランザクションを外してもこのテストは通ります。書き込みがそもそも起きないためです。

第10章に実質同じテストが既にあります。「green だから直った」と読まないでください。 この章で入れた仕組みのうち、テストで確認できるものと確認できないものは、このあとで分けて扱います。

再送で二重注文にならないことを確認する

tests/Feature/OrderIdempotencyTest.php
use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('同じキーで2回送っても注文は1件だけになる', function () {
Sanctum::actingAs(User::factory()->create());

$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);

$payload = ['items' => [['product_id' => $product->id, 'quantity' => 3]]];
$headers = ['Idempotency-Key' => 'test-key-001'];

$first = $this->withHeaders($headers)->postJson('/api/v1/orders', $payload);
$first->assertStatus(201);

$second = $this->withHeaders($headers)->postJson('/api/v1/orders', $payload);
$second->assertStatus(200);

expect($second->json('data.id'))->toBe($first->json('data.id'));

$this->assertDatabaseCount('orders', 1);
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 7, // 10 - 3。2 回引かれていない
]);
});

4 つ確認しています。1 回目が 201、2 回目が 200 であること同じ注文が返ること注文が 1 件だけであること、そして在庫が 1 回しか減っていないことです。

正常系も含めています。第13章で、異常系だけのテストは対象が存在しなくても通ることを扱いました。1 回目の 201 を確認しておかないと、常にエラーになる実装でもこのテストが通ります。

条件付き UPDATE そのものを確認する

decrease の条件付き UPDATE は、SQLite での唯一の防御です。それなのに、ここまでの 3 つのテストは 1 つもこの経路を通りません。assertEnough が先に在庫を確認して例外を投げるので、影響行数が 0 になる分岐まで到達しないためです。

API 越しでは到達できないので、クラスを直接呼びます。

tests/Feature/StockCheckerTest.php
use App\Exceptions\InsufficientStockException;
use App\Services\StockChecker;
use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('在庫より多い数を減らそうとすると例外になり在庫は変わらない', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 2]);

expect(fn () => app(StockChecker::class)->decrease([
['product_id' => $product->id, 'quantity' => 5],
]))->toThrow(InsufficientStockException::class);

$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 2,
]);
});

where('quantity', '>=', ...) を外すと、このテストは red になります。例外が飛ばず、在庫が -3 になるからです。条件付き UPDATE を消したら気づける状態になりました。

ロールバックを確認する

DB::transaction() を外すと red になるテストも、同じやり方で書けます。

ロールバックが働くには、書き込みが 1 つ成功したあとに次が失敗する必要があります。HTTP 越しでは作れません。在庫が足りない明細は assertEnough が書き込みの前に弾き、同じ商品を 2 行に分けた注文は第5章の StoreOrderRequest::after() が 422 で弾くからです。

弾かれることと、入力を作れないことは違います。 サービスクラスを直接呼べば、その組み合わせを渡せます。

tests/Feature/OrderRollbackTest.php
use App\Exceptions\InsufficientStockException;
use App\Services\OrderPlacementService;
use Illuminate\Foundation\Testing\RefreshDatabase;

pest()->use(RefreshDatabase::class);

test('明細の途中で在庫が尽きたら減算ごと巻き戻る', function () {
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 1]);

expect(fn () => app(OrderPlacementService::class)->place($user, [
['product_id' => $product->id, 'quantity' => 1],
['product_id' => $product->id, 'quantity' => 1],
]))->toThrow(InsufficientStockException::class);

$this->assertDatabaseCount('orders', 0);
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 1, // 1 行目で減った 1 個が戻っている
]);
});

assertEnough は素通りします。在庫 1 個に対して「1 個」の明細を 1 行ずつ照らし合わせるだけで、2 行の合計が 2 個であることは見ていないからです。 decrease に入ると 1 行目が在庫を 0 にし、2 行目が条件に合う行を見つけられず例外を投げます。

DB::transaction() を外すと、1 行目の減算が残って quantity が 0 になります。トランザクションが巻き戻していることを、このテストが固定します。

このテストは「起きてはいけない入力」を通しています

第5章のバリデーションが働いていれば、この明細は API に届きません。それでも書く価値があります。バリデーションを 1 つ消したときに、データが壊れるか壊れないかが分かれるからです。

守りが二重にあるとき、外側だけをテストすると内側が効いているか分かりません。内側を直接呼ぶテストは、外側が破れたときに何が起きるかを固定します。

並行実行そのものはテストしていません

この章の中心である「2 つのリクエストが同時に来る」状況は、通常のテストで再現していません。2 つのプロセスを本当に同時に走らせる必要があり、実行のたびに結果が変わるテストになるためです。

上の 2 つが確認しているのは、競合が起きたときに壊れない仕組みのほうです。競合そのものを起こす部分は確認していません。行ロックが実際に他のリクエストを待たせるかどうかは、MySQL か PostgreSQL を用意して手元で確かめてください。

本番で効く注意点

Form Request に在庫チェックを足さない

第5章で「在庫の確認は Form Request でやらない」と決めました。この章で本当の防御線ができた今も、その判断は変わりません。

早めに弾ける利点はあります。防御線が 2 本あると、どちらが正しさを保証しているのか読む人に伝わらなくなります。 片方を消したときに何が壊れるか、誰も判断できなくなります。保証はこの章の側にあります。

在庫が負になっていないか監視する

条件付き UPDATE を入れたので、通常の経路では在庫は負になりません。管理画面からの在庫調整や、データ移行のスクリプトは、この経路を通りません。

データベースの制約で負数を禁じる手もあります。符号なし整数型は使えません。 PostgreSQL にこの型は無く、SQLite は型を強制しないので、環境によって黙って無効になります。lockForUpdate() と同じ形の落とし穴です。

CHECK (quantity >= 0) なら、3 つのデータベースのいずれでも働きます。採るかどうかは、何を守りたいかで決めます。 制約に当たった時点で例外になるので、在庫が負になる書き込みは確実に止まります。一方で Blueprint に専用のメソッドが無いため、マイグレーションに生の SQL を書くことになります。またエラーが InsufficientStockException でなく制約違反として飛ぶので、409 への変換を別途書かないと 500 になります。

この連載では入れません。 通常の注文経路は条件付き UPDATE が既に塞いでおり、残るのは管理画面や移行スクリプトからの書き込みです。そこを守りたいなら CHECK 制約は妥当な選択です。入れない場合は、定期的に負の在庫を検索して通知する仕組みで補います。

トランザクションは短く保つ

ロックを握っている時間が長いほど、他のリクエストが待たされます。トランザクションの中に置くのは、まとめて成功か失敗かを決めたい処理だけにします。

計算・整形・バリデーションは、トランザクションに入る前に済ませられます。

再送の鍵に有効期限を設ける

idempotency_keyorders に持たせると、注文と同じだけ残り続けます。1 年前のキーと衝突する可能性は低いものの、ゼロではありません。

規模が大きくなったら、キーを別テーブルに切り出して古いものを削除する形に移します。どれだけの期間を保持するかは、この連載では決めません。 クライアントがどれくらい経ってから再送しうるかによって決まる値で、一般的な正解がありません。

同じ鍵で違う内容が来た場合を決めておく

この章の実装は、キーが同じなら中身を見ずに1 回目の結果を返します。クライアントの不具合で、別の注文に同じキーが付く可能性があります。

仕様草案は、同じ鍵で異なる内容が届いた場合に 422 を返すよう定めています8。実装するにはリクエスト本文の指紋を保存して比較します。この連載では実装しません。 扱わない範囲を決めておくこと自体が設計です。

まとめ

  • トランザクションは「途中で失敗しても中途半端に残らない」を保証する。同時実行の競合は防げない
  • 競合を防ぐのは行ロックか条件付き UPDATE。トランザクションとは役割が違う
  • lockForUpdate() はトランザクションの中でしか意味を持たない。止まるのは書き込む側だけで、ロックを取らない読み取りは素通りする
  • SQLite では lockForUpdate() が SQL を生成しない。手元で動いても守られていない
  • 条件付き UPDATE は 1 行の競合に強く、環境を選ばない。複数行にまたがる保証はトランザクションが担う
  • デッドロックは全員が同じ順序で行を触れば避けられる。それでも起きるので attempts: で再試行する
  • 再試行はクロージャを頭から実行し直す。メール送信や課金をトランザクションの中に置かない
  • 再送は既存の注文を在庫を触る前に探して返す。順序を誤ると、成立済みの注文に在庫不足を返す
  • 探しても見つからない同時再送は、データベースの一意制約が止める。アプリケーションの判定には隙間ができる
  • 再送に対してエラーを返さない。1 回目の結果を返すのが冪等性

次に読む

在庫が守れたので、第1章で挙げた 4 つの問題のうち残るは 1 つです。次章 イベントと通知 — 注文確定から先を疎結合にする では、メールの送信時間がそのままレスポンス時間になる問題を扱います。まずは注文が確定したあとの処理を、サービスクラスの外へ切り離します。この章で「トランザクションの中でメールを送らない」と決めたことが、そこで効いてきます。

練習問題

次のコードは在庫の二重引き当てを防げていません。理由を説明してください
public function place(User $user, array $items): Order
{
$this->stockChecker->assertEnough($items);

return DB::transaction(function () use ($user, $items) {
$order = $this->createOrder($user, $items);
$this->stockChecker->decrease($items);

return $order;
});
}

解答例

在庫の確認がトランザクションの外にあります。 assertEnoughlockForUpdate() を使っていても、このクエリが終わった時点でロックが解放されます。

ロックが生きているのはトランザクションが終わるまでです。トランザクションに入っていないクエリのロックは、そのクエリの完了とともに消えます。確認が終わってからトランザクションが始まるまでのあいだに、別のリクエストが同じ在庫を持っていけます。

トランザクションを張る位置は、守りたい範囲の外側でなければなりません。この例では確認と減算の両方を含む必要があります。

なお decrease が条件付き UPDATE で書かれていれば、在庫が負になる事態そのものは避けられます。その場合でも、注文を作ったあとに在庫不足が判明することになります。確認を先に済ませる意味が無くなります。

「デッドロックが出たので attempts を 10 に増やした」という対応にどう応じますか

解答例

原因を確かめずに回数だけ増やす対応は勧められません。 3 回で解決しないデッドロックが 10 回で解決する保証はなく、失敗するまでの時間が 3 倍になるだけです。そのあいだリクエストは応答を返しません。

先に確かめることが 2 つあります。

1 つ目は、行を触る順序が揃っているかです。 順序が揃っていればデッドロックの多くは起きません。この章では明細を product_id 順に並べましたが、在庫を触る処理が他にもあれば、そちらも同じ順序にする必要があります。管理画面の在庫調整が別の順序で回っていれば、そこが原因です。

2 つ目は、トランザクションが長すぎないかです。 ロックを握っている時間が長いほど、衝突する確率が上がります。トランザクションの中に外部 API 呼び出しが混ざっていないか、ループの中で 1 件ずつ処理していないかを確認します。

再試行は、順序を揃えても残るまれな衝突を吸収するための仕組みです。頻発しているなら設計側に原因があります。 回数を増やす前に、どの処理とどの処理が競合しているかをログから特定してください。


Footnotes

  1. 出典: Database: Getting Started(Laravel 公式ドキュメント 13.x)。「Database Transactions」の節。DB::transaction が例外時に自動でロールバックして例外を再送出すること、成功時に自動でコミットすること、第 2 引数でデッドロック時の試行回数を指定できることについて。 2

  2. 出典: Database: Query Builder(Laravel 公式ドキュメント 13.x)。「Pessimistic Locking」の節。lockForUpdate が対象行の更新と共有ロックの取得を防ぐこと、悲観的ロックはトランザクションで囲むことが推奨されることについて。ロックが実際に何を止めるかはデータベース側の挙動なので、3 を併せて参照。

  3. 出典: Locking Reads(MySQL 8.4 リファレンスマニュアル)。SELECT ... FOR UPDATE が他のトランザクションの更新と SELECT ... FOR SHARE を止める一方、ロックを取らない一貫読み取りは「読み取りビューに存在するレコードに設定されたロックを無視する」ことについて。既定の分離レベルでの挙動。 2

  4. 出典: Illuminate/Database/Query/Grammars/SQLiteGrammar.php(laravel/framework 13.x)。compileLock メソッドが空の文字列を返す実装になっており、ロック句が SQL に含まれないことについて。

  5. 出典: Illuminate/Database/Query/Builder.php(laravel/framework 13.x)。decrement の戻り値が int<0, max> (影響行数) であることについて。

  6. 出典: Illuminate/Database/ConcurrencyErrorDetector.php(laravel/framework 13.x)。再試行の対象と判定するエラーメッセージの一覧に database is lockeddatabase table is locked が含まれることについて。

  7. 出典: Illuminate/Database/Concerns/ManagesTransactions.php(laravel/framework 13.x)。handleTransactionException が、同時実行エラーかつ入れ子 (transactions > 1) のときは再試行のループに戻らず DeadlockException を投げることについて。

  8. 出典: The Idempotency-Key HTTP Header Field(IETF HTTPAPI ワーキンググループ、Standards Track の草案)。完了済みの操作に対しては 1 回目の結果を返すこと、同じ鍵で異なる内容が届いた場合は 422 を返すこと、鍵の一意性の範囲はリソース側が定めること、クライアントの鍵とリソース側が持つ属性を組み合わせた複合キーが推奨されることについて。1 回目が完了する前に再送が届いた場合には 409 を返すこと (草案の例は A request is outstanding for this Idempotency-Key.) もこの草案の規定。標準化の途上にある草案であり、確定した仕様ではありません。 2 3 4

  9. 出典: Illuminate/Database/SQLiteConnection.php(laravel/framework 13.x)。isUniqueConstraintErrorUNIQUE constraint failed: を含むエラーに一致し、SQLite でも UniqueConstraintViolationException が投げられることについて。

  10. 出典: Illuminate/Http/Concerns/InteractsWithInput.phpHeaderBag.php(laravel/framework 13.x と symfony/http-foundation)。header()HeaderBag::get() に委譲し、ヘッダが存在しなければ既定値を、値が空なら空文字を返すことについて。