UNIQUE制約という名の「沈黙の守護者」――PostgreSQL内部構造とパフォーマンスの深淵
PostgreSQLを長年触っていると、`UNIQUE`制約を単なる「重複を防ぐための保険」としか見ていないエンジニアによく出会います。しかし、大規模トラフィックを捌くデータベースの設計において、この制約は単なる制約以上の、極めて重要な「内部構造のトリガー」です。
今日は、教科書的な説明はすっ飛ばして、なぜ`UNIQUE`が物理層でどう振る舞い、そしてどんな時に我々の首を絞めるのか、その深淵を覗いてみましょう。
—
1. 物理層での挙動:B-treeインデックスの「裏側」
`UNIQUE`制約を定義した瞬間、PostgreSQLは裏で自動的にB-treeインデックスを作成します。これは周知の事実ですが、実務家として意識すべきは「データ整合性のための検索」と「インデックス保守コスト」のトレードオフです。
挿入(INSERT)のたびに、PostgreSQLはB-treeをリーフノードまでトラバースし、エントリが存在しないことを確認してから書き込みを行います。ここで興味深いのは、MVCC(多版同時実行制御)との兼ね合いです。
PostgreSQLの`UNIQUE`インデックスは、たとえ削除済み(`xmin`/`xmax`で無効化された)レコードであっても、インデックスエントリ自体は保持し続けます。つまり、デッドタプルが蓄積していても、インデックスのサイズは簡単には縮まないのです。これが、頻繁にINSERT/DELETEを繰り返すテーブルでインデックスが肥大化し、検索効率がジリジリと低下していく隠れた原因の一つです。
2. パフォーマンストラブルの温床:競合の連鎖
高負荷環境で`UNIQUE`制約が引き起こす最大の難敵は、「ロック競合」です。
複数のトランザクションが同時に同じ値を挿入しようとすると、先頭のトランザクションがコミットするまで、後続のトランザクションは`UNIQUE`インデックスのリーフノードの同じエントリ(またはその周辺)に対するロックで待機します。
- トラブルシューティングの勘所:
もし、大量のINSERTを行っている際にデッドロックや待機イベント(`lwlock:IndexInsertLock`など)が観測されたら、それはアプリケーションの設計を見直すシグナルです。
- アプリケーション側で、既に存在しそうな値を事前にチェックしていませんか?(いわゆる「Check-then-Insert」のアンチパターン)
- データベースの制約に頼るなら、競合が発生した際に適切にリトライする「楽観的制御」の設計が不可欠です。
3. NULLの扱いに潜む「仕様の罠」
PostgreSQLの`UNIQUE`制約において、`NULL`は「重複」とみなされません。標準SQLの仕様に忠実な挙動ですが、これがバグの原因になることがよくあります。
「特定のカラムのみユニークにしたいが、NULLは複数許容したい」という要件ならこれだけで良いのですが、もし「NULLであっても、特定の値についてはユニークにしたい」といった複雑な要件がある場合、通常の制約では太刀打ちできません。
そんな時に取り出す「魔法の武器」が、部分インデックス(Partial Index)です。
CREATE UNIQUE INDEX idx_unique_email_active
ON users (email)
WHERE email IS NOT NULL;
これを使うことで、インデックスサイズを最小限に抑えつつ、必要な範囲でだけユニーク性を担保する。この「ピンポイントな設計」こそが、熟練エンジニアの仕事だと私は思います。
4. 最後に:制約を「思考」する
`UNIQUE`制約は、単なるバリデーションツールではありません。データベースの「真実の所在」を規定するルールです。
設計時にこの制約を貼るかどうか迷ったら、まずは「データの一貫性が崩れた時に、コストを払ってリカバリするのか、それとも最初から物理的に防ぐのか」を自問してください。前者の場合はアプリケーション側のハンドリングが必要ですが、後者の場合はPostgreSQLの堅牢なエンジンに任せるのが、長期的には最もメンテナンスコストが低くなるはずです。
データベースは、正直な箱です。私たちが書いた制約の通りに、淡々と、しかし確実に動いてくれます。だからこそ、その内部で起きていることまで想像力を働かせたいものですね。
それでは、また次回の深掘りでお会いしましょう。
コメント