データベースのデッドロックは、2つ以上のトランザクションが互いに相手の持つロックの解放を待ち、どちらも先へ進めなくなる状態です。放っておけば永久に止まるため、主要なRDBMSは待ちの循環を自動で検出し、片方の処理をエラーにして打ち切ります。PostgreSQL・MySQL・SQL Serverはトランザクション全体を、Oracleはデッドロックに関係した1文だけをロールバックします。
この記事では、PostgreSQL 18.6・MySQL 8.4.8・SQL Server 2025で実際にデッドロックを起こしたときのエラーとログを示しながら、発生する典型パターン、DBごとの検出仕様の違い、Amazon RDS・Auroraでの調べ方、起きたときのリトライ実装、再発を防ぐ設計を順に解説します。
まとめ:デッドロックの原因と解消方法の要点
- 原因:複数のトランザクションが同じ行を異なる順序でロックすること。典型は「Aは行1→行2、Bは行2→行1の順に更新する」ケース。MySQLでは「行が無いことを
FOR UPDATEで確かめてからINSERTする」処理も、隙間へのロックどうしでデッドロックする。 - 検出:DBが自動で検出し、片方をエラーで打ち切る。PostgreSQLは SQLSTATE 40P01、MySQL(InnoDB)はエラー1213、SQL Serverはエラー1205、OracleはORA-00060。
- 巻き戻る範囲はDBで違う:PostgreSQL・MySQL・SQL Serverは犠牲になったトランザクション全体がロールバックされる。Oracleはデッドロックに関係した1文だけで、トランザクションの残りの扱いはアプリが決める。
- 起きたとき:トランザクション全体が戻るDB(PostgreSQL・MySQL・SQL Server)では、アプリ側でBEGINから最初からやり直す(リトライ)。失敗した文だけ再実行すると、先行する変更が消えたまま処理が進む。Oracleは戻った1文を再試行するか、トランザクションをROLLBACKするかをアプリが決める。
- MySQLの1205に注意:ロック待ちタイムアウト(1205)は既定で待っていた文だけが戻る。エラーを無視してCOMMITすると、振替の片側だけが確定する(実測で残高合計が200から190に減った)。
- 予防:ロックを取る順序を全処理で統一する(主キー順に
SELECT ... FOR UPDATEなど)。トランザクションを短くし、更新対象を絞れるインデックスを張る。 - ロック待ちとの違い:単なるロック待ちは相手が終われば進む。デッドロックは待ちが循環していて、DBが介入しない限り解けない。
デッドロックの定義とロック待ちとの違い
英語の deadlock は「行き詰まり」「膠着状態」を意味し、計算機科学では複数の処理が互いに相手の持つ資源を待って永久に進めない状態を指します。DBでいう資源は、主に行・ページ・テーブルに掛かるロックです。
PostgreSQLやMySQLのInnoDBでは、トランザクションは更新した行のロックを、コミットかロールバックするまで保持します(ロックとトランザクションの基本はトランザクションの分離レベル・ACID・commit/rollbackの解説を参照)。この「終わるまで持ち続ける」性質があるため、別々のトランザクションが互いの行を欲しがると循環待ちが起きます。SQL Serverの最適化されたロック(optimized locking)が有効な場合は、行・ページのロックを行の更新直後に解放し、代わりにトランザクションIDのロックを終了まで保持しますが、逆順の更新では同じように循環待ちになります(Microsoftのデッドロックガイドに例があります)。
デッドロックが成立する4条件(Coffmanの条件)
1971年にCoffman、Elphick、Shoshaniが論文「System Deadlocks」(ACM Computing Surveys 3巻2号)で整理した4条件が、今もデッドロックの説明の基準になっています。4つが同時に成り立ったときだけデッドロックが起き、どれか1つを崩せば起きません。
| 条件 | 意味 | DBでの対応 |
|---|---|---|
| 相互排他 | 資源を同時に1者しか使えない | 排他ロック(行ロック) |
| 保持と待機 | 資源を持ったまま別の資源を待つ | ロックを持ったまま次の文を実行 |
| 横取り不可 | 他者の資源を強制的に奪えない | 他トランザクションのロックは外せない |
| 循環待ち | 待ちの連鎖が輪になる | A→B→Aのロック待ち |
DBの実務で崩せるのは主に4つ目の循環待ちです。相互排他と横取り不可はロックの仕組みそのものなので外せません。そのため予防策は「全トランザクションで同じ順序でロックを取り、輪を作らせない」に集約されます。
ロック待ち・ロック待ちタイムアウトとの違い
デッドロックと混同されやすいのが、単なるロック待ちと、その待ちが上限に達したロック待ちタイムアウトです。エラー番号も対処も違います。
| 状態 | 待ちの形 | 放置した場合 | MySQLのエラー |
|---|---|---|---|
| ロック待ち | 一方向(A→B) | Bが終われば進む | なし |
| ロック待ちタイムアウト | 形を問わず上限超過 | 上限で待ち側がエラー | 1205 |
| デッドロック | 循環(A→B→A) | 永久に進まない | 1213 |
ロック待ちタイムアウトが示すのは「待ちが上限を超えた」という事実だけです。よくある原因は長時間トランザクションや、コミットされずに放置されたセッションですが、MySQLでデッドロック検出を無効にしている場合は循環待ちもこのエラーとして現れます。タイムアウトが出たら、相手のトランザクションの長さ、innodb_lock_wait_timeout などの設定、循環待ちの有無を順に確認します。デッドロックとして報告されたものは「ロック順序の設計」の問題なので、調べる場所が違います。
デッドロックが起きる典型パターンと具体例
口座振替で起きる更新順序の逆転
典型例が、同じ2行を逆の順序で更新するケースです。口座1から口座2へ振り込む処理と、口座2から口座1へ振り込む処理が同時に走ると次のように進みます。
- トランザクションAが口座1を更新し、口座1の行ロックを取る
- トランザクションBが口座2を更新し、口座2の行ロックを取る
- Aが口座2を更新しようとして、Bのロック解放を待つ
- Bが口座1を更新しようとして、Aのロック解放を待つ(循環が完成)
SQLとしては何の問題もない振替処理でも、「送金元から先に更新する」というロジックだと、送金の向きによってロック順序が逆転します。次章でこのパターンを実際に再現します。
共有ロックから排他ロックへの昇格による循環待ち
1行しか触らなくてもデッドロックは起きます。2つのトランザクションが同じ行を SELECT ... FOR SHARE(共有ロック)で読み、その後で同じ行を UPDATE しようとすると、互いの共有ロックが排他ロックへの昇格を妨げて循環待ちになります。PostgreSQL 18.6で同じスクリプトを2セッションで0.3秒ずらして流すと、片方が ERROR: deadlock detected で打ち切られました。
「後で更新する行は、最初から FOR UPDATE で排他ロックを取る」のが対策です。読んだ値を元に更新する処理で共有ロックを使う理由はほとんどありません。
外部キーの親行ロックとDBごとの差
子テーブルへのINSERTは、参照先の親行にロックを掛けます。「注文を追加してから顧客の注文数を更新する」処理を2つ同時に流すと、親行のロックが競合します。この型はDBによって結果が分かれます。
- PostgreSQL 18.6:子テーブルへのINSERT後に親の非キー列をUPDATEする処理を2セッションで同時に流すと、デッドロックは起きず2件とも完了した。外部キー検査で取るのは
FOR KEY SHAREという弱いロックで、キー以外の列の更新(FOR NO KEY UPDATE相当)とは衝突しないため。 - MySQL 8.4.8:同じ流れで後発のUPDATEが
ERROR 1213になった。待ちの最中のperformance_schema.data_locksでは、両方のトランザクションが親行にS,REC_NOT_GAP(行だけの共有ロック)を持ち、先発のUPDATEがX,REC_NOT_GAPをWAITINGで待っていた。前節の昇格パターンと同じ形。
PostgreSQLからMySQLへ(またはその逆へ)移行したときに初めてデッドロックが出る、という形で表面化しやすい違いです。移行時は、外部キーを持つテーブルへの同時INSERTと親行の更新が同じトランザクションに入っている処理を洗い出しておきます。移行前の設計確認から任せたい場合はデータベース設計・移行支援も参考にしてください。
InnoDBのインデックス不足とロック範囲の拡大
このパターンは主にMySQLのInnoDBで問題になります。InnoDBの更新・削除は、条件に一致した行だけでなく、検索で走査したインデックスレコードにロックを掛けます。条件列にインデックスが無いと走査範囲が広がり、本来は別々の行を触る2つの処理が互いのロックにぶつかって循環待ちになります。既定の分離レベル(REPEATABLE READ)では走査範囲にネクストキーロック(行とその直前の隙間へのロック)も掛かります。PostgreSQLの行ロックは実際に更新・ロックした行にだけ掛かるため、走査しただけの行はロックされません。InnoDBで「同じ行を触っていないのにデッドロックする」ときは、まず実行計画で全件走査になっていないかを確認します。隙間へのロックだけでデッドロックする例は次々章で再現します。
PostgreSQL 18で再現:デッドロック発生から検出までの流れ
口座振替の逆転パターンを、PostgreSQL 18.6(2026-10-23にローカルで実行)で再現します。テーブルは次の2行だけです。
CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);
INSERT INTO accounts VALUES (1, 1000), (2, 1000);
セッションAとセッションBで、次のSQLをBを0.3秒遅らせて流します。pg_sleep(1) は、相手が1行目のロックを取るまで待つための間です。
-- セッションA(口座1 → 口座2へ100)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SELECT pg_sleep(1);
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
-- セッションB(口座2 → 口座1へ50)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
SELECT pg_sleep(1);
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
セッションAの2つ目のUPDATEが、次のエラーで打ち切られました(VERBOSITY=verbose で表示)。
ERROR: 40P01: deadlock detected
DETAIL: Process 14949 waits for ShareLock on transaction 754; blocked by process 14951.
Process 14951 waits for ShareLock on transaction 753; blocked by process 14949.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,2) in relation "accounts"
ERROR: 25P02: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
この出力と、psqlの \timing による計測、実行後の残高、サーバーログを合わせると、次の4点が分かります。
- 検出までの時間:Aの2つ目のUPDATEはエラーまで1,001ミリ秒待たされた。PostgreSQLはロック待ちが
deadlock_timeout(既定1秒)を超えた時点で初めて循環を調べる。 - 巻き戻る範囲:Aのトランザクション全体が無効になり、以降の文は25P02で拒否され、COMMITはROLLBACKとして返った。最終残高は口座1が1,050、口座2が950で、Aの1つ目のUPDATE(口座1から100引く)も取り消されている。
- 生き残った側:Bの2つ目のUPDATEは549ミリ秒待ったあと、Aの打ち切り直後に通り、COMMITまで完了した。
- SQL文はサーバーログにだけ出る:クライアントに返るのはプロセスIDとトランザクションIDまで。どのSQLが衝突したかは、サーバーログの
Process 14949: UPDATE accounts SET balance = balance + 100 WHERE id = 2;のような行で確認する。
この例はセーブポイントを使っていません。PostgreSQLではセーブポイントやPL/pgSQLの例外ブロックで囲んだ部分だけを戻すこともでき、その場合はトランザクション全体は失われません。どちらが犠牲になるかは、PostgreSQLでは先に deadlock_timeout に達して検査を走らせた側になりやすいものの、公式ドキュメントも予測は難しいとしています。アプリは「どちらの処理も打ち切られうる」前提で作ります。
MySQL 8.4で再現:ギャップロック型と1205の落とし穴
MySQL 8.4.8(2026-10-23にローカルで実行・既定の REPEATABLE READ)で、PostgreSQLとは結果が分かれるケースを再現します。2セッションを1秒ずらして流し、待ちが起きている最中に別セッションから performance_schema.data_locks を読んでいます。
存在確認してからINSERTする処理のギャップロック衝突
「行が無ければ挿入する」処理を FOR UPDATE で守ろうとすると、InnoDBでは対象の行が1件も無いのにデッドロックします。
CREATE TABLE t (id int PRIMARY KEY, v int);
INSERT INTO t VALUES (10, 1), (20, 2);
-- セッションA・Bの両方で実行(Bは1秒遅れ)
BEGIN;
SELECT * FROM t WHERE id = 15 FOR UPDATE; -- 0行
DO SLEEP(2);
INSERT INTO t VALUES (15, 1);
COMMIT;
Bの INSERT が ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction で打ち切られ、Aの INSERT は1.16秒待ったあとに通りました。待ちの最中の data_locks(列を抜粋)は次のとおりです。
trx | idx | LOCK_MODE | LOCK_STATUS | LOCK_DATA
1327 | PRIMARY | X,GAP | GRANTED | 20
1326 | PRIMARY | X,GAP | GRANTED | 20
1326 | PRIMARY | X,GAP,INSERT_INTENTION | WAITING | 20
存在しない id = 15 への FOR UPDATE は、10と20のあいだの隙間に X,GAP(ギャップロック)を掛けます(LOCK_DATA の20は「20の手前の隙間」を指す)。ギャップロックどうしは衝突しないのでA・Bとも取得でき、その後のINSERTが必要とする挿入意向ロック(INSERT_INTENTION)が相手のギャップロックと衝突して、互いに待つ輪になります。Bが挿入意向ロックを要求した直後に1213が返っており、PostgreSQLのように1秒待ってから検査する動きではありませんでした。
同じSQLをPostgreSQL 18.6で流すとデッドロックは起きず、後からのINSERTが duplicate key value violates unique constraint(23505)で失敗しました。PostgreSQLは存在しない行にロックを掛けないためです。MySQLでも分離レベルを READ COMMITTED にするとギャップロックが掛からず、Bは ERROR 1062 (23000): Duplicate entry '15' for key 't.PRIMARY' になりました。
直し方は、存在確認とINSERTを分けないことです。同じ2セッションを INSERT INTO t VALUES (15, 1) ON DUPLICATE KEY UPDATE v = v + 1 の1文に書き換えると、Bは1.00秒待ったあとに更新として通り(2 rows affected)、デッドロックは起きませんでした。PostgreSQLなら INSERT ... ON CONFLICT が同じ役割です。
LATEST DETECTED DEADLOCKとエラーログの読み方
直前の衝突は SHOW ENGINE INNODB STATUS の LATEST DETECTED DEADLOCK 節に残ります(MySQL 8.4 リファレンスの説明では直近の1件だけ)。上の再現では次のように出ました(物理レコードのダンプ行は省略)。
*** (1) TRANSACTION:
TRANSACTION 1326, ACTIVE 3 sec inserting
INSERT INTO t VALUES(15,1)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `d`.`t` trx id 1326 lock_mode X locks gap before rec
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `d`.`t` trx id 1326 lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 1327, ACTIVE 2 sec inserting
INSERT INTO t VALUES(15,2)
...
*** WE ROLL BACK TRANSACTION (2)
読むのは3か所です。各トランザクションの最後に実行していたSQL、HOLDS THE LOCK(S) と WAITING FOR THIS LOCK のロックの種類(locks gap before rec ならギャップロック、locks rec but not gap なら行だけのロック)、そして最後の WE ROLL BACK TRANSACTION の番号です。ここに出るSQLは各トランザクションが最後に実行した文だけなので、先に実行した SELECT ... FOR UPDATE はアプリのコードから追います。
全件を残すには innodb_print_all_deadlocks(既定OFF)をONにしてエラーログへ出します。ただし8.4.8の既定 log_error_verbosity=2 で試すと、エラーログに出たのはトランザクションとロックの本文だけで、「Transactions deadlock detected」の見出し行と WE ROLL BACK TRANSACTION (2) の行は出ませんでした。この2行は [Note] レベルのため、log_error_verbosity=3 にして初めて記録されます。犠牲になった側をエラーログから知りたいなら、verbosityも合わせて上げます。
ロック待ちタイムアウト1205で半端な更新が確定する挙動
innodb_deadlock_detect をOFF、innodb_lock_wait_timeout を3秒にして、口座振替の逆転(残高は両口座100、Aは口座1から10を、Bは口座2から20を相手へ送る)を流しました。デッドロックは検出されず、3秒後にAの2つ目のUPDATEが ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction になります。
問題はその後です。MySQL 8.4のエラー処理の説明どおり、1205で戻るのは待っていた文だけでした。Aのトランザクションは生きていて、直後の SELECT では口座1が90(自分の引き落としが残ったまま)でした。エラーを無視してCOMMITまで進めると、そのまま確定します。Aがコミットしてロックを手放した後にBも完了し、最終残高は口座1が110、口座2が80で、合計が200から190に減りました。Aの「口座2へ10入れる」側だけが消えたためです。
innodb_rollback_on_timeout(既定OFF・起動時のみ設定可)をONにして同じ処理を流すと、1205の直後の SELECT で口座1は100に戻っており、トランザクション全体が取り消されていました。アプリの作りとしては、1205を受けたらその場でROLLBACKしてから、1213と同じくBEGINからやり直す、と決めておくのが安全です。
DB別のデッドロック検出とエラーの違い
デッドロックの自動検出は主要RDBMSすべてにありますが、エラー番号、詳細の残る場所、そして何が巻き戻るかが違います。エラーハンドリングを書くときに効いてくるのは巻き戻る範囲です。
| DB | エラー | 巻き戻る範囲 | 詳細の確認先 |
|---|---|---|---|
| PostgreSQL | 40P01 deadlock detected | トランザクション全体 | サーバーログ |
| MySQL(InnoDB) | 1213(SQLSTATE 40001) | トランザクション全体 | SHOW ENGINE INNODB STATUS |
| SQL Server | 1205 | トランザクション全体 | system_health |
| Oracle Database | ORA-00060 | 関係した1文のみ | トレースファイル |
MySQL(InnoDB)の検出設定と犠牲者の選択
InnoDBのデッドロック検出は innodb_deadlock_detect(既定ON)で有効になっており、MySQL 8.4のデッドロック検出の説明では、挿入・更新・削除した行数で測った「小さいほう」のトランザクションを選んでロールバックするとされています。ロック待ちの上限は innodb_lock_wait_timeout(既定50秒)で、超えると前章の1205になります。設定値の既定は InnoDBのパラメータ一覧で確認できます。いま誰が誰を待っているかは、前章で使った performance_schema.data_lock_waits と data_locks を結合して見ます。MySQLの版の選び方はMySQLの特徴とバージョン選定の解説にまとめています。
SQL ServerのDEADLOCK_PRIORITYによる犠牲者の選択
SQL Serverではロックモニターが既定5秒間隔でデッドロックを探し、見つかると間隔を最短100ミリ秒まで縮めて巡回します(Microsoftのデッドロックガイド)。犠牲者はまず SET DEADLOCK_PRIORITY の低い側、同じならロールバックのコストが小さい側が選ばれます。優先度もコストも同じ場合はランダムです。
SQL Server 2025(17.0.5035.5・Developer)をDockerで動かし、口座振替の逆転を流すと、既定の優先度では後から来たセッションが Msg 1205, Level 13 ... has been chosen as the deadlock victim. Rerun the transaction. で打ち切られました。先に始めたセッションに SET DEADLOCK_PRIORITY LOW を付けて流し直すと、今度はそちらが犠牲になりました。循環が成立した時刻(02:04:51.372)から両セッションが終わるまで約4.3秒で、PostgreSQLの約1秒、InnoDBの即時より長くかかっています。
発生したデッドロックのグラフは、既定で動いている拡張イベント system_health セッションが xml_deadlock_report として記録しています。直後に調べるなら、メモリ上の ring_buffer ターゲットから次のクエリで取り出せます(sqlcmdでは -I を付けて QUOTED_IDENTIFIER をONにしないとXMLメソッドがエラー1934になります)。
SELECT x.value('(@timestamp)[1]', 'datetime2(0)') AS ts,
x.value('(data/value/deadlock/victim-list/victimProcess/@id)[1]', 'varchar(30)') AS victim,
x.query('.') AS deadlock_xml
FROM (SELECT CAST(st.target_data AS xml) AS td
FROM sys.dm_xe_session_targets AS st
JOIN sys.dm_xe_sessions AS s ON s.address = st.event_session_address
WHERE s.name = 'system_health' AND st.target_name = 'ring_buffer') AS r
CROSS APPLY r.td.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS t(x);
ディスク上の event_file ターゲットにも同じイベントが入りますが、再現直後に sys.fn_xe_file_target_read_file で読んだときは0件で、少し時間をおいてから1件出てきました。ring_buffer はメモリ上の限られた領域なので、時間がたってからの調査は event_file 側で行います。
Oracle Databaseの文単位ロールバックと残存ロック
Oracleは ORA-00060: deadlock detected while waiting for resource を返し、デッドロックに関わった1文だけをロールバックします(Oracle AI Database 26aiの概念ガイドの記述)。トランザクションそのものは継続し、先行する文の変更とロックは残ります。相手がそのロックを必要としている場合、COMMITやROLLBACKなどで解放されるまで待つため、通常はエラーを受けたトランザクションを明示的にROLLBACKします。PostgreSQLやMySQLと同じ感覚で「エラーが返ったら自動で全部戻っている」と考えると、ロックを抱えたまま処理を続けてしまいます。関係したトランザクションとリソースはサーバー側のトレースファイルで確認します。
Amazon RDS・Auroraでのデッドロックの調べ方
マネージドDBではサーバーのファイルを直接読めないため、設定はパラメータグループで変え、ログはコンソールかCloudWatch Logsで読みます。
- RDS for MySQL・Aurora MySQL:
innodb_print_all_deadlocksをONにすると全件がエラーログに出る(AuroraはDBクラスターパラメータとして案内)。エラーログはDBの「変更」画面の Log exports で選ぶとCloudWatch Logsへ発行される(RDS for MySQLの手順)。SHOW ENGINE INNODB STATUSは、PROCESS権限を持つユーザーでDBに接続して実行する。 - RDS for PostgreSQL・Aurora PostgreSQL:
log_lock_waitsとdeadlock_timeoutはカスタムのパラメータグループで変えられる動的パラメータ(RDS for PostgreSQLのパラメータ一覧)。行ロック待ちは Database Insights の待機イベントLock:transactionidとして見える(待機イベントの解説)。 - Aurora共通:CloudWatchメトリクス
Deadlocksが「1秒あたりのデッドロックの平均数」をAurora MySQL・Aurora PostgreSQLの両方で出している(Auroraのメトリクス一覧)。0より大きい状態が続いたら通知するアラームを置き、ログで原因を詰める。
Aurora MySQLの版ごとの違いや移行手順はAurora MySQL 8.4の新機能とアップグレード手順の解説で扱っています。
デッドロックの解消方法:起きたときの対処手順
検出が有効なら、デッドロックはDBが片方を打ち切った時点で解けます。検出までの時間はDBの設定やエンジンによる自動調整で変わり(PostgreSQLの deadlock_timeout、SQL Serverのロックモニターの間隔など)、ロールバックする変更量が多いほど解放までに時間がかかります。運用上の課題は、打ち切られた処理をどう完了させるかと、同じデッドロックを繰り返させないことの2つです。
アプリ側でのトランザクション全体の再実行
デッドロックで打ち切られたトランザクションは、BEGINからやり直せば多くの場合成功します。相手のトランザクションはすでに進んでいるためです。PostgreSQL・MySQL・SQL Serverでは失敗した文だけを再実行すると、それ以前の変更が消えたまま処理が進むので結果が壊れます。Oracleは戻ったのが1文だけなので、少し待ってその文を再試行するか、ROLLBACKしてトランザクションごとやり直すかを選べます。
次はPython(psycopg 3.2.13)でのリトライ実装です。デッドロック(40P01)とシリアライズ失敗(40001)だけを捕まえ、初回を含めて最大5回(再試行は最大4回)、間隔を指数的に広げながら実行します。検証時の版に合わせる場合の前提は pip install "psycopg[binary]==3.2.13" と前章の accounts テーブルで、DSN は検証に使ったローカルのPostgreSQL(Unixソケット・ポート55433)なので、自分の環境の接続先に書き換えてください。
import random
import time
import psycopg
from psycopg import errors
DSN = "host=/tmp port=55433 user=postgres dbname=postgres"
def transfer(conn, src, dst, amount):
with conn.transaction():
conn.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, src))
conn.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, dst))
def with_retry(fn, *args, attempts=5):
for i in range(1, attempts + 1):
try:
return fn(*args)
except (errors.DeadlockDetected, errors.SerializationFailure) as e:
if i == attempts:
raise
wait = 0.05 * 2 ** i + random.uniform(0, 0.05)
print(f"retry {i}: {e.sqlstate} -> {wait:.2f}s")
time.sleep(wait)
with psycopg.connect(DSN, autocommit=True) as conn:
with_retry(transfer, conn, 1, 2, 100)
残高を両口座1,000に戻し、別々の接続を持つ2スレッドで口座1→2へ100と口座2→1へ50を0.3秒ずらして流し、2つのUPDATEの間に1秒の間を入れてデッドロックを起こすと、片方が retry 1: 40P01 -> 0.13s を出して再実行し、最終残高は口座1が950、口座2が1,050で揃いました。リトライ間隔にランダムな揺らぎを足すのは、2つの処理が同じタイミングで再実行して再び衝突するのを避けるためです。MySQLのドライバで書く場合は、1213に加えて1205も「ROLLBACKしてから再実行」の対象に入れます(前章の半端コミットを防ぐため)。
リトライできない処理もあります。トランザクションの中で外部APIの呼び出しやメール送信をしていると、再実行で二重に送られます。外部への副作用はコミット後に回すか、冪等にしてから再実行の対象にします。
ログによる衝突SQLとロック順序の確認
リトライで業務は止まらなくなっても、頻発するデッドロックは性能を削ります。次の順で原因を詰めます。
- DBのログから、衝突した2つのSQLと、それぞれが待っていたロック(テーブル・行)を取り出す(PostgreSQLはサーバーログ、MySQLは
SHOW ENGINE INNODB STATUSかエラーログ、SQL Serverはsystem_health)。 - 2つのSQLを発行しているアプリのコードを特定し、トランザクション内でどの順序で行・テーブルを触っているかを書き出す。
- 順序が逆転している箇所、共有ロックの後に更新している箇所、存在確認してからINSERTしている箇所、インデックスが効かず範囲を広く取っている箇所のどれに当たるかを判定する。
- 後述の予防策で直し、同じ負荷でデッドロックが消えたかをログの件数で確かめる。
PostgreSQLで「今どのセッションが誰を待っているか」を見るには、pg_blocking_pids() が使えます。
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type,
left(query, 45) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
pid | blocked_by | wait_event_type | query
-------+------------+-----------------+------------------------------------------
15082 | {15079} | Lock | UPDATE accounts SET balance=2 WHERE id=1
あわせて log_lock_waits をONにすると、deadlock_timeout を超えたロック待ちが process 15082 still waiting for ShareLock on transaction 765 after 1147.293 ms の形でログに残ります。PostgreSQL 18のドキュメントでも既定はoffで、18.6の新規クラスタでも off だったので、デッドロックを追うときは明示的に有効にします。発生件数の推移は pg_stat_database の deadlocks 列で追えます。18.6で振替の逆転を1回起こすと、この値は0から1に増えました。
デッドロックの予防策:設計で循環待ちを作らない
ロック取得順序の統一による循環待ちの防止
最も効くのは、4条件の循環待ちを崩すことです。複数の行を更新するトランザクションでは、更新の前に対象行を主キー順に FOR UPDATE でまとめてロックします。次の例はPostgreSQLとMySQLで動く構文です(SQL Serverは FOR UPDATE の代わりに UPDLOCK などのテーブルヒントを使います)。
BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
前章と同じ逆向きの振替2本をこの形に書き換えて同時に流すと、後から来たトランザクションは先頭の SELECT ... FOR UPDATE で558ミリ秒待っただけで、デッドロックは起きず両方コミットされ、残高は口座1が950、口座2が1,050になりました。送金の向きに関係なく、両者が必ず口座1→口座2の順でロックを取るためです。テーブルをまたぐ場合も「親テーブル→子テーブル」のように全処理で順序を決めておきます。FOR UPDATE の挙動や、待たずに諦める NOWAIT・ロック中の行を飛ばす SKIP LOCKED は楽観ロックと悲観ロックの違いと使い分けの解説で詳しく扱っています。
トランザクションの短縮とインデックスによるロック範囲の縮小
ロックを持っている時間が長いほど、他のトランザクションと重なる確率が上がります。トランザクションの中でユーザー入力を待つ、外部APIを呼ぶ、大量の行を1回で更新する、といった書き方は避けます。途中までの反映が業務上許される大量更新(ログの削除や集計列の埋め直しなど)は、一定件数ずつのバッチに分けてコミットすると、1回のロック数と保持時間の両方が減ります。件数はロック待ちの発生状況を見ながら調整します。口座振替のように全体で1つの整合性を持つ処理は分割できません。
更新・削除の条件列にインデックスを張るのも同じ効果があります。InnoDBでは全件走査を避けるだけでロックの範囲が大きく狭まります。ただしインデックスを使っても、範囲条件ではギャップへのロックが、外部キーがあれば参照先の行へのロックが残るため、対象行だけになるとは限りません。
lock_timeoutの上限とdeadlock_timeoutの関係
デッドロックではない長いロック待ちで処理が詰まるのを防ぐには、待ちの上限を設定します。PostgreSQLは SET lock_timeout = '500ms' のようにセッション単位で設定でき、別セッションが行ロックを持った状態で18.6で試すと、0.67秒後に ERROR: 55P03: canceling statement due to lock timeout で打ち切られました。上限は画面やAPIの応答時間の要件から決めます。ただし lock_timeout が deadlock_timeout(既定1秒)より短いと、循環待ちでもデッドロック検査の前にロックタイムアウトとして打ち切られ、どのプロセスが輪を作っていたかという詳細はログに出ません。デッドロックの原因を追っている期間は、lock_timeout を deadlock_timeout より長くしておきます。
デッドロック検出の無効化と分離レベル変更の適用条件
MySQLの innodb_deadlock_detect をOFFにすると検出のコストは消えますが、デッドロックは innodb_lock_wait_timeout(既定50秒)まで解けず、しかも1205で戻るのは既定では待っていた文だけです(前章の実測で残高合計が10減った例)。同じ行へ非常に多くのスレッドが集中して検出処理そのものが重くなる環境向けの設定で、デッドロックを減らす手段ではありません。
「分離レベルを下げればデッドロックが減る」も半分だけ正しい話です。MySQLでREPEATABLE READからREAD COMMITTEDに下げるとギャップへのロックが減り、前章の存在確認後INSERTのデッドロックは一意制約違反(1062)に変わりました。ただし口座振替のような更新順序の逆転は、どの分離レベルでも行ロックの取り合いとして起きます。InnoDBのREAD COMMITTEDでは、ロック読み取りとUPDATE・DELETEがギャップロックを掛けず(外部キー検査と重複キー検査は除く)、UPDATE・DELETEで条件に一致しなかった行のロックもWHEREの評価後に解放されます(MySQL 8.4の分離レベルの説明)。ただし読み取りの一貫性も変わるので、ログで衝突の原因がこれらのロックだと確かめ、業務の読み取り要件を満たすと判断できたときに使います。分離レベルとMVCCによる読み取りの仕組みはMVCC(多版型同時実行制御)の解説を参照してください。
よくある質問
デッドロックとは簡単に言うと何ですか?
2つ以上のトランザクションが、互いに相手の持つロックの解放を待って、どちらも進めなくなった状態です。DBは自動で検出して片方をエラーで打ち切ります。PostgreSQL・MySQL・SQL Serverはそのトランザクションの変更をすべて取り消し、Oracleはデッドロックに関係した1文だけを取り消します。
デッドロックが起きたらどう解消しますか?
その場の解消はDBが自動で行います。アプリは打ち切られたトランザクションをBEGINから再実行し(Oracleは戻った文の再試行かROLLBACKかを判断し)、再発防止としてログから衝突したSQLを特定してロックの取得順序を統一します。
デッドロックの原因で多いのは何ですか?
同じ行を異なる順序で更新する処理が同時に走ることです。共有ロックで読んだ行を後から更新するケース、MySQLで存在確認の FOR UPDATE の後にINSERTするケース、インデックスが無く更新時に広い範囲をロックするケースも多く見られます。
ロック待ちタイムアウトとデッドロックは同じですか?
違います。ロック待ちタイムアウトは待ちの形に関係なく待ち時間が上限を超えたもので、MySQLではエラー1205になり、既定では待っていた文だけが戻ります。デッドロックは待ちが循環したもので、エラー1213になりトランザクション全体が戻ります。1205を受けた後にCOMMITすると、それまでの変更だけが確定するので、ROLLBACKしてからやり直します。
1つのトランザクションだけでデッドロックは起きますか?
通常の行ロックの取り合いでは、同じトランザクションが自分の持つロックを待つことはないため起きません(SQL Serverでは並列クエリの実行スレッドやMARSの資源もデッドロックの対象になり、これは例外です)。注意したいのは、アプリが1つのスレッドの中で2本のDB接続を開き、接続1でロックを取ったまま接続2で同じ行を更新するケースです。接続2は接続1のコミットを待ち、接続1はそのスレッドが接続2の完了を待っているので永久に進みません。DBから見えるのは接続2から接続1への一方向の待ちだけなので、デッドロックとして検出されず、lock_timeout などの上限に達するまで止まり続けます。PostgreSQL 18.6とpsycopgで再現すると、接続2は40P01ではなく、lock_timeout に設定した3秒の時点で55P03(ロックタイムアウト)になりました。