0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Last updated at Posted at 2026-10-20

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

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

  • 利用中のユーザー同士で同じユーザー名を登録できない
  • 退会済みのユーザー名は再利用できる
  • 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日確認)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?