データベースのソフトデリートパターン:PostgreSQLとMySQLによる安全なレコード削除・復元・インデックス最適化

Database tutorial - IT technology blog
Database tutorial - IT technology blog

物理削除の問題点

キャリアの初期、あるSaaSプロダクトに携わっていたとき、サポート担当者が誤って顧客のアカウントを削除してしまったことがあった。バックアップは十分に新しいものがなく、データは永遠に失われた。あの一件だけで、データ削除に対するアプローチが根本から変わった。

ハードデリート — DELETE FROM users WHERE id = 42 — はデフォルトで取り消しができない。本番システムにおいて、それはリスクそのものだ。ユーザーは誤ったボタンを押す。スクリプトにはバグがある。管理者はWHERE句を打ち間違える。データが消えたとき、顧客からの信頼も一緒に消える。

私はMySQL、PostgreSQL、MongoDBを十数種類のプロジェクトで使ってきた。データベースによってデータのライフサイクル管理は異なるが、リレーショナルシステムで「削除」ボタンが必要な機能を実装するときは、必ずソフトデリートを選ぶ。何度も助けられてきた。

ソフトデリートとは何か

ソフトデリートとは、レコードを物理的に削除する代わりに「削除済み」としてマークすることだ。行はデータベースに残り、アプリケーションのクエリがそれをスキップするようにフラグを立てるだけだ。

最も一般的な実装は、NULL許容のタイムスタンプカラムを使う方法だ:

ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL;

ユーザーが「削除」されるとdeleted_at = NOW()をセットする。アクティブな状態ではNULLのままにする。削除を考慮すべきすべてのクエリにWHERE deleted_at IS NULLを追加する。

チームによってはis_deleted BOOLEAN DEFAULT FALSEというboolean型を好む場合もある。私はそれに反対する立場だ。タイムスタンプ版なら削除がいつ発生したかがわかる — 監査、GDPRの30日間保持ウィンドウ、深夜2時の本番インシデントのデバッグには、その情報が欠かせない。

追加すべきコアカラム

  • deleted_at TIMESTAMP NULL — レコードがソフトデリートされた日時
  • deleted_by INTEGER NULL REFERENCES users(id)誰が削除したか(任意だが有用)

is_deletedというbooleanカラムは不要だ。冗長であり、deleted_atと同期がずれる可能性がある別のカラムが増えるだけだ。deleted_at IS NULLをチェックすれば十分だ。

PostgreSQLでのソフトデリート実装

スキーマの設定

CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INTEGER NOT NULL REFERENCES users(id),
  total       NUMERIC(10, 2) NOT NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
  deleted_at  TIMESTAMP NULL
);

ソフトデリートと復元のクエリ

-- 注文をソフトデリートする
UPDATE orders
SET deleted_at = NOW()
WHERE id = 101 AND deleted_at IS NULL;

-- ソフトデリートされた注文を復元する
UPDATE orders
SET deleted_at = NULL
WHERE id = 101;

-- アクティブな注文のみ一覧表示
SELECT * FROM orders
WHERE deleted_at IS NULL
ORDER BY created_at DESC;

-- 削除済みの注文のみ一覧表示(管理者向け復元画面)
SELECT * FROM orders
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC;

部分インデックス — パフォーマンスの鍵

PostgreSQLは部分インデックスをサポートしている — 特定の条件を満たす行だけをカバーするインデックスだ。ソフトデリートでは、アクティブな行だけにインデックスを張り、削除済みの行は完全に無視できる:

-- アクティブなレコードのみインデックス化 — サイズが小さく、はるかに高速
CREATE INDEX idx_orders_user_active
ON orders (user_id)
WHERE deleted_at IS NULL;

-- アクティブなレコードの時系列クエリ用
CREATE INDEX idx_orders_created_active
ON orders (created_at DESC)
WHERE deleted_at IS NULL;

これがないと、user_idの通常のインデックスには削除済みの行も含まれる。クエリプランナーはフィルタリングの前にすべてをスキャンしてしまう。500万行のテーブルで80%がソフトデリート済みだとすると、1クエリあたり400万回の無駄な読み込みが発生する。部分インデックスにより、処理対象を約100万行のアクティブなレコードに絞り込める。MySQLは部分インデックスをネイティブサポートしていない — ソフトデリートを多用するスキーマでPostgreSQLを好む理由のひとつだ。

ビューでアプリケーションコードをシンプルに

すべてのクエリにWHERE deleted_at IS NULLを散りばめるのはメンテナンスの悪夢だ。エンジニアが一度忘れると、削除済みのレコードがUIに漏れ出す。ビューでそのフィルターを一元管理できる:

CREATE VIEW active_orders AS
SELECT * FROM orders
WHERE deleted_at IS NULL;

-- アプリケーションのクエリがシンプルになる
SELECT * FROM active_orders WHERE user_id = 5;

PostgreSQLではこれを更新可能なビューにすることもできる。ひとつ注意点として、チーム全員がビューの存在を把握し、一貫して使用することを徹底しなければならない。ordersテーブルに対する直接クエリが混在すると、コードレビューで見落とされやすくなる。

MySQLでのソフトデリート実装

スキーマとクエリ

MySQLの構文はここでもPostgreSQLとほぼ同じだ:

CREATE TABLE products (
  id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(255) NOT NULL,
  price      DECIMAL(10,2) NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  deleted_at DATETIME NULL DEFAULT NULL,
  INDEX idx_deleted_at (deleted_at)
);
-- ソフトデリート
UPDATE products SET deleted_at = NOW() WHERE id = 55;

-- 復元
UPDATE products SET deleted_at = NULL WHERE id = 55;

-- アクティブなレコードのみ
SELECT * FROM products WHERE deleted_at IS NULL;

MySQLでの部分インデックスの代替策

MySQLは部分インデックスをサポートしていない。最も近い代替策はdeleted_atを先頭にした複合インデックスだ。WHERE deleted_at IS NULL AND user_id = ?でフィルタリングするクエリは、左端のカラムから先にヒットする:

-- 複合インデックス: deleted_at + user_id
ALTER TABLE orders ADD INDEX idx_soft_user (deleted_at, user_id);

MySQL 8.0.13では関数インデックスが追加され、よりクリーンな選択肢が使えるようになった — 部分インデックスフィルターのように機能する生成カラムだ:

-- MySQL 8.0.13+: 部分インデックスの代替として生成カラムを使用
ALTER TABLE orders
  ADD COLUMN is_active TINYINT(1) GENERATED ALWAYS AS (IF(deleted_at IS NULL, 1, NULL)) STORED,
  ADD INDEX idx_active_user (is_active, user_id);

生成カラムはアクティブな行に1を、削除済みの行にNULLを格納する。インデックスを張ることで、PostgreSQLの部分インデックスに近いフィルタリング動作が得られる。

実プロジェクトからの実践的なヒント

ユニーク制約には特別な対処が必要

多くのチームが引っかかる落とし穴がある。users.emailUNIQUE制約があるとしよう。ユーザーがアカウントをソフトデリートし、同じメールアドレスで再登録しようとする。古い行が「削除済み」であるにもかかわらず、制約が新規登録をブロックしてしまう。

PostgreSQLの解決策は部分ユニークインデックスだ:

-- アクティブな(削除されていない)行にのみユニーク制約を適用
CREATE UNIQUE INDEX uq_users_email_active
ON users (email)
WHERE deleted_at IS NULL;

MySQLの場合、ひとつの選択肢は削除時にメールアドレスをdeleted_usersアーカイブテーブルに移すことだ。もうひとつはユニークキーにdeleted_atを含める方法だが、同じメールアドレスが2回ソフトデリートされると破綻する。

外部キーとカスケード

ソフトデリートはON DELETE CASCADEのように自動的にカスケードしない。ユーザーをソフトデリートしても、そのユーザーの注文はアクティブのままテーブルに残る。伝播は明示的に処理する必要がある。私の標準的なアプローチ:

  • 親子関係では、親とすべての子単一のトランザクションでソフトデリートする。
  • アプリケーションレベルのロジックまたはトリガーを使って、ソフトデリートをチェーン下位に伝播させる。
-- アトミック: ユーザーとそのすべての注文を同時にソフトデリート
BEGIN;
  UPDATE orders SET deleted_at = NOW() WHERE user_id = 42 AND deleted_at IS NULL;
  UPDATE users SET deleted_at = NOW() WHERE id = 42;
COMMIT;

古いソフトデリート済みレコードのパージ

ソフトデリートはストレージ面でコストがかからないわけではない。何年もソフトデリート済みの行が積み重なったテーブルは肥大化し、バックアップが遅くなり、インデックスのメンテナンスが複雑になる。私は定期的なクリーンアップジョブを実行して、90日以上前にソフトデリートされたレコードをハードデリートしている — そのウィンドウはデータ保持ポリシーやGDPRの義務に合わせて調整すること:

-- PostgreSQL: 90日以上前にソフトデリートされたレコードをハードデリート
DELETE FROM orders
WHERE deleted_at IS NOT NULL
  AND deleted_at < NOW() - INTERVAL '90 days';

MySQL版:

DELETE FROM orders
WHERE deleted_at IS NOT NULL
  AND deleted_at < DATE_SUB(NOW(), INTERVAL 90 DAY);

これをcronジョブまたはデータベースイベントとしてスケジュールする。必ずステージング環境でテストしてから本番に適用すること。大きなテーブルでは、テーブルロックを長時間保持しないよう、一度に10,000行ずつバッチ削除するとよい。

ORMとの統合

これを手動で組み込む必要はない。Sequelize、Django ORM、LaravelのEloquent、SQLAlchemyはすべて — 組み込みまたは小さなプラグイン経由で — ソフトデリートをサポートしている。PythonのSQLAlchemyでは:

from sqlalchemy import Column, DateTime, func
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass

class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, nullable=False)
    deleted_at = Column(DateTime, nullable=True, default=None)

    def soft_delete(self, session):
        self.deleted_at = func.now()
        session.flush()

    def restore(self, session):
        self.deleted_at = None
        session.flush()

    @classmethod
    def active(cls, session):
        return session.query(cls).filter(cls.deleted_at.is_(None))

SQLAlchemy-SoftDeleteライブラリやDjangoのカスタムマネージャーを使えば、モデルレベルでフィルターをグローバルに適用できる。つまり、そのモデルに対するすべてのクエリが自動的に削除済み行を除外するため、チームの誰もWHERE句の追加を忘れる心配がない — それを追加する責任が誰にもないからだ。

ソフトデリートが適さないケース

ソフトデリートがすべての状況に合うわけではない。私がスキップする3つのケースを挙げる:

  • 大量のイベントやログテーブル — 行は設計上イミュータブルだ。削除フラグではなく、アーカイブまたはパーティショニングを使うべきだ。
  • 極めて大きなテーブル — レコードの80%がソフトデリート済みになると、アクティブなクエリのたびにテーブルの大半をフィルタリングすることになる。アーカイブテーブルへの分割がより明確な解決策だ。
  • GDPRの厳格な消去権(第17条)への対応が必要な場合 — ソフトデリート単体では第17条を満たせない。個人データを実際に削除するパージの仕組みが別途必要だ。ソフトデリートは復元のための猶予期間を与えるが、本当の消去の代替にはならない。

仕組みを定着させるために

deleted_atカラムひとつ。スキーマ変更はそれだけだ。しかし、それによって誤った削除を5秒以内に復元できるようになった — UPDATEひとつで完了する。PostgreSQLの部分インデックスは、削除済みの行が積み重なっても読み取りパフォーマンスを維持する。90日間のパージジョブにより、テーブルが際限なく肥大化するのを防ぐ。

フィルターが一貫して適用されなければ、何の意味もない。インフラレベルで確実に適用すること — ビュー、ORMクエリスコープ、またはPostgreSQLの行レベルセキュリティを使って。一度正しく設定してしまえば、チームの残りのメンバーは二度と気にしなくていい。

Share: