Article

🐘 PostgreSQLの部分UNIQUEインデックス:論理削除しても重複登録を防ぐ設計

postgresql, sql, database, backend

ユーザー名を変更・退会できるサービスでは、データを物理的に削除せず、削除日時を設定する場合があります。これは**論理削除(ソフトデリート)**と呼ばれる設計です。

たとえば、次の要件があるとします。

  • 利用中のユーザー同士で同じユーザー名を登録できない
  • 退会済みのユーザー名は再利用できる
  • 2人が同時に登録しても、同じユーザー名を持つ現役ユーザーを作らない

PostgreSQLの部分UNIQUEインデックスなら、こうしたルールをアプリ側の事前チェックだけに依存せず、データベースで保証できます。

普通のUNIQUE制約ではなぜ困る?

CREATE TABLE app_users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  handle TEXT NOT NULL,
  deleted_at TIMESTAMPTZ,
  UNIQUE (handle)
);

この定義は、退会済みの行にも一意性を要求します。退会後の再登録を許可したい要件には合いません。

では UNIQUE(handle, deleted_at) にすればよいのでしょうか。これも違います。通常のPostgreSQLのUNIQUEでは、NULL同士は異なる値として扱われます。deleted_at がNULLの現役行を複数作れてしまうため、肝心の重複防止になりません。

解決策:現役ユーザーだけを対象にする

以下は最初から作る例です。前のテーブル定義とは別に実行してください。

CREATE TABLE app_users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  handle TEXT NOT NULL,
  deleted_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX app_users_active_handle_uq
  ON app_users ((lower(handle)))
  WHERE deleted_at IS NULL;

WHERE deleted_at IS NULL はインデックスに含める行の条件です。この条件に当てはまる現役行だけにUNIQUE(一意性)が適用されます。

lower(handle) は大文字・小文字をそろえる式です。たとえば Alice と alice を同じユーザー名と扱いたい場合に使えます。これは式インデックスと呼ばれる仕組みです。大小文字を区別する仕様なら ON app_users(handle) に変更できます。

退会後の再登録を試す

INSERT INTO app_users(handle) VALUES ('Alice');

-- 行は残し、削除日時だけを設定
UPDATE app_users
SET deleted_at = now()
WHERE lower(handle) = 'alice' AND deleted_at IS NULL;

-- 退会済みなので同じユーザー名で再登録できる
INSERT INTO app_users(handle) VALUES ('alice');

-- 現役ユーザーと衝突するためエラー(SQLSTATE 23505)
INSERT INTO app_users(handle) VALUES ('ALICE');

23505 はUNIQUE違反のSQLSTATE(DBのエラー種別を表すコード)です。APIでは、このコードを検出して「そのユーザー名は使用中です」といった業務上のエラーに変換します。

アプリから「SELECTで空きを確認→INSERT」と2回問い合わせるだけでは、別リクエストが確認と挿入の間に割り込むことがあります。同時実行時の最終的な保証はDB制約に任せるのが重要です。

ON CONFLICTと組み合わせる

重複時にエラーとせず挿入を省略したい場合、次のように書けます。

INSERT INTO app_users(handle)
VALUES ('ALICE')
ON CONFLICT ((lower(handle))) WHERE deleted_at IS NULL
DO NOTHING
RETURNING id;

ON CONFLICT はUNIQUE違反時の代替処理です。DO NOTHING は挿入をやめ、RETURNING は実際に挿入された行の値を返します。衝突した場合、結果は0行です。

今回は部分UNIQUEインデックスのキー式と条件を指定する必要があります。部分UNIQUEインデックスは通常のUNIQUE制約名ではないため、インデックス名を ON CONFLICT ON CONSTRAINT に指定する方法とは区別してください。

検索時にもWHERE条件をそろえる

PREPARE find_active_user(text) AS
SELECT id, handle
FROM app_users
WHERE deleted_at IS NULL
  AND lower(handle) = lower($1);

EXECUTE find_active_user('Alice');

PREPARE はパラメーター付きSQLを準備する操作、$1 は最初の引数です。JavaのJDBCでは PreparedStatement に ? を書いて値を設定することに相当します。

部分インデックスは、クエリのWHERE条件がインデックスの対象条件を満たすとPostgreSQLが計画時に判断できる場合に利用候補となります。そのため deleted_at IS NULL を明示しましょう。検索するユーザー名の値をパラメーター化するのは問題ありませんが、インデックスの条件自体をパラメーター化すると対応を推論できない場合があります。

実際に使われるかどうかは表の件数や統計情報にも左右されます。

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM app_users
WHERE deleted_at IS NULL AND lower(handle) = 'alice';

EXPLAIN は実行計画を見る命令です。ANALYZE を付けるとSQLを実際に実行して測定するため、更新系SQLに使用するときは注意してください。

本番データへ導入する際のハマりどころ

1. 既存の重複を確認する。 既に現役ユーザーに重複があるとUNIQUEインデックスは作成できません。

SELECT lower(handle) AS normalized_handle, count(*) AS active_count
FROM app_users
WHERE deleted_at IS NULL
GROUP BY lower(handle)
HAVING count(*) > 1;

2. 大きなテーブルではロックを考慮する。 通常のインデックス作成は書き込みを待たせる場合があります。サービス運用中に追加するなら、次の CONCURRENTLY が候補です。最初のCREATE INDEXとの二重実行ではなく、既存テーブルへ導入するときの代替手順です。

CREATE UNIQUE INDEX CONCURRENTLY app_users_active_handle_uq
  ON app_users ((lower(handle)))
  WHERE deleted_at IS NULL;

CONCURRENTLY は書き込みを止めにくい一方、処理に時間がかかり、追加の負荷が発生することがあります。トランザクションブロック内では実行できません。 マイグレーションツールが自動でトランザクションを張る場合は設定を確認します。また、構築に失敗すると INVALID 状態のインデックスが残ることがあるため、エラー原因の解消後に状態を確認して復旧します。

3. 退会取り消しにも衝突がある。 退会済みのユーザーを復活させようと deleted_at をNULLに戻すとき、既にそのユーザー名を他の現役ユーザーが使っていればUNIQUE違反になります。

使い分けの結論

要件 選び方
全行で同じ値を禁止 通常のUNIQUE制約
現役行だけで同じ値を禁止 部分UNIQUEインデックス
大文字小文字を無視 lower()などの式インデックス(要件を確認)
同時登録の競合を防ぐ DBの一意性制約+23505のハンドリング
大規模な既存表に追加 事前重複チェック+CONCURRENTLYの検討

「どの行の、どの値を一意にするか」をDBの定義に表現すると、アプリの実装と制約のズレを減らせます。

公式ドキュメント(2026年10月16日確認)