【実務・中級編】 デッドロック検出器 – PostgreSQL

皆さん、こんにちは!今日も元気にデータベースと格闘してますか?

現場でPostgreSQLを使っていると、たまーに「あれ?このトランザクション、なんでこんなに待ってるんだ?」とか「急にトランザクションがROLLBACKされた!」なんて経験、ないですか?特に負荷が高いシステムや、複数のプロセスが同じデータにアクセスするような場面だと、避けられないのが「デッドロック」という現象です。

正直、デッドロックって聞くと、ちょっと身構えちゃう人もいるかもしれません。「うわ、なんか難しそう…」って。でも大丈夫!今日のテーマは、PostgreSQLがどのようにこのデッドロックを検出し、私たちのシステムを守ってくれているのか、その裏側と、僕らがどう付き合っていくべきかについて、現場目線でじっくり解説していきます。

教科書的な説明はもう飽きたって?そうですよね!僕もそう思います。だから今日は、まるで隣に座って話しているかのように、実践的なアドバイスを交えながら進めていきましょう。

PostgreSQLのデッドロック検出器、その仕組みと付き合い方

デッドロックって、そもそも何?

まずは基本の「キ」から。デッドロックとは、複数のトランザクションがお互いに相手が持っているロックの解放を待ち合ってしまい、結果としてどのトランザクションも処理を進められなくなる「膠着状態」のことです。

例えば、こんな状況を想像してみてください。

  • トランザクションA: 「テーブルXの行1をロックしたぞ。次はテーブルYの行2をロックしたいな。」
  • トランザクションB: 「テーブルYの行2をロックしたぜ。次はテーブルXの行1をロックしたいんだけどな。」

ほら、これでデッドロックの完成です。AはYの行2が解放されるのを待ち、BはXの行1が解放されるのを待っています。永遠に待ち続けますよね。もしPostgreSQLが何もしなければ、この2つのトランザクションはずっとリソースを占有したまま、システム全体が遅延する原因になってしまいます。

そこで登場するのが、PostgreSQLの「デッドロック検出器」なんです。

PostgreSQLデッドロック検出器の秘密:ロック待ちグラフ

PostgreSQLは、この厄介なデッドロックを放置しません。ある一定時間(これは後で説明する `deadlock_timeout` で設定できます)トランザクションがロック待ち状態になったら、「もしかしてデッドロック?」と疑い始め、動き出します。

その核心にあるのが「ロック待ちグラフ(Wait-For Graph)」という考え方です。

ロック待ちグラフってどんなもの?

PostgreSQLは、現在実行中のトランザクションと、それらがどのロックを保持していて、どのロックを待っているのか、という情報を常に内部で管理しています。この関係を図にしたものがロック待ちグラフです。

  • ノード: 各トランザクション
  • エッジ: 「このトランザクションは、あのトランザクションが保持しているロックを待っている」という関係

例えば、先ほどの例だと、

`トランザクションA –[待ち]–> トランザクションB` (Bが持つYのロックをAが待つ)
`トランザクションB –[待ち]–> トランザクションA` (Aが持つXのロックをBが待つ)

といったエッジが生成されます。

循環を検出する!

デッドロック検出器は、このロック待ちグラフを定期的に走査し、「循環(サイクル)」がないかをチェックします。グラフの中に循環が見つかった瞬間、それがデッドロックの発生を意味します。

「あ、これ、お互い待ち合ってるぞ!」とPostgreSQLが気づくわけですね。

犠牲者の選定とトランザクションの強制終了

デッドロックが検出されたら、PostgreSQLはどうするでしょうか?両方のトランザクションを待ち続けさせるわけにはいきません。どちらか一方を強制的に終了させ、残りのトランザクションが処理を進められるようにする必要があります。

PostgreSQLは、デッドロックを構成するトランザクションの中から「デッドロックの犠牲者(deadlock victim)」を1つ選びます。犠牲者となったトランザクションは `ROLLBACK` され、保持していたロックをすべて解放します。これにより、もう一方のトランザクションは待っていたロックを獲得し、処理を続行できるようになるわけです。

「じゃあ、どっちが犠牲者になるの?」って思いますよね。PostgreSQLの内部ロジックでは、デッドロックに関わるトランザクションの中で、最も新しいトランザクション(つまり、デッドロック検出時に最も若いXIDを持つトランザクション) が犠牲者に選ばれる傾向があります。これは実装の詳細なのであまり深く気にしなくてもいいですが、一応知っておくと「あ、これだからこっちが落ちたのか」と納得できるかもしれません。

`deadlock_timeout` の役割

このデッドロック検出のトリガーとなるのが `deadlock_timeout` パラメータです。

これは、ロック待ち状態のトランザクションがデッドロック検出をトリガーするまでに待機する時間(ミリ秒) を指定します。デフォルトは `1000ms` (1秒) です。

SHOW deadlock_timeout;

deadlock_timeout
——————
1s
(1 row)

この値が短いと、デッドロックの検出が早まりますが、単なる長いロック待ちも頻繁にチェックすることになるため、システムリソースを消費する可能性もあります。逆に長すぎると、デッドロックが発生してから解決されるまでに時間がかかり、システムが膠着する時間が伸びてしまいます。

基本的にはデフォルトのままで問題ないことが多いですが、システムの特性やパフォーマンス要件に応じて調整を検討することもアリです。ただし、短くしすぎると、検出処理自体のオーバーヘッドが馬鹿にならなくなることもあるので、慎重にね。

デッドロックを体験してみよう!具体的な使用例とコード

理論ばかりじゃつまらないですよね。実際にデッドロックを発生させて、PostgreSQLの挙動を見てみましょう。

2つのターミナル(またはDBクライアント)を用意してください。

準備

— サンプルテーブルの作成
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
balance INT NOT NULL
);

— データ投入
INSERT INTO accounts (balance) VALUES (1000), (2000);

ターミナル1 (セッション1)

— 1. トランザクション開始
BEGIN;

— 2. id=1のレコードをロック (UPDATEで排他ロックがかかる)
UPDATE accounts SET balance = balance – 100 WHERE id = 1;

— 3. ここで少し待つ。まだコミットしない。
— (ターミナル2で次の操作を行う)

— 4. id=2のレコードを更新しようとするが、ターミナル2がロックしているため待機
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

ターミナル2 (セッション2)

— 1. トランザクション開始
BEGIN;

— 2. id=2のレコードをロック
UPDATE accounts SET balance = balance – 200 WHERE id = 2;

— 3. ここで少し待つ。まだコミットしない。
— (ターミナル1で次の操作を行う)

— 4. id=1のレコードを更新しようとするが、ターミナル1がロックしているため待機
UPDATE accounts SET balance = balance + 200 WHERE id = 1;

さあ、ターミナル1とターミナル2で、それぞれ `UPDATE … WHERE id = 1;` と `UPDATE … WHERE id = 2;` の実行を交互に行ってみてください。

しばらくすると、どちらかのセッションで、以下のようなエラーが発生するはずです。

ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 678; blocked by process 54321.
Process 54321 waits for ShareLock on transaction 910; blocked by process 12345.
HINT: See server log for full deadlock information.

そして、エラーが発生しなかった方のセッションは、無事に処理を続行し、コミットできるようになります。これがまさにデッドロックが検出され、片方のトランザクションが犠牲になった瞬間です。

ログを確認しよう!

PostgreSQLのサーバーログを見ると、もっと詳しい情報が記録されています。

LOG: process 12345 detected deadlock while waiting for ShareLock on transaction 678 after 1000.000 ms
DETAIL: Process 12345 waits for ShareLock on transaction 678.
Process 54321 waits for ShareLock on transaction 910.
… and so on
HINT: See server log for full deadlock information.
STATEMENT: UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 678; blocked by process 54321.
Process 54321 waits for ShareLock on transaction 910; blocked by process 12345.
HINT: See server log for full deadlock information.

`deadlock detected` のメッセージだけでなく、どのプロセスがどのトランザクションを待ち、それがどのプロセスによってブロックされているのか、という詳細な情報が `DETAIL` に出力されます。これこそが、PostgreSQLが内部で構築したロック待ちグラフの情報なんですね。

`pg_locks` でロック状況を確認する

デッドロックが発生する前に、現在のロック状況を確認したい場合は、`pg_locks` ビューが非常に役立ちます。

SELECT
pid,
usename,
mode,
granted,
state,
query
FROM
pg_stat_activity psa
JOIN
pg_locks pl ON psa.pid = pl.pid
WHERE
psa.datname = current_database()
ORDER BY
pid, granted DESC;

このクエリを実行すると、どのプロセスがどのリソース(テーブル、タプル、トランザクションIDなど)に対して、どのようなモードのロックを保持しているか(`granted = true`)、あるいは待っているか(`granted = false`)が分かります。デッドロックになりそうな兆候(お互いの待ち状態)を察知するのに使えるかもしれません。

デッドロックを回避するためのヒント

デッドロック検出器は素晴らしい機能ですが、そもそもデッドロックを発生させないに越したことはありません。完全にゼロにするのは難しいですが、発生頻度を劇的に減らすためのプラクティスをいくつか紹介します。

1. ロックを獲得する順序を統一する:
最も効果的なのがこれ。複数のリソース(テーブルの行など)をロックする必要がある場合、常に同じ順序でロックを獲得するようにアプリケーションを設計します。
先ほどの例で言えば、「常に `id` の昇順でロックする」というルールを設ければ、デッドロックは発生しません。

2. トランザクションを短く保つ:
トランザクションがロックを保持する時間をできるだけ短くすることで、他のトランザクションとの競合機会を減らします。長時間かかる処理は、可能な限りトランザクション外で行うようにしましょう。

3. 適切なインデックスを使用する:
`WHERE` 句で指定する列にインデックスがないと、PostgreSQLは意図しない範囲のロック(テーブルロックなど)を獲得してしまうことがあります。適切なインデックスは、必要な行だけを効率的にロックするのに役立ちます。

4. ロックレベルを考慮する:
PostgreSQLにはさまざまなロックモードがありますが、デフォルトの `READ COMMITTED` 分離レベルでは、`UPDATE` や `DELETE` は行レベルの排他ロック (`RowExclusiveLock`) を取得します。必要に応じて `SELECT FOR UPDATE` などを活用し、アプリケーションの要件に合ったロックモードを明示的に指定することも重要です。

5. 不必要なロックを避ける:
例えば、`SELECT … FOR UPDATE` を使う際、本当に更新するつもりのない行までロックしないように注意しましょう。

実務での注意点とアドバイス

デッドロック検出器はPostgreSQLが自動でやってくれるので安心ですが、僕らエンジニアが知っておくべき、そして準備しておくべきこともあります。

1. デッドロックは「常に起こりうるもの」として設計する心構え

どんなに頑張って設計しても、デッドロックを完全にゼロにすることは非常に難しいです。特に、ユーザーの操作順序が読めないWebアプリケーションなどでは、まれに発生する可能性があります。だから、「デッドロックが発生した時はどうするか」まで含めて設計することが大切です。

2. アプリケーション側でのリトライ処理の重要性

デッドロックの犠牲になったトランザクションは `ROLLBACK` されます。この時、アプリケーションは `deadlock detected` エラーを受け取ります。このエラーを受け取ったら、ただ失敗とするのではなく、一定時間待ってからトランザクションを再試行するロジックを組み込むのが、堅牢なシステムを作る上で非常に重要です。

例えば、こんな擬似コードです。

try_count = 0
max_retries = 3
while try_count < max_retries: try: # ここでデータベースへのトランザクション処理を実行 execute_transaction() print("トランザクション成功!") break # 成功したらループを抜ける except DeadlockError as e: # デッドロックエラーを検知 print(f"デッドロック検出!({try_count + 1}/{max_retries}回目)") try_count += 1 if try_count < max_retries: # 少し待ってからリトライ (ランダムな待ち時間を推奨) import time, random time.sleep(random.uniform(0.1, 0.5)) else: print("リトライ回数を超過しました。") raise e # リトライ回数を超えたら諦める ランダムな時間待つのは、複数のデッドロックトランザクションが同時にリトライして、またデッドロックになる「デッドロックの連鎖」を防ぐためです。

3. `deadlock_timeout` のチューニングは慎重に

前述の通り、このパラメータはデッドロック検出の頻度と解決までの時間に影響します。安易に短くしすぎると、PostgreSQLがロック待ちグラフのチェックに費やすリソースが増え、かえってパフォーマンスに悪影響を与えることがあります。逆に長くしすぎると、デッドロックによるシステムの停止時間が伸びてしまいます。

基本的にはデフォルト(1秒)で十分機能しますが、もしデッドロックが頻繁に発生し、かつその解決に時間がかかりすぎていると感じるなら、調整を検討する価値はあります。ただし、その際は十分な負荷テストを行い、影響範囲を把握することを強くお勧めします。

4. ログ監視の重要性

PostgreSQLのログには、デッドロックが発生したという重要な情報が記録されます。このログを監視ツールで適切に収集・分析することで、デッドロックの発生頻度や影響を把握し、システムの改善につなげることができます。「あれ?ログにデッドロックって出てるぞ…」って気づけるように、ちゃんとログは見ておこうね。

まとめ

今日はPostgreSQLのデッドロック検出器について、その仕組みから具体的な挙動、そして実務での付き合い方まで、一歩踏み込んで解説しました。

PostgreSQLは、内部で賢くロック待ちグラフを管理し、デッドロックを検出して自動的に解決してくれます。これは非常に強力な機能であり、僕らが安心してデータベースを使える大きな理由の一つです。

しかし、この自動検出に甘んじるだけでなく、

  • デッドロックの発生メカニズムを理解し、できるだけ回避する設計を心がけること
  • 発生してしまった場合のアプリケーション側のリカバリロジックを準備すること

これらが、堅牢で安定したシステムを構築する上で、非常に重要になります。

デッドロックは、データベースシステムを使う上で避けて通れない課題ですが、その仕組みを正しく理解し、適切な対策を講じることで、恐れることはありません。今日の話が、皆さんの日々の開発や運用の一助になれば幸いです。

それでは、また次の記事でお会いしましょう!Happy Hacking!

コメント

タイトルとURLをコピーしました