株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
このシリーズについて
「知ってはいる。けど、人に説明しろと言われると詰まる」——そんな歯がゆいDB用語を、1記事1用語・図解中心で解消していくシリーズです。
まず自己診断
「デッドロックが出ました」と言われて、こう答えていませんか。
「ロックがぶつかったんだよね。とりあえずリトライで」
半分合っています。でも次の3つに即答できますか。
- 誰がロックを持っている?
- 何をロックしている?(その行だけ?テーブル全部?)
- それはただの待ち? それともデッドロック?
3が特に大事です。この2つはまったく別物で、対処も違います。待ちは待てば解けますが、デッドロックは永遠に解けないので、DBが強制的に片方を殺します。
ロックの2軸:これだけ
ロックは2つの軸で整理すると一瞬で片付きます。
軸2の組み合わせは、この表がすべてです。Sどうしだけが共存できると覚えます。
| 自分が取りたい \ 相手が持っている | 共有S | 排他X |
|---|---|---|
| 共有S | 取れる | 待つ |
| 排他X | 待つ | 待つ |
実測:「行だけ」ではないし、行ロックは見えない
「行をロックした」と言いつつ、裏ではテーブルにもロックが掛かっています。PostgreSQL 16.14(Docker postgres:16) で pg_locks を覗きます。
-- 観察用クエリ(第3のセッションから実行)
SELECT locktype, relation::regclass::text AS tbl, mode, granted
FROM pg_locks
WHERE (relation = 'members'::regclass OR locktype IN ('tuple','transactionid'))
AND pid <> pg_backend_pid()
ORDER BY granted DESC, locktype;
まず、誰とも競合していない単独のUPDATEを保持したまま観察します。
-- セッション1(COMMITせずに保持)
BEGIN;
UPDATE members SET points = 500 WHERE id = 1;
locktype | tbl | mode | granted
---------------+---------+------------------+---------
relation | members | RowExclusiveLock | t
transactionid | | ExclusiveLock | t
行ロックが出てきません。 これはPostgreSQLの重要な性質で、行ロックの実体は行(タプル)自体に書き込まれるため、pg_locks には現れないのです(公式ドキュメントにも「行レベルロックは通常このビューに現れない」と明記)。ここに見えている relation の RowExclusiveLock は「この表の行をこれから書き換えます」という弱い意思表示で、他のUPDATEを止めません。
次に、別セッションが同じ行を更新しようとして待機している状態で、同じクエリを実行します。
locktype | tbl | mode | granted
---------------+---------+------------------+---------
relation | members | RowExclusiveLock | t
relation | members | RowExclusiveLock | t
transactionid | | ExclusiveLock | t
transactionid | | ExclusiveLock | t
tuple | members | ExclusiveLock | t
transactionid | | ShareLock | f ← granted = f(待たされている)
競合して初めて tuple ロックが現れました。そして最後の行、**granted が f(false)のものが「待たされている人」です。つまり pg_locks は「今ロックされているものの一覧」というより、「誰が待たされているかが分かる場所」**として使うのが実践的です。
同時に pg_stat_activity を見ると、当事者がはっきりします。
pid | state | wait_event_type | query
-----+---------------------+-----------------+------------------------------------------
100 | active | Lock | UPDATE members SET points = 900 WHERE id
101 | idle in transaction | Client | UPDATE members SET points = 500 WHERE id
pid 100 が wait_event_type = Lock で待たされ、pid 101 が idle in transaction(トランザクションを開いたまま何もしていない)で握りっぱなし。冒頭の「誰が」「何を」への答えが、そのまま出力に出ています。
実測:SELECTは待たされない
もうひとつ大事な事実。他人が書き換え中の行でも、普通の SELECT は待ちません。
T2のSELECT結果: 100
Time: 0.878 ms ← 即座に返る(更新前の値が読める)
UPDATE 1
Time: 4039.055 ms ← 同じ行のUPDATEは約4秒待たされた
PostgreSQLやMySQL(InnoDB)はMVCCを使うので、読み手は書き手を待たない。「重いバッチが走ると画面が全部止まる」という思い込みは、多くの場合ここで誤っています。
ただし
SELECT ... FOR UPDATEのように明示的にロックを取る読み取りは別で、これは待ちます。
「ただの待ち」と「デッドロック」の違い
ここが本題です。図で見ると一瞬でわかります。
ただのロック待ち=一方向の待ち行列。 相手がCOMMITすれば自動的に進みます。上の実測で4秒待ってから成功したのがこれです。
デッドロック=待ちが循環している。 誰も先に進めないので、待っても永久に解けません。
この「誰が誰を待っているか」の図を 待ちグラフ と呼び、輪っかができたらデッドロックです。判定はこれだけ。DBは定期的にこのグラフを調べ、輪を見つけたら片方のトランザクションを強制終了して輪を断ち切ります。
実測:デッドロックを故意に起こす
原因はほぼ常に 「2人がアクセス順序を逆にした」 ことです。再現します。
同じ2行を、T1は 1→2 の順、T2は 2→1 の順で更新します。
-- セッション1 -- セッション2
BEGIN; BEGIN;
UPDATE members SET points = points + 10 WHERE id = 1;
UPDATE members SET points = points + 10 WHERE id = 2;
UPDATE members SET points = points + 10 WHERE id = 2; -- 待ち
UPDATE members SET points = points + 10 WHERE id = 1; -- 循環
PostgreSQL 16.14 の実際の出力(セッション2側):
ERROR: deadlock detected
DETAIL: Process 108 waits for ShareLock on transaction 733; blocked by process 107.
Process 107 waits for ShareLock on transaction 734; blocked by process 108.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,1) in relation "members"
DETAIL が待ちグラフの輪をそのまま文章にしてくれています。108が107を待ち、107が108を待つ——完全な循環です。
MySQL 8.0.46 で同じことをすると:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
本記事の実行結果はすべて実測値です。スクリプト実行のため、MySQLクライアントが付ける
at line Nの表示のみ省いています。
どちらも、犠牲になった側だけがロールバックされ、もう片方は何事もなく成功します。実測でもセッション1は正常にCOMMITできました。つまりデッドロックは「両方死ぬ」のではなく、DBが片方を犠牲にして解決してくれている状態です。
犠牲者の選び方はエンジン次第で、MySQLは公式ドキュメントによると「更新した行数が少ないトランザクション」を優先的に選びます。どちらが死ぬかはアプリ側で選べないため、エラー1213や deadlock detected を受け取ったらリトライする作りが必要になります。
回避の原則3つ
1. アクセス順序を統一する(最も効く)
デッドロックは「順序の食い違い」で起きるので、全員が同じ順序で触れば原理的に輪ができません。
「複数行を更新するときは必ずID昇順」のような規約を決めるだけで、大半は消えます。
2. トランザクションを短くする
ロックは COMMITまで解放されません。トランザクションの中で外部API呼び出しやファイル生成をしていると、その間ずっと他人を待たせます。重い処理はトランザクションの外に出します。
3. インデックスで「ロックする範囲」を絞る
これが見落とされがちです。そしてここはエンジンで挙動が割れます。
grade 列にインデックスがない状態で、1行しか該当しない WHERE grade = 'gold' を更新し、その裏でまったく別の行(id = 3)を更新してみます。
-- セッション1: 該当は1行だけのはず
BEGIN;
UPDATE m2 SET points = points + 1 WHERE grade = 'gold';
-- セッション2: 無関係な別の行を更新
UPDATE m2 SET points = 999 WHERE id = 3;
MySQL 8.0.46(インデックスなし):
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
無関係な行なのに待たされ、タイムアウトしました。全行スキャンの過程で触った行までロックされたためです。CREATE INDEX idx_grade ON m2(grade); を貼って同じ手順を実行すると、セッション2は即座に成功します。
PostgreSQL 16.14(同じくインデックスなし):
UPDATE 1
Time: 5.767 ms ← 待たされずに成功
EXPLAIN で確認すると PostgreSQL も Seq Scan(全行スキャン)をしています。それでも条件に合致した行しかロックを保持しないので、巻き添えが起きません。
| エンジン | インデックスなしで全行スキャンしたとき |
|---|---|
| MySQL 8.0 (InnoDB) | スキャンした行まで巻き込んでロック(待たされる) |
| PostgreSQL 16 | 合致した行しかロックしない(待たされない) |
つまり「インデックスがないとロック範囲が広がる」は、主にMySQL/InnoDBで顕著な話です。とはいえPostgreSQLでも、スキャン量が減れば競合そのものが起きにくくなり、トランザクションも短くなります。インデックスは速度のためだけのものではないという結論は、どちらでも変わりません。
まとめ:1行で説明するなら
「ロックは粒度(行かテーブルか)と種類(共有か排他か)の2軸で決まります。複数のトランザクションが互いの持っているロックを待ち合って輪ができた状態がデッドロック。ただの待ちは相手のCOMMITで解けますが、輪は永久に解けないのでDBが検知して片方をロールバックします。原因はほぼアクセス順序の食い違いなので、順序を統一するのが一番効きます」
そして「誰が」「何を」は想像で議論するものではありません。PostgreSQLなら pg_locks の granted = f と pg_stat_activity を見れば、待っている当事者がそのまま出てきます。
次回
第4回:スキーマ ——「同じ『スキーマ』という言葉が、文脈ごとに違うものを指している」問題を整理します。
(シリーズ全11回の予定は第1回に掲載しています)
参考
- PostgreSQL 16 Documentation - Explicit Locking
- PostgreSQL 16 Documentation - pg_locks
- PostgreSQL 16 Documentation - Lock Management(deadlock_timeout)
- MySQL 8.0 Reference Manual - InnoDB Locking
- MySQL 8.0 Reference Manual - Deadlocks in InnoDB
- MySQL 8.0 Reference Manual - Deadlock Detection
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!