Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

SELECT FOR UPDATEのデッドロックを防ぐ2つの設計パターン

はじめに

こんにちは、ヤプリでサーバサイドエンジニアをしている熊埜御堂です。
業務で排他ロックが必要な実装をする機会がありました。排他ロック制御をする際はデッドロックに気をつける必要があると思います。本記事では、実装で講じたデッドロックに対する2つの対策を、ロック機構の仕組みとともに解説します。

実装の全体像

以下は、チケットの一括消費処理のサンプルコードです。複数のチケットに対して同時に排他ロックを取得し、消費履歴の作成と残回数のデクリメントをアトミックに行う必要があります。
今回はPHP(laravel)を例にして説明しますが、他言語でも考え方は同じです。

<?php
public static function bulkConsume(int $shopId, array $ticketIds, DateTimeInterface $usedAt): void
{
    DB::transaction(function () use ($shopId, $ticketIds, $usedAt): void {
        // 対策1: orderBy('id') でロック順序を固定
        $tickets = Ticket::where('shop_id', $shopId)
            ->whereIn('id', $ticketIds)
            ->where('expires_at', '>=', $usedAt)
            ->where(fn (Builder $q) => $q
                ->where('remaining', '>', 0))
            ->orderBy('id')
            ->lockForUpdate()
            ->get();

        // 対策2: 空振り時に即座にトランザクションを終了
        $missingIds = collect($ticketIds)->diff($tickets->pluck('id'));
        if ($missingIds->isNotEmpty()) {
            throw new TicketBulkNotFoundException($missingIds->toArray());
        }

        // 消費履歴の一括作成
        TicketUsageLog::insert($tickets->map(fn (Ticket $t) => [
            'ticket_id' => $t->id,
            'shop_id'   => $t->shop_id,
            'used_at'   => $usedAt,
        ])->toArray());

        // 残回数のデクリメント
        Ticket::whereIn('id', $tickets->pluck('id'))->decrement('remaining');
    });
}
?>

この実装には、デッドロックを防ぐための工夫が2つ含まれています。順に解説します。

対策1: orderBy('id') — ロック取得順序の統一

デッドロックの典型パターン

複数行に SELECT ... FOR UPDATE で排他ロックを取る場合、トランザクション間でロック取得順序が逆転するとデッドロックが発生します。

TX1: ids = [10, 20] → id=10 をレコードロック → id=20 をロック待ち
TX2: ids = [20, 10] → id=20 をレコードロック → id=10 をロック待ち → 💀 デッドロック

TX1はid=20を、TX2はid=10を待ち続ける。互いに相手のロック解放を待つ循環が生まれ、永遠に進めません。

解決: 一貫した順序でロックを取得する

orderBy('id') を加えることで、どのトランザクションも必ずidの昇順でロックを取得します。

TX1: ids = [10, 20] → id=10 レコードロック ✅ → id=20 レコードロック ✅
TX2: ids = [20, 10] → id=10 ロック待ち  (TX1が保持中)
TX1: COMMIT → ロック解放
TX2: id=10 レコードロック ✅ → id=20 レコードロック ✅ → 続行

TX2はid=10の時点でブロックされるため、TX1とロックを奪い合う循環が構造的に発生しません。

※なお、ORDER BY id はクエリ結果の返却順序を制御するものであり、ロック取得順序を直接保証するものではありません。InnoDB は実際にスキャンするインデックスの順序でロックを取得します。今回のように WHERE id IN (...) でPKを直接指定している場合は主キーインデックスが使われるため問題ありませんが、セカンダリインデックスでの検索が中心になるクエリでは、EXPLAIN で実行計画を確認し、意図したインデックスが使われていることを確認することを推奨します。

対策2: 空振り時の即時例外 — ギャップロックによるデッドロック防止

InnoDBの「空振り」時の挙動

SELECT ... FOR UPDATE で該当行が見つからなかった場合、InnoDBは行そのもの(レコードロック)ではなく、ギャップロックを取得します。

ギャップロックとは、インデックス上のレコード間の「隙間」に対するロックです。

テーブルに id = 10, 20, 30 が存在する場合のインデックス:

  [10] ... (gap A) ... [20] ... (gap B) ... [30]

ここで SELECT ... WHERE id = 15 FOR UPDATE を実行すると、id=15は存在しないため、InnoDBは「gap A(10〜20の隙間)」にギャップロックを取得します。

ギャップロック同士は競合しない

ギャップロックには重要な特性があります。ギャップロック同士は互いにブロックしません。

TX1: SELECT ... WHERE id = 15 FOR UPDATE → ギャップロック取得 ✅
TX2: SELECT ... WHERE id = 15 FOR UPDATE → ギャップロック取得 ✅(待たない)

ギャップロックの役割は「この隙間に誰もINSERTさせない」という防御的なものであり、排他ではなく共有的に動作します。

INSERT と組み合わさるとデッドロックする

ここまでは問題ありません。危険なのはこの後です。

INSERTを実行すると挿入意図ロックが必要になりますが、これはギャップロックと競合します。

TX1: SELECT ... FOR UPDATE (0件) → ギャップロック取得 ✅
TX2: SELECT ... FOR UPDATE (0件) → ギャップロック取得 ✅
TX1: INSERT ... → TX2のギャップロックと競合 → 待ち
TX2: INSERT ... → TX1のギャップロックと競合 → 待ち → 💀 デッドロック

両方がギャップロックを持った状態で、両方がそのギャップにINSERTしようとする。互いの相手のギャップロックが邪魔になり、デッドロックが成立します。

解決: 空振り時にINSERTまで到達させない

今回のサンプルコードではロック取得後に消費履歴のINSERTと残回数のデクリメント(UPDATE)を行っています。デクリメント自体は既存行のUPDATEなのでギャップロックとは無関係ですが、INSERTの方はまさにこのデッドロックパターンに該当します。空振り時にINSERTまで処理が進んでしまうと、ギャップロックと挿入意図ロックの競合によりデッドロックが発生し得ます。

本実装では、SELECT ... FOR UPDATE の取得件数が要求件数に満たない場合、即座に例外を投げてトランザクションを終了します。

<?php
$missingIds = collect($ticketIds)->diff($tickets->pluck('id'));
if ($missingIds->isNotEmpty()) {
    throw new TicketBulkNotFoundException($missingIds->toArray());
}
// ↑ ここで例外 → ロールバック → INSERT に到達しない
?>

ギャップロックだけを持った状態で即座にロールバックされるため、挿入意図ロックとの競合が発生する余地がありません。

ロックの種類まとめ

ロックの種類 取得タイミング ロック対象 競合関係
レコードロック(排他) FOR UPDATEで行がヒット 実在する行 排他ロック同士は競合する
ギャップロック FOR UPDATEで行が空振り レコード間の隙間 ギャップロック同士は競合しない
挿入意図ロック INSERT実行時 挿入先のギャップ ギャップロックと競合する

まとめ

リスク 原因 対策
ロック順序の逆転によるデッドロック トランザクション間で異なる順序で行ロックを取得 orderBy('id') で昇順に統一
ギャップロック + INSERTによるデッドロック 空振り時にギャップロックを保持したままINSERTを実行 空振り時に即座に例外を投げてINSERTに到達させない

排他制御の設計では「どのロックが・いつ・どの順序で取得されるか」を常に意識することが重要です。特に SELECT ... FOR UPDATE は、行が存在する場合はレコードロック、存在しない場合はギャップロックと、取得されるロックの種類が異なります。両方のケースを想定した設計が、本番環境でのデッドロック障害を防ぐ鍵になります。


ヤプリに興味を持った方はぜひカジュアル面談にお越しください!

open.talentio.com